mirror of
https://github.com/stablyai/orca.git
synced 2026-09-28 08:02:43 +00:00
Implement responsive browser toolbar overflow with tool folding (#23265)
Toolbar buttons fold into a dropdown menu when space is constrained, preserving minimum address bar width. ArtifactPublishButton gains controlled popover state with optional virtual anchoring. Tour-pinned tools stay visible while others fold in priority order.
This commit is contained in:
@@ -1,12 +1,28 @@
|
||||
// @vitest-environment happy-dom
|
||||
|
||||
import '@testing-library/jest-dom/vitest'
|
||||
import type { ReactNode } from 'react'
|
||||
import { createRef, type ReactNode } from 'react'
|
||||
import { cleanup, render, screen, waitFor } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
type ArtifactPublishButtonMocks = {
|
||||
connect: ReturnType<typeof vi.fn>
|
||||
openSettingsPage: ReturnType<typeof vi.fn>
|
||||
openSettingsTarget: ReturnType<typeof vi.fn>
|
||||
getPublishedLink: ReturnType<typeof vi.fn>
|
||||
copyLink: ReturnType<typeof vi.fn>
|
||||
openLink: ReturnType<typeof vi.fn>
|
||||
publish: ReturnType<typeof vi.fn>
|
||||
openPopover: ((open: boolean) => void) | null
|
||||
closePopover: ((event: Event) => void) | null
|
||||
state: {
|
||||
orcaProfileAuthStatus: Record<string, unknown>
|
||||
settings: { artifactSharingEnabled: boolean }
|
||||
}
|
||||
}
|
||||
|
||||
const mocks = vi.hoisted<ArtifactPublishButtonMocks>(() => ({
|
||||
connect: vi.fn(),
|
||||
openSettingsPage: vi.fn(),
|
||||
openSettingsTarget: vi.fn(),
|
||||
@@ -14,9 +30,10 @@ const mocks = vi.hoisted(() => ({
|
||||
copyLink: vi.fn(),
|
||||
openLink: vi.fn(),
|
||||
publish: vi.fn(),
|
||||
openPopover: null as ((open: boolean) => void) | null,
|
||||
openPopover: null,
|
||||
closePopover: null,
|
||||
state: {
|
||||
orcaProfileAuthStatus: { configured: true, state: 'connected' } as Record<string, unknown>,
|
||||
orcaProfileAuthStatus: { configured: true, state: 'connected' },
|
||||
settings: { artifactSharingEnabled: true }
|
||||
}
|
||||
}))
|
||||
@@ -42,7 +59,17 @@ vi.mock('@/components/ui/popover', () => ({
|
||||
mocks.openPopover = onOpenChange ?? null
|
||||
return <>{children}</>
|
||||
},
|
||||
PopoverContent: ({ children }: { children: ReactNode }) => <div>{children}</div>,
|
||||
PopoverAnchor: ({ children }: { children?: ReactNode }) => <>{children}</>,
|
||||
PopoverContent: ({
|
||||
children,
|
||||
onCloseAutoFocus
|
||||
}: {
|
||||
children: ReactNode
|
||||
onCloseAutoFocus?: (event: Event) => void
|
||||
}) => {
|
||||
mocks.closePopover = onCloseAutoFocus ?? null
|
||||
return <div>{children}</div>
|
||||
},
|
||||
PopoverTrigger: ({ children }: { children: ReactNode }) => (
|
||||
<span onClick={() => mocks.openPopover?.(true)}>{children}</span>
|
||||
)
|
||||
@@ -81,6 +108,7 @@ describe('ArtifactPublishButton', () => {
|
||||
mocks.getPublishedLink.mockResolvedValue(null)
|
||||
mocks.copyLink.mockResolvedValue(true)
|
||||
mocks.openPopover = null
|
||||
mocks.closePopover = null
|
||||
mocks.state.orcaProfileAuthStatus = { configured: true, state: 'connected' }
|
||||
mocks.state.settings = { artifactSharingEnabled: true }
|
||||
})
|
||||
@@ -101,6 +129,31 @@ describe('ArtifactPublishButton', () => {
|
||||
expect(screen.getByRole('button', { name: 'Update shared content' })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('supports a controlled virtual anchor without rendering a second trigger', async () => {
|
||||
const anchorRef = createRef<HTMLButtonElement>()
|
||||
render(
|
||||
<>
|
||||
<button ref={anchorRef}>Overflow</button>
|
||||
<ArtifactPublishButton
|
||||
sourceKey="/repo/report.md"
|
||||
createRequest={vi.fn()}
|
||||
anchorRef={anchorRef}
|
||||
open
|
||||
onOpenChange={vi.fn()}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
|
||||
expect(screen.queryByRole('button', { name: 'Share as artifact' })).toBeNull()
|
||||
expect(await screen.findByRole('button', { name: 'Generate link' })).toBeInTheDocument()
|
||||
|
||||
const focus = vi.spyOn(anchorRef.current!, 'focus')
|
||||
const closeEvent = new Event('close', { cancelable: true })
|
||||
mocks.closePopover?.(closeEvent)
|
||||
expect(closeEvent.defaultPrevented).toBe(true)
|
||||
expect(focus).toHaveBeenCalledWith({ preventScroll: true })
|
||||
})
|
||||
|
||||
it('offers sign-in and blocks confirmation while signed out', async () => {
|
||||
const user = userEvent.setup()
|
||||
mocks.state.orcaProfileAuthStatus = { configured: true, state: 'local' }
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { useEffect, useRef, useState, type RefObject } from 'react'
|
||||
import { ArrowRight, Loader2, Share2 } from 'lucide-react'
|
||||
import type { ArtifactWriteRequest } from '../../../../shared/artifacts'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
|
||||
import { Popover, PopoverAnchor, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
import { cn } from '@/lib/utils'
|
||||
@@ -21,14 +21,26 @@ export function ArtifactPublishButton({
|
||||
sourceKey,
|
||||
createRequest,
|
||||
className,
|
||||
disabled
|
||||
disabled,
|
||||
anchorRef,
|
||||
open: controlledOpen,
|
||||
onOpenChange
|
||||
}: {
|
||||
sourceKey: string
|
||||
createRequest: () => Promise<ArtifactWriteRequest>
|
||||
className?: string
|
||||
disabled?: boolean
|
||||
}): React.JSX.Element {
|
||||
const [open, setOpen] = useState(false)
|
||||
} & (
|
||||
| { anchorRef?: never; open?: never; onOpenChange?: never }
|
||||
| {
|
||||
anchorRef?: RefObject<HTMLButtonElement | null>
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
}
|
||||
)): React.JSX.Element {
|
||||
const [ownOpen, setOwnOpen] = useState(false)
|
||||
const open = controlledOpen ?? ownOpen
|
||||
const setOpen = onOpenChange ?? setOwnOpen
|
||||
const [publishing, setPublishing] = useState(false)
|
||||
const [lookupRevision, setLookupRevision] = useState(0)
|
||||
const [linkLookup, setLinkLookup] = useState<PublishedLinkLookup | null>(null)
|
||||
@@ -111,25 +123,32 @@ export function ArtifactPublishButton({
|
||||
)
|
||||
return (
|
||||
<Popover open={open} onOpenChange={(nextOpen) => !busy && setOpen(nextOpen)}>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
className={cn('shrink-0', className)}
|
||||
disabled={blocked}
|
||||
aria-label={label}
|
||||
>
|
||||
{publishing ? <Loader2 className="animate-spin" /> : <Share2 />}
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom" sideOffset={4}>
|
||||
{label}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
{anchorRef ? (
|
||||
<PopoverAnchor
|
||||
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: DOM refs are transiently null before mount, though Radix's virtualRef type omits that lifecycle state.
|
||||
virtualRef={anchorRef as RefObject<HTMLButtonElement>}
|
||||
/>
|
||||
) : (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
className={cn('shrink-0', className)}
|
||||
disabled={blocked}
|
||||
aria-label={label}
|
||||
>
|
||||
{publishing ? <Loader2 className="animate-spin" /> : <Share2 />}
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom" sideOffset={4}>
|
||||
{label}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
|
||||
<PopoverContent
|
||||
ref={popoverContentRef}
|
||||
@@ -141,6 +160,13 @@ export function ArtifactPublishButton({
|
||||
event.preventDefault()
|
||||
popoverContentRef.current?.focus({ preventScroll: true })
|
||||
}}
|
||||
onCloseAutoFocus={(event) => {
|
||||
if (!anchorRef) {
|
||||
return
|
||||
}
|
||||
event.preventDefault()
|
||||
anchorRef.current?.focus({ preventScroll: true })
|
||||
}}
|
||||
>
|
||||
<div className="space-y-1 border-b border-border/60 px-4 py-3.5">
|
||||
<h3 className="text-sm font-semibold">
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
* STA-3811: imports never touch the Google cookie family, so every import menu must disclose it
|
||||
* at the moment of decision.
|
||||
*/
|
||||
import { act, type ReactNode } from 'react'
|
||||
import { act, createRef, type ReactNode } from 'react'
|
||||
import { createRoot, type Root } from 'react-dom/client'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import en from '@/i18n/locales/en.json'
|
||||
@@ -67,6 +67,12 @@ describe('cookie-import Google disclosure footer', () => {
|
||||
onImportFromFile={vi.fn()}
|
||||
viewportPresetId={null}
|
||||
onApplyViewportPreset={vi.fn()}
|
||||
overflow={{
|
||||
triggerRef: createRef<HTMLButtonElement>(),
|
||||
tools: [],
|
||||
deferUntilClose: vi.fn(),
|
||||
onMenuCloseAutoFocus: vi.fn()
|
||||
}}
|
||||
/>
|
||||
)
|
||||
],
|
||||
|
||||
+9
-2
@@ -26,10 +26,12 @@ import { translate } from '@/i18n/i18n'
|
||||
|
||||
type BrowserImportHintButtonProps = {
|
||||
profileId: string | null
|
||||
compact?: boolean
|
||||
}
|
||||
|
||||
export function BrowserImportHintButton({
|
||||
profileId
|
||||
profileId,
|
||||
compact = false
|
||||
}: BrowserImportHintButtonProps): React.JSX.Element | null {
|
||||
const [open, setOpen] = useState(false)
|
||||
const [importMenuOpen, setImportMenuOpen] = useState(false)
|
||||
@@ -178,7 +180,12 @@ export function BrowserImportHintButton({
|
||||
data-contextual-tour-target="browser-import-hint"
|
||||
>
|
||||
<Import className="size-3.5" />
|
||||
{translate('auto.components.browser.pane.BrowserImportHintButton.b24fef25be', 'Import')}
|
||||
{compact
|
||||
? null
|
||||
: translate(
|
||||
'auto.components.browser.pane.BrowserImportHintButton.b24fef25be',
|
||||
'Import'
|
||||
)}
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent align="end" side="bottom" sideOffset={6} className="w-80 p-3">
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
import { BrowserToolbarMenuDropdown } from './browser-toolbar-menu-dropdown'
|
||||
import { BrowserToolbarProfileDialogs } from './browser-toolbar-profile-dialogs'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
import type { BrowserChromeOverflowMenuProps } from './browser-chrome-folded-tools'
|
||||
|
||||
type BrowserToolbarMenuProps = {
|
||||
currentProfileId: string | null
|
||||
@@ -20,6 +21,7 @@ type BrowserToolbarMenuProps = {
|
||||
viewportPresetId: BrowserViewportPresetId | null
|
||||
onDestroyWebview: () => void
|
||||
isActive: boolean
|
||||
overflow: BrowserChromeOverflowMenuProps
|
||||
}
|
||||
|
||||
export function BrowserToolbarMenu({
|
||||
@@ -28,7 +30,8 @@ export function BrowserToolbarMenu({
|
||||
browserPageId,
|
||||
viewportPresetId,
|
||||
onDestroyWebview,
|
||||
isActive
|
||||
isActive,
|
||||
overflow
|
||||
}: BrowserToolbarMenuProps): React.JSX.Element {
|
||||
const browserSessionProfiles = useAppStore((s) => s.browserSessionProfiles)
|
||||
const detectedBrowsers = useAppStore((s) => s.detectedBrowsers)
|
||||
@@ -241,6 +244,7 @@ export function BrowserToolbarMenu({
|
||||
onImportFromFile={() => void handleImportFromFile()}
|
||||
viewportPresetId={viewportPresetId}
|
||||
onApplyViewportPreset={applyViewportPreset}
|
||||
overflow={overflow}
|
||||
/>
|
||||
|
||||
<BrowserToolbarProfileDialogs
|
||||
|
||||
+109
@@ -0,0 +1,109 @@
|
||||
import { Crosshair, MessageSquarePlus } from 'lucide-react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
import { cn } from '@/lib/utils'
|
||||
import type { GrabIntent } from '../describe-page/browser-page-types'
|
||||
|
||||
/** The in-guest element picker, driving both Grab (copy) and Annotate (comment). */
|
||||
export type BrowserChromeElementTools = {
|
||||
/** The intent the picker is armed for right now, or null when it is idle. */
|
||||
activeIntent: GrabIntent | null
|
||||
onStartIntent: (intent: GrabIntent) => void
|
||||
disabled: boolean
|
||||
grabShortcutLabel: string
|
||||
annotationCount: number
|
||||
}
|
||||
|
||||
export function BrowserChromeElementToolButtons({
|
||||
tools,
|
||||
showGrab,
|
||||
showAnnotate,
|
||||
showTourAnchors
|
||||
}: {
|
||||
tools: BrowserChromeElementTools
|
||||
showGrab: boolean
|
||||
showAnnotate: boolean
|
||||
showTourAnchors: boolean
|
||||
}): React.JSX.Element {
|
||||
return (
|
||||
<>
|
||||
{showGrab ? (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span className="inline-flex">
|
||||
<Button
|
||||
size="icon"
|
||||
variant={tools.activeIntent === 'copy' ? 'default' : 'ghost'}
|
||||
className={cn(
|
||||
'h-8 w-8',
|
||||
tools.activeIntent === 'copy' &&
|
||||
'bg-foreground/80 text-background hover:bg-foreground/90'
|
||||
)}
|
||||
onClick={() => tools.onStartIntent('copy')}
|
||||
disabled={tools.disabled}
|
||||
aria-label={translate(
|
||||
'auto.components.browser.pane.BrowserPane.fdfc7fe0ef',
|
||||
'Grab page element'
|
||||
)}
|
||||
{...(showTourAnchors
|
||||
? { 'data-contextual-tour-target': 'browser-grab-control' }
|
||||
: {})}
|
||||
>
|
||||
<Crosshair className="size-4" />
|
||||
</Button>
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom" sideOffset={4}>
|
||||
{translate(
|
||||
'auto.components.browser.pane.BrowserPane.acbe79fd01',
|
||||
'Grab page element ({{value0}})',
|
||||
{ value0: tools.grabShortcutLabel }
|
||||
)}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
) : null}
|
||||
|
||||
{showAnnotate ? (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
{/* Why: disabled buttons drop hover events, so the tooltip needs an enabled wrapper. */}
|
||||
<span className="inline-flex">
|
||||
<Button
|
||||
size="icon"
|
||||
variant={tools.activeIntent === 'annotate' ? 'default' : 'ghost'}
|
||||
className={cn(
|
||||
'relative h-8 w-8',
|
||||
tools.activeIntent === 'annotate' &&
|
||||
'bg-foreground/80 text-background hover:bg-foreground/90'
|
||||
)}
|
||||
onClick={() => tools.onStartIntent('annotate')}
|
||||
disabled={tools.disabled}
|
||||
aria-label={translate(
|
||||
'auto.components.browser.pane.BrowserPane.fc9be38f6f',
|
||||
'Annotate page element'
|
||||
)}
|
||||
{...(showTourAnchors
|
||||
? { 'data-contextual-tour-target': 'browser-annotation-control' }
|
||||
: {})}
|
||||
>
|
||||
<MessageSquarePlus className="size-4" />
|
||||
{tools.annotationCount > 0 ? (
|
||||
<span className="absolute -top-1 -right-1 flex min-w-4 items-center justify-center rounded-full bg-primary px-1 text-[10px] leading-4 text-primary-foreground">
|
||||
{tools.annotationCount}
|
||||
</span>
|
||||
) : null}
|
||||
</Button>
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom" sideOffset={4}>
|
||||
{translate(
|
||||
'auto.components.browser.pane.BrowserPane.fc9be38f6f',
|
||||
'Annotate page element'
|
||||
)}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
) : null}
|
||||
</>
|
||||
)
|
||||
}
|
||||
+107
@@ -0,0 +1,107 @@
|
||||
// @vitest-environment happy-dom
|
||||
import { cleanup, fireEvent, render, screen } from '@testing-library/react'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { Share2 } from 'lucide-react'
|
||||
|
||||
vi.mock('@/components/ui/dropdown-menu', () => ({
|
||||
DropdownMenuItem: ({
|
||||
children,
|
||||
onSelect,
|
||||
role,
|
||||
'aria-checked': ariaChecked
|
||||
}: {
|
||||
children: React.ReactNode
|
||||
onSelect: () => void
|
||||
role?: string
|
||||
'aria-checked'?: boolean
|
||||
}) => (
|
||||
<button role={role} aria-checked={ariaChecked} onClick={onSelect}>
|
||||
{children}
|
||||
</button>
|
||||
),
|
||||
DropdownMenuSeparator: () => <hr />,
|
||||
DropdownMenuShortcut: ({ children }: { children: React.ReactNode }) => <span>{children}</span>
|
||||
}))
|
||||
|
||||
import { BrowserChromeFoldedMenuItems } from './browser-chrome-folded-tools'
|
||||
|
||||
afterEach(cleanup)
|
||||
|
||||
describe('BrowserChromeFoldedMenuItems', () => {
|
||||
it('runs ordinary actions directly', () => {
|
||||
const onSelect = vi.fn()
|
||||
render(
|
||||
<BrowserChromeFoldedMenuItems
|
||||
tools={[{ stage: 'share', label: 'Share', icon: Share2, onSelect }]}
|
||||
deferUntilClose={vi.fn()}
|
||||
/>
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Share' }))
|
||||
expect(onSelect).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('defers popover actions until the menu closes', () => {
|
||||
const onSelect = vi.fn()
|
||||
const deferUntilClose = vi.fn()
|
||||
render(
|
||||
<BrowserChromeFoldedMenuItems
|
||||
tools={[
|
||||
{
|
||||
stage: 'share',
|
||||
label: 'Share',
|
||||
icon: Share2,
|
||||
onSelect,
|
||||
deferUntilMenuClose: true
|
||||
}
|
||||
]}
|
||||
deferUntilClose={deferUntilClose}
|
||||
/>
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Share' }))
|
||||
expect(onSelect).not.toHaveBeenCalled()
|
||||
expect(deferUntilClose).toHaveBeenCalledWith(onSelect)
|
||||
})
|
||||
|
||||
it('preserves active state for folded toggle tools', () => {
|
||||
render(
|
||||
<BrowserChromeFoldedMenuItems
|
||||
tools={[
|
||||
{
|
||||
stage: 'draw',
|
||||
label: 'Draw',
|
||||
icon: Share2,
|
||||
onSelect: vi.fn(),
|
||||
active: true
|
||||
}
|
||||
]}
|
||||
deferUntilClose={vi.fn()}
|
||||
/>
|
||||
)
|
||||
|
||||
expect(
|
||||
screen.getByRole('menuitemcheckbox', { name: 'Draw' }).getAttribute('aria-checked')
|
||||
).toBe('true')
|
||||
})
|
||||
|
||||
it('gives toggle tools their own section, split from the actions by a divider', () => {
|
||||
const { container } = render(
|
||||
<BrowserChromeFoldedMenuItems
|
||||
tools={[
|
||||
{ stage: 'draw', label: 'Draw', icon: Share2, onSelect: vi.fn(), active: false },
|
||||
{ stage: 'share', label: 'Share', icon: Share2, onSelect: vi.fn() }
|
||||
]}
|
||||
deferUntilClose={vi.fn()}
|
||||
/>
|
||||
)
|
||||
|
||||
// Rendered order: Draw, divider, Share, closing divider.
|
||||
expect(Array.from(container.querySelectorAll('button, hr')).map((el) => el.tagName)).toEqual([
|
||||
'BUTTON',
|
||||
'HR',
|
||||
'BUTTON',
|
||||
'HR'
|
||||
])
|
||||
})
|
||||
})
|
||||
+106
@@ -0,0 +1,106 @@
|
||||
import { Check } from 'lucide-react'
|
||||
import type { LucideIcon } from 'lucide-react'
|
||||
import {
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuShortcut
|
||||
} from '@/components/ui/dropdown-menu'
|
||||
import type { BrowserChromeFoldStage } from './use-browser-chrome-tool-fold'
|
||||
|
||||
export type BrowserChromeFoldedTool = {
|
||||
stage: BrowserChromeFoldStage
|
||||
label: string
|
||||
icon: LucideIcon
|
||||
onSelect: () => void
|
||||
disabled?: boolean
|
||||
shortcut?: string
|
||||
count?: number
|
||||
/** Present for toggle tools, including false, so the menu preserves their pressed state. */
|
||||
active?: boolean
|
||||
/**
|
||||
* Run only after the menu has closed and skip its focus return. A tool that opens its own
|
||||
* popover needs this: focus landing back on the ⋯ trigger would dismiss that popover at once.
|
||||
*/
|
||||
deferUntilMenuClose?: boolean
|
||||
}
|
||||
|
||||
export type BrowserChromeOverflowMenuProps = {
|
||||
triggerRef: React.RefObject<HTMLButtonElement | null>
|
||||
tools: readonly BrowserChromeFoldedTool[]
|
||||
deferUntilClose: (action: () => void) => void
|
||||
onMenuCloseAutoFocus: (event: Event) => void
|
||||
}
|
||||
|
||||
function FoldedToolContent({ tool }: { tool: BrowserChromeFoldedTool }): React.JSX.Element {
|
||||
const showActiveCheck = tool.active === true
|
||||
const showTrailing = showActiveCheck || Boolean(tool.count) || Boolean(tool.shortcut)
|
||||
return (
|
||||
<>
|
||||
<tool.icon className="size-3.5" />
|
||||
{tool.label}
|
||||
{showTrailing ? (
|
||||
// Why: a leading check column would push these icon rows out of line with the plain rows.
|
||||
<span className="ml-auto flex items-center gap-2">
|
||||
{showActiveCheck ? <Check className="size-3.5 text-foreground" /> : null}
|
||||
{tool.count ? (
|
||||
<span className="flex min-w-4 items-center justify-center rounded-full bg-primary px-1 text-[10px] leading-4 text-primary-foreground">
|
||||
{tool.count}
|
||||
</span>
|
||||
) : tool.shortcut ? (
|
||||
<DropdownMenuShortcut>{tool.shortcut}</DropdownMenuShortcut>
|
||||
) : null}
|
||||
</span>
|
||||
) : null}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function FoldedToolRow({
|
||||
tool,
|
||||
deferUntilClose
|
||||
}: {
|
||||
tool: BrowserChromeFoldedTool
|
||||
deferUntilClose: (action: () => void) => void
|
||||
}): React.JSX.Element {
|
||||
const onSelect = (): void =>
|
||||
tool.deferUntilMenuClose ? deferUntilClose(tool.onSelect) : tool.onSelect()
|
||||
return (
|
||||
<DropdownMenuItem
|
||||
disabled={tool.disabled}
|
||||
onSelect={onSelect}
|
||||
// Why: keeps toggle semantics without the checkbox item's reserved leading column,
|
||||
// which would misalign this row's icon against the plain rows in the same menu.
|
||||
{...(tool.active === undefined
|
||||
? {}
|
||||
: { role: 'menuitemcheckbox', 'aria-checked': tool.active })}
|
||||
>
|
||||
<FoldedToolContent tool={tool} />
|
||||
</DropdownMenuItem>
|
||||
)
|
||||
}
|
||||
|
||||
/** The toolbar tools that no longer fit, rendered at the top of a surface's ⋯ menu. */
|
||||
export function BrowserChromeFoldedMenuItems({
|
||||
tools,
|
||||
deferUntilClose
|
||||
}: Pick<BrowserChromeOverflowMenuProps, 'tools' | 'deferUntilClose'>): React.JSX.Element | null {
|
||||
if (tools.length === 0) {
|
||||
return null
|
||||
}
|
||||
// Why: the element/markup toggles fold first and carry on/off state, so they read as their own
|
||||
// section ahead of the one-shot actions below rather than as one flat tool list.
|
||||
const toggleTools = tools.filter((tool) => tool.active !== undefined)
|
||||
const actionTools = tools.filter((tool) => tool.active === undefined)
|
||||
return (
|
||||
<>
|
||||
{toggleTools.map((tool) => (
|
||||
<FoldedToolRow key={tool.stage} tool={tool} deferUntilClose={deferUntilClose} />
|
||||
))}
|
||||
{toggleTools.length > 0 && actionTools.length > 0 ? <DropdownMenuSeparator /> : null}
|
||||
{actionTools.map((tool) => (
|
||||
<FoldedToolRow key={tool.stage} tool={tool} deferUntilClose={deferUntilClose} />
|
||||
))}
|
||||
<DropdownMenuSeparator />
|
||||
</>
|
||||
)
|
||||
}
|
||||
+158
@@ -0,0 +1,158 @@
|
||||
// @vitest-environment happy-dom
|
||||
import { cleanup, fireEvent, render, screen } from '@testing-library/react'
|
||||
import { useState } from 'react'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { BrowserChromeFoldStage } from './use-browser-chrome-tool-fold'
|
||||
import type { BrowserChromeFoldedTool } from './browser-chrome-folded-tools'
|
||||
|
||||
const mocks = vi.hoisted(() => {
|
||||
const stages: readonly BrowserChromeFoldStage[] = []
|
||||
return { folded: new Set<BrowserChromeFoldStage>(), stages }
|
||||
})
|
||||
|
||||
vi.mock('./use-browser-chrome-tool-fold', () => ({
|
||||
BROWSER_CHROME_FOLD_ORDER: [
|
||||
'import-label',
|
||||
'external',
|
||||
'devtools',
|
||||
'share',
|
||||
'import',
|
||||
'draw',
|
||||
'grab',
|
||||
'annotate'
|
||||
],
|
||||
useBrowserChromeToolFold: (_rowRef: unknown, stages: readonly BrowserChromeFoldStage[]) => {
|
||||
mocks.stages = stages
|
||||
return mocks.folded
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('./browser-navigation-control-row', () => ({
|
||||
BrowserNavigationControlRow: ({ children }: { children: React.ReactNode }) => (
|
||||
<div>{children}</div>
|
||||
)
|
||||
}))
|
||||
|
||||
vi.mock('../annotate/MarkupDrawButton', () => ({
|
||||
MarkupDrawButton: () => <button>Draw</button>
|
||||
}))
|
||||
|
||||
vi.mock('./browser-chrome-element-tool-buttons', () => ({
|
||||
BrowserChromeElementToolButtons: () => null
|
||||
}))
|
||||
|
||||
import { BrowserChromeToolbar } from './browser-chrome-toolbar'
|
||||
|
||||
const controls = {
|
||||
canGoBack: false,
|
||||
canGoForward: false,
|
||||
loading: false,
|
||||
goBack: vi.fn(),
|
||||
goForward: vi.fn(),
|
||||
reload: vi.fn(),
|
||||
navigate: vi.fn()
|
||||
}
|
||||
const emptyOverflowMenu = (): null => null
|
||||
|
||||
beforeEach(() => {
|
||||
mocks.folded = new Set()
|
||||
mocks.stages = []
|
||||
})
|
||||
|
||||
afterEach(cleanup)
|
||||
|
||||
describe('BrowserChromeToolbar', () => {
|
||||
it('does not duplicate an action already provided by the surface menu', () => {
|
||||
mocks.folded = new Set(['devtools', 'external'])
|
||||
let foldedTools: readonly BrowserChromeFoldedTool[] = []
|
||||
|
||||
render(
|
||||
<BrowserChromeToolbar
|
||||
controls={controls}
|
||||
addressSlot={null}
|
||||
elementTools={null}
|
||||
markup={{ active: false, disabled: false, onToggle: vi.fn(), canShowDiscoveryHint: false }}
|
||||
viewSource={{
|
||||
label: 'Open source file',
|
||||
onSelect: vi.fn(),
|
||||
alreadyInOverflowMenu: true
|
||||
}}
|
||||
openExternal={{ label: 'Open with default app', onSelect: vi.fn() }}
|
||||
overflowMenu={(overflow) => {
|
||||
foldedTools = overflow.tools
|
||||
return null
|
||||
}}
|
||||
/>
|
||||
)
|
||||
|
||||
expect(foldedTools.map((tool) => tool.stage)).toEqual(['external'])
|
||||
})
|
||||
|
||||
it('compacts the import hint before removing it', () => {
|
||||
mocks.folded = new Set(['import-label'])
|
||||
const importControl = vi.fn(() => null)
|
||||
|
||||
render(
|
||||
<BrowserChromeToolbar
|
||||
controls={controls}
|
||||
addressSlot={null}
|
||||
importControl={importControl}
|
||||
elementTools={null}
|
||||
markup={{ active: false, disabled: false, onToggle: vi.fn(), canShowDiscoveryHint: false }}
|
||||
viewSource={null}
|
||||
openExternal={null}
|
||||
overflowMenu={emptyOverflowMenu}
|
||||
/>
|
||||
)
|
||||
|
||||
expect(importControl).toHaveBeenCalledWith(true)
|
||||
})
|
||||
|
||||
it('keeps the active tour control out of the fold sequence', () => {
|
||||
render(
|
||||
<BrowserChromeToolbar
|
||||
controls={controls}
|
||||
addressSlot={null}
|
||||
pinnedStage="grab"
|
||||
elementTools={{
|
||||
activeIntent: null,
|
||||
onStartIntent: vi.fn(),
|
||||
disabled: false,
|
||||
grabShortcutLabel: 'Cmd+Shift+C',
|
||||
annotationCount: 0
|
||||
}}
|
||||
markup={{ active: false, disabled: false, onToggle: vi.fn(), canShowDiscoveryHint: false }}
|
||||
viewSource={null}
|
||||
openExternal={null}
|
||||
overflowMenu={emptyOverflowMenu}
|
||||
/>
|
||||
)
|
||||
|
||||
expect(mocks.stages).not.toContain('grab')
|
||||
expect(mocks.stages).toContain('annotate')
|
||||
})
|
||||
|
||||
it('keeps the same share control mounted when it moves into overflow', () => {
|
||||
function StatefulShare(): React.JSX.Element {
|
||||
const [clicks, setClicks] = useState(0)
|
||||
return <button onClick={() => setClicks((count) => count + 1)}>Share state {clicks}</button>
|
||||
}
|
||||
const props = {
|
||||
controls,
|
||||
addressSlot: null,
|
||||
elementTools: null,
|
||||
markup: { active: false, disabled: false, onToggle: vi.fn(), canShowDiscoveryHint: false },
|
||||
shareControl: () => <StatefulShare />,
|
||||
viewSource: null,
|
||||
openExternal: null,
|
||||
overflowMenu: emptyOverflowMenu
|
||||
}
|
||||
const view = render(<BrowserChromeToolbar {...props} />)
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Share state 0' }))
|
||||
|
||||
mocks.folded = new Set(['share'])
|
||||
view.rerender(<BrowserChromeToolbar {...props} />)
|
||||
|
||||
expect(screen.getByRole('button', { name: 'Share state 1' })).not.toBeNull()
|
||||
})
|
||||
})
|
||||
+187
-124
@@ -1,24 +1,35 @@
|
||||
import { cn } from '@/lib/utils'
|
||||
import { Crosshair, ExternalLink, MessageSquarePlus, SquareCode } from 'lucide-react'
|
||||
import { useRef, useState } from 'react'
|
||||
import {
|
||||
Crosshair,
|
||||
ExternalLink,
|
||||
MessageSquarePlus,
|
||||
PenTool,
|
||||
Share2,
|
||||
SquareCode
|
||||
} from 'lucide-react'
|
||||
import type { LucideIcon } from 'lucide-react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
import {
|
||||
BrowserNavigationControlRow,
|
||||
type BrowserNavigationControls
|
||||
} from './browser-navigation-control-row'
|
||||
import { MarkupDrawButton } from '../annotate/MarkupDrawButton'
|
||||
import type { GrabIntent } from '../describe-page/browser-page-types'
|
||||
import type {
|
||||
BrowserChromeFoldedTool,
|
||||
BrowserChromeOverflowMenuProps
|
||||
} from './browser-chrome-folded-tools'
|
||||
import {
|
||||
BROWSER_CHROME_FOLD_ORDER,
|
||||
useBrowserChromeToolFold,
|
||||
type BrowserChromeFoldStage
|
||||
} from './use-browser-chrome-tool-fold'
|
||||
import {
|
||||
BrowserChromeElementToolButtons,
|
||||
type BrowserChromeElementTools
|
||||
} from './browser-chrome-element-tool-buttons'
|
||||
|
||||
/** The in-guest element picker, driving both Grab (copy) and Annotate (comment). */
|
||||
export type BrowserChromeElementTools = {
|
||||
/** The intent the picker is armed for right now, or null when it is idle. */
|
||||
activeIntent: GrabIntent | null
|
||||
onStartIntent: (intent: GrabIntent) => void
|
||||
disabled: boolean
|
||||
grabShortcutLabel: string
|
||||
annotationCount: number
|
||||
}
|
||||
export type { BrowserChromeElementTools } from './browser-chrome-element-tool-buttons'
|
||||
|
||||
export type BrowserChromeMarkupTool = {
|
||||
active: boolean
|
||||
@@ -33,10 +44,40 @@ export type BrowserChromeMarkupTool = {
|
||||
canShowDiscoveryHint: boolean
|
||||
}
|
||||
|
||||
export type BrowserChromeShareControl = {
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
anchorRef?: React.RefObject<HTMLButtonElement | null>
|
||||
}
|
||||
|
||||
export type BrowserChromeToolAction = {
|
||||
onSelect: () => void
|
||||
label: string
|
||||
disabled?: boolean
|
||||
/** True when the surface's overflow menu already exposes this action. */
|
||||
alreadyInOverflowMenu?: boolean
|
||||
}
|
||||
|
||||
function BrowserChromeActionButton({
|
||||
action,
|
||||
icon: Icon
|
||||
}: {
|
||||
action: BrowserChromeToolAction
|
||||
icon: LucideIcon
|
||||
}): React.JSX.Element {
|
||||
return (
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
className="h-7 w-7"
|
||||
onClick={action.onSelect}
|
||||
title={action.label}
|
||||
aria-label={action.label}
|
||||
disabled={action.disabled}
|
||||
>
|
||||
<Icon className="size-4" />
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -59,148 +100,170 @@ export function BrowserChromeToolbar({
|
||||
viewSource,
|
||||
openExternal,
|
||||
overflowMenu,
|
||||
showTourAnchors = false
|
||||
showTourAnchors = false,
|
||||
pinnedStage
|
||||
}: {
|
||||
controls: BrowserNavigationControls
|
||||
addressSlot: React.ReactNode
|
||||
reloadControl?: React.ReactNode
|
||||
reloadLabel?: string
|
||||
/** Cookie import — a browsing session concept; null where there is no session to import into. */
|
||||
importControl?: React.ReactNode
|
||||
importControl?: ((compact: boolean) => React.ReactNode) | null
|
||||
elementTools: BrowserChromeElementTools | null
|
||||
markup: BrowserChromeMarkupTool
|
||||
shareControl?: React.ReactNode
|
||||
shareControl?: (control: BrowserChromeShareControl) => React.ReactNode
|
||||
viewSource: BrowserChromeToolAction | null
|
||||
openExternal: BrowserChromeToolAction | null
|
||||
overflowMenu?: React.ReactNode
|
||||
overflowMenu: (props: BrowserChromeOverflowMenuProps) => React.ReactNode
|
||||
/** Only the browsing pane anchors the contextual tour; a second anchor would steal its steps. */
|
||||
showTourAnchors?: boolean
|
||||
/** Keeps the active contextual-tour control measurable while the remaining tools still fold. */
|
||||
pinnedStage?: BrowserChromeFoldStage
|
||||
}): React.JSX.Element {
|
||||
const rowRef = useRef<HTMLDivElement>(null)
|
||||
const overflowTriggerRef = useRef<HTMLButtonElement>(null)
|
||||
const present: Record<BrowserChromeFoldStage, boolean> = {
|
||||
'import-label': importControl != null,
|
||||
import: importControl != null,
|
||||
external: openExternal !== null,
|
||||
devtools: viewSource !== null,
|
||||
share: shareControl != null,
|
||||
draw: true,
|
||||
grab: elementTools !== null,
|
||||
annotate: elementTools !== null
|
||||
}
|
||||
const stages = BROWSER_CHROME_FOLD_ORDER.filter(
|
||||
(stage) => present[stage] && stage !== pinnedStage
|
||||
)
|
||||
const folded = useBrowserChromeToolFold(rowRef, stages)
|
||||
|
||||
const [sharePopoverOpen, setSharePopoverOpen] = useState(false)
|
||||
const afterMenuCloseRef = useRef<(() => void) | null>(null)
|
||||
|
||||
const foldedTools: BrowserChromeFoldedTool[] = []
|
||||
if (elementTools && folded.has('grab')) {
|
||||
foldedTools.push({
|
||||
stage: 'grab',
|
||||
label: translate('auto.components.browser.pane.BrowserPane.fdfc7fe0ef', 'Grab page element'),
|
||||
icon: Crosshair,
|
||||
onSelect: () => elementTools.onStartIntent('copy'),
|
||||
disabled: elementTools.disabled,
|
||||
shortcut: elementTools.grabShortcutLabel,
|
||||
active: elementTools.activeIntent === 'copy'
|
||||
})
|
||||
}
|
||||
if (elementTools && folded.has('annotate')) {
|
||||
foldedTools.push({
|
||||
stage: 'annotate',
|
||||
label: translate(
|
||||
'auto.components.browser.pane.BrowserPane.fc9be38f6f',
|
||||
'Annotate page element'
|
||||
),
|
||||
icon: MessageSquarePlus,
|
||||
onSelect: () => elementTools.onStartIntent('annotate'),
|
||||
disabled: elementTools.disabled,
|
||||
count: elementTools.annotationCount,
|
||||
active: elementTools.activeIntent === 'annotate'
|
||||
})
|
||||
}
|
||||
if (folded.has('draw')) {
|
||||
foldedTools.push({
|
||||
stage: 'draw',
|
||||
label: translate('auto.components.browser-pane.markup.drawButton', 'Draw on screenshot'),
|
||||
icon: PenTool,
|
||||
onSelect: markup.onToggle,
|
||||
disabled: markup.disabled,
|
||||
active: markup.active
|
||||
})
|
||||
}
|
||||
if (shareControl && folded.has('share')) {
|
||||
foldedTools.push({
|
||||
stage: 'share',
|
||||
label: translate(
|
||||
'auto.components.artifacts.ArtifactPublishButton.a4a49da6af',
|
||||
'Share as artifact'
|
||||
),
|
||||
icon: Share2,
|
||||
onSelect: () => setSharePopoverOpen(true),
|
||||
deferUntilMenuClose: true
|
||||
})
|
||||
}
|
||||
if (viewSource && folded.has('devtools') && !viewSource.alreadyInOverflowMenu) {
|
||||
foldedTools.push({ stage: 'devtools', icon: SquareCode, ...viewSource })
|
||||
}
|
||||
if (openExternal && folded.has('external') && !openExternal.alreadyInOverflowMenu) {
|
||||
foldedTools.push({ stage: 'external', icon: ExternalLink, ...openExternal })
|
||||
}
|
||||
|
||||
const runAfterMenuClose = (action: () => void): void => {
|
||||
afterMenuCloseRef.current = action
|
||||
}
|
||||
const onMenuCloseAutoFocus = (event: Event): void => {
|
||||
const action = afterMenuCloseRef.current
|
||||
if (!action) {
|
||||
return
|
||||
}
|
||||
afterMenuCloseRef.current = null
|
||||
event.preventDefault()
|
||||
action()
|
||||
}
|
||||
const showFoldedAnnotationDot = folded.has('annotate') && (elementTools?.annotationCount ?? 0) > 0
|
||||
|
||||
return (
|
||||
<BrowserNavigationControlRow
|
||||
rowRef={rowRef}
|
||||
controls={controls}
|
||||
addressSlot={addressSlot}
|
||||
reloadControl={reloadControl}
|
||||
reloadLabel={reloadLabel}
|
||||
showTourAnchors={showTourAnchors}
|
||||
>
|
||||
{importControl}
|
||||
{folded.has('import') ? null : importControl?.(folded.has('import-label'))}
|
||||
|
||||
{elementTools ? (
|
||||
<>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span className="inline-flex">
|
||||
<Button
|
||||
size="icon"
|
||||
variant={elementTools.activeIntent === 'copy' ? 'default' : 'ghost'}
|
||||
className={cn(
|
||||
'h-8 w-8',
|
||||
elementTools.activeIntent === 'copy' &&
|
||||
'bg-foreground/80 text-background hover:bg-foreground/90'
|
||||
)}
|
||||
onClick={() => elementTools.onStartIntent('copy')}
|
||||
disabled={elementTools.disabled}
|
||||
aria-label={translate(
|
||||
'auto.components.browser.pane.BrowserPane.fdfc7fe0ef',
|
||||
'Grab page element'
|
||||
)}
|
||||
{...(showTourAnchors
|
||||
? { 'data-contextual-tour-target': 'browser-grab-control' }
|
||||
: {})}
|
||||
>
|
||||
<Crosshair className="size-4" />
|
||||
</Button>
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom" sideOffset={4}>
|
||||
{translate(
|
||||
'auto.components.browser.pane.BrowserPane.acbe79fd01',
|
||||
'Grab page element ({{value0}})',
|
||||
{ value0: elementTools.grabShortcutLabel }
|
||||
)}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
{/* Why: disabled <button> drops hover events, so wrap in a span so the tooltip trigger still fires. */}
|
||||
<span className="inline-flex">
|
||||
<Button
|
||||
size="icon"
|
||||
variant={elementTools.activeIntent === 'annotate' ? 'default' : 'ghost'}
|
||||
className={cn(
|
||||
'relative h-8 w-8',
|
||||
elementTools.activeIntent === 'annotate' &&
|
||||
'bg-foreground/80 text-background hover:bg-foreground/90'
|
||||
)}
|
||||
onClick={() => elementTools.onStartIntent('annotate')}
|
||||
disabled={elementTools.disabled}
|
||||
aria-label={translate(
|
||||
'auto.components.browser.pane.BrowserPane.fc9be38f6f',
|
||||
'Annotate page element'
|
||||
)}
|
||||
{...(showTourAnchors
|
||||
? { 'data-contextual-tour-target': 'browser-annotation-control' }
|
||||
: {})}
|
||||
>
|
||||
<MessageSquarePlus className="size-4" />
|
||||
{elementTools.annotationCount > 0 ? (
|
||||
<span className="absolute -top-1 -right-1 flex min-w-4 items-center justify-center rounded-full bg-primary px-1 text-[10px] leading-4 text-primary-foreground">
|
||||
{elementTools.annotationCount}
|
||||
</span>
|
||||
) : null}
|
||||
</Button>
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom" sideOffset={4}>
|
||||
{translate(
|
||||
'auto.components.browser.pane.BrowserPane.fc9be38f6f',
|
||||
'Annotate page element'
|
||||
)}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</>
|
||||
<BrowserChromeElementToolButtons
|
||||
tools={elementTools}
|
||||
showGrab={!folded.has('grab')}
|
||||
showAnnotate={!folded.has('annotate')}
|
||||
showTourAnchors={showTourAnchors}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
<MarkupDrawButton
|
||||
onClick={markup.onToggle}
|
||||
disabled={markup.disabled}
|
||||
active={markup.active}
|
||||
surfaceActive={markup.canShowDiscoveryHint}
|
||||
/>
|
||||
{folded.has('draw') ? null : (
|
||||
<MarkupDrawButton
|
||||
onClick={markup.onToggle}
|
||||
disabled={markup.disabled}
|
||||
active={markup.active}
|
||||
surfaceActive={markup.canShowDiscoveryHint}
|
||||
/>
|
||||
)}
|
||||
|
||||
{shareControl}
|
||||
{shareControl?.({
|
||||
open: sharePopoverOpen,
|
||||
onOpenChange: setSharePopoverOpen,
|
||||
anchorRef: folded.has('share') ? overflowTriggerRef : undefined
|
||||
})}
|
||||
|
||||
{viewSource ? (
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
className="h-7 w-7"
|
||||
onClick={viewSource.onSelect}
|
||||
title={viewSource.label}
|
||||
aria-label={viewSource.label}
|
||||
disabled={viewSource.disabled}
|
||||
>
|
||||
<SquareCode className="size-4" />
|
||||
</Button>
|
||||
{viewSource && !folded.has('devtools') ? (
|
||||
<BrowserChromeActionButton action={viewSource} icon={SquareCode} />
|
||||
) : null}
|
||||
|
||||
{openExternal ? (
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
className="h-7 w-7"
|
||||
onClick={openExternal.onSelect}
|
||||
title={openExternal.label}
|
||||
aria-label={openExternal.label}
|
||||
disabled={openExternal.disabled}
|
||||
>
|
||||
<ExternalLink className="size-4" />
|
||||
</Button>
|
||||
{openExternal && !folded.has('external') ? (
|
||||
<BrowserChromeActionButton action={openExternal} icon={ExternalLink} />
|
||||
) : null}
|
||||
|
||||
{overflowMenu}
|
||||
<span className="relative inline-flex">
|
||||
{overflowMenu({
|
||||
triggerRef: overflowTriggerRef,
|
||||
tools: foldedTools,
|
||||
deferUntilClose: runAfterMenuClose,
|
||||
onMenuCloseAutoFocus
|
||||
})}
|
||||
{/* Why: keeps pending annotations visible once the annotate button has folded into ⋯. */}
|
||||
{showFoldedAnnotationDot ? (
|
||||
<span className="pointer-events-none absolute top-1 right-1 size-1.5 rounded-full bg-primary ring-2 ring-background" />
|
||||
) : null}
|
||||
</span>
|
||||
</BrowserNavigationControlRow>
|
||||
)
|
||||
}
|
||||
|
||||
+3
@@ -36,6 +36,7 @@ export function BrowserNavigationControlRow({
|
||||
reloadControl,
|
||||
reloadLabel,
|
||||
showTourAnchors = true,
|
||||
rowRef,
|
||||
children
|
||||
}: {
|
||||
controls: BrowserNavigationControls
|
||||
@@ -46,10 +47,12 @@ export function BrowserNavigationControlRow({
|
||||
reloadLabel?: string
|
||||
/** Off for surfaces the browsing tour does not cover — a second anchor would steal its steps. */
|
||||
showTourAnchors?: boolean
|
||||
rowRef?: React.Ref<HTMLDivElement>
|
||||
children?: React.ReactNode
|
||||
}): React.JSX.Element {
|
||||
return (
|
||||
<div
|
||||
ref={rowRef}
|
||||
className="relative z-10 flex shrink-0 items-center gap-2 border-b border-border/70 bg-background/95 px-3 py-1.5"
|
||||
{...(showTourAnchors ? { 'data-contextual-tour-target': 'browser-toolbar' } : {})}
|
||||
>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { Dispatch, RefObject, SetStateAction } from 'react'
|
||||
import { ArtifactPublishButton } from '@/components/artifacts/ArtifactPublishButton'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
import { useAppStore } from '@/store'
|
||||
import type { BrowserReloadTrigger } from '../navigate/browser-reload-action'
|
||||
import BrowserAddressBar from './BrowserAddressBar'
|
||||
import { BrowserChromeToolbar } from './browser-chrome-toolbar'
|
||||
@@ -102,9 +103,20 @@ export function BrowserPageToolbar({
|
||||
currentBrowserUrl: string
|
||||
externalUrl: string | null
|
||||
}): React.JSX.Element {
|
||||
const browserTourStep = useAppStore((state) =>
|
||||
state.activeContextualTourId === 'browser' ? state.activeContextualTourStepIndex : null
|
||||
)
|
||||
const pinnedStage =
|
||||
browserTourStep === 0
|
||||
? ('grab' as const)
|
||||
: browserTourStep === 1
|
||||
? ('annotate' as const)
|
||||
: undefined
|
||||
|
||||
return (
|
||||
<BrowserChromeToolbar
|
||||
showTourAnchors
|
||||
pinnedStage={pinnedStage}
|
||||
controls={{
|
||||
canGoBack: canGoBack || Boolean(convertedFrom),
|
||||
canGoForward: canGoForward || Boolean(convertedTo),
|
||||
@@ -161,7 +173,9 @@ export function BrowserPageToolbar({
|
||||
onHardReload={() => runReloadTrigger('hard-reload')}
|
||||
/>
|
||||
}
|
||||
importControl={<BrowserImportHintButton profileId={sessionProfileId} />}
|
||||
importControl={(compact) => (
|
||||
<BrowserImportHintButton profileId={sessionProfileId} compact={compact} />
|
||||
)}
|
||||
elementTools={{
|
||||
activeIntent: grab.state !== 'idle' ? grabIntent : null,
|
||||
onStartIntent: startGrabIntent,
|
||||
@@ -176,13 +190,16 @@ export function BrowserPageToolbar({
|
||||
canShowDiscoveryHint: isActive
|
||||
}}
|
||||
shareControl={
|
||||
shareableArtifactFile ? (
|
||||
<ArtifactPublishButton
|
||||
sourceKey={shareableArtifactFile.filePath}
|
||||
className="h-7 w-7"
|
||||
createRequest={() => readBrowserHtmlArtifactRequest(currentBrowserUrl)}
|
||||
/>
|
||||
) : null
|
||||
shareableArtifactFile
|
||||
? (control) => {
|
||||
const props = {
|
||||
sourceKey: shareableArtifactFile.filePath,
|
||||
className: 'h-7 w-7',
|
||||
createRequest: () => readBrowserHtmlArtifactRequest(currentBrowserUrl)
|
||||
}
|
||||
return <ArtifactPublishButton {...props} {...control} />
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
viewSource={{
|
||||
onSelect: () => void window.api.browser.openDevTools({ browserPageId }),
|
||||
@@ -204,7 +221,7 @@ export function BrowserPageToolbar({
|
||||
),
|
||||
disabled: !externalUrl
|
||||
}}
|
||||
overflowMenu={
|
||||
overflowMenu={(overflow) => (
|
||||
<BrowserToolbarMenu
|
||||
currentProfileId={sessionProfileId}
|
||||
workspaceId={workspaceId}
|
||||
@@ -212,8 +229,9 @@ export function BrowserPageToolbar({
|
||||
viewportPresetId={viewportPresetId}
|
||||
onDestroyWebview={() => destroyPersistentWebview(browserPageId)}
|
||||
isActive={isActive}
|
||||
overflow={overflow}
|
||||
/>
|
||||
}
|
||||
)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
+17
-2
@@ -30,6 +30,10 @@ type DetectedBrowserEntry = {
|
||||
}
|
||||
import { BROWSER_VIEWPORT_PRESETS } from '../../../../../shared/browser-viewport-presets'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
import {
|
||||
BrowserChromeFoldedMenuItems,
|
||||
type BrowserChromeOverflowMenuProps
|
||||
} from './browser-chrome-folded-tools'
|
||||
|
||||
type BrowserToolbarMenuDropdownProps = {
|
||||
menuOpen: boolean
|
||||
@@ -45,6 +49,7 @@ type BrowserToolbarMenuDropdownProps = {
|
||||
onImportFromFile: () => void
|
||||
viewportPresetId: BrowserViewportPresetId | null
|
||||
onApplyViewportPreset: (nextId: BrowserViewportPresetId | null) => void
|
||||
overflow: BrowserChromeOverflowMenuProps
|
||||
}
|
||||
|
||||
export function BrowserToolbarMenuDropdown({
|
||||
@@ -60,12 +65,14 @@ export function BrowserToolbarMenuDropdown({
|
||||
onImportFromBrowser,
|
||||
onImportFromFile,
|
||||
viewportPresetId,
|
||||
onApplyViewportPreset
|
||||
onApplyViewportPreset,
|
||||
overflow
|
||||
}: BrowserToolbarMenuDropdownProps): React.JSX.Element {
|
||||
return (
|
||||
<DropdownMenu modal={false} open={menuOpen} onOpenChange={onMenuOpenChange}>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
ref={overflow.triggerRef}
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
className="h-8 w-8"
|
||||
@@ -77,7 +84,15 @@ export function BrowserToolbarMenuDropdown({
|
||||
<Ellipsis className="size-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-56">
|
||||
<DropdownMenuContent
|
||||
align="end"
|
||||
className="w-56"
|
||||
onCloseAutoFocus={overflow.onMenuCloseAutoFocus}
|
||||
>
|
||||
<BrowserChromeFoldedMenuItems
|
||||
tools={overflow.tools}
|
||||
deferUntilClose={overflow.deferUntilClose}
|
||||
/>
|
||||
{allProfiles.map((profile) => {
|
||||
const isSelectedProfile = profile.id === effectiveProfileId
|
||||
return (
|
||||
|
||||
+159
@@ -0,0 +1,159 @@
|
||||
// @vitest-environment happy-dom
|
||||
import { act, cleanup, render } from '@testing-library/react'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { useRef } from 'react'
|
||||
import { BROWSER_CHROME_ADDRESS_SLOT_ATTRIBUTE } from './browser-chrome-address-slot'
|
||||
import {
|
||||
BROWSER_CHROME_ADDRESS_MIN_WIDTH_PX,
|
||||
BROWSER_CHROME_FOLD_ORDER,
|
||||
useBrowserChromeToolFold
|
||||
} from './use-browser-chrome-tool-fold'
|
||||
|
||||
const FIXED_WIDTH = 100
|
||||
const TOOL_WIDTH = 30
|
||||
|
||||
// A flex row where the address takes whatever the fixed controls and visible tools leave over.
|
||||
const layout = { rowWidth: 600, visibleTools: BROWSER_CHROME_FOLD_ORDER.length }
|
||||
let resizeCallbacks: (() => void)[] = []
|
||||
|
||||
function Host({
|
||||
onFolded,
|
||||
stages = BROWSER_CHROME_FOLD_ORDER
|
||||
}: {
|
||||
onFolded: (count: number) => void
|
||||
stages?: readonly (typeof BROWSER_CHROME_FOLD_ORDER)[number][]
|
||||
}): React.JSX.Element {
|
||||
const rowRef = useRef<HTMLDivElement>(null)
|
||||
const folded = useBrowserChromeToolFold(rowRef, stages)
|
||||
layout.visibleTools = stages.length - folded.size
|
||||
onFolded(folded.size)
|
||||
return (
|
||||
<div ref={rowRef} data-testid="row">
|
||||
<div {...{ [BROWSER_CHROME_ADDRESS_SLOT_ATTRIBUTE]: 'true' }} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function contentWidth(): number {
|
||||
return FIXED_WIDTH + TOOL_WIDTH * layout.visibleTools
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
resizeCallbacks = []
|
||||
vi.stubGlobal(
|
||||
'ResizeObserver',
|
||||
class {
|
||||
constructor(callback: () => void) {
|
||||
resizeCallbacks.push(callback)
|
||||
}
|
||||
observe(): void {}
|
||||
disconnect(): void {}
|
||||
}
|
||||
)
|
||||
vi.spyOn(HTMLElement.prototype, 'clientWidth', 'get').mockImplementation(() => layout.rowWidth)
|
||||
vi.spyOn(HTMLElement.prototype, 'scrollWidth', 'get').mockImplementation(() =>
|
||||
Math.max(layout.rowWidth, contentWidth())
|
||||
)
|
||||
vi.spyOn(HTMLElement.prototype, 'getBoundingClientRect').mockImplementation(() =>
|
||||
DOMRect.fromRect({ width: Math.max(0, layout.rowWidth - contentWidth()) })
|
||||
)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
cleanup()
|
||||
vi.restoreAllMocks()
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
function resizeTo(width: number): void {
|
||||
layout.rowWidth = width
|
||||
act(() => resizeCallbacks.forEach((callback) => callback()))
|
||||
}
|
||||
|
||||
function expectedFolded(width: number): number {
|
||||
const room = width - FIXED_WIDTH - BROWSER_CHROME_ADDRESS_MIN_WIDTH_PX
|
||||
const fits = Math.max(0, Math.floor(room / TOOL_WIDTH))
|
||||
return Math.max(0, BROWSER_CHROME_FOLD_ORDER.length - fits)
|
||||
}
|
||||
|
||||
describe('useBrowserChromeToolFold', () => {
|
||||
it('keeps every tool inline when the address has its minimum width', () => {
|
||||
let folded = -1
|
||||
layout.rowWidth = 600
|
||||
render(<Host onFolded={(count) => (folded = count)} />)
|
||||
expect(folded).toBe(0)
|
||||
})
|
||||
|
||||
it('folds just enough tools to give the address its minimum width', () => {
|
||||
let folded = -1
|
||||
layout.rowWidth = 300
|
||||
render(<Host onFolded={(count) => (folded = count)} />)
|
||||
expect(folded).toBe(expectedFolded(300))
|
||||
expect(folded).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it('unfolds as the row widens and refolds as it narrows', () => {
|
||||
let folded = -1
|
||||
layout.rowWidth = 600
|
||||
render(<Host onFolded={(count) => (folded = count)} />)
|
||||
|
||||
resizeTo(260)
|
||||
expect(folded).toBe(expectedFolded(260))
|
||||
|
||||
resizeTo(400)
|
||||
expect(folded).toBe(expectedFolded(400))
|
||||
|
||||
resizeTo(700)
|
||||
expect(folded).toBe(0)
|
||||
})
|
||||
|
||||
it('folds everything but lets the address shrink once no tool is left', () => {
|
||||
let folded = -1
|
||||
layout.rowWidth = 150
|
||||
render(<Host onFolded={(count) => (folded = count)} />)
|
||||
expect(folded).toBe(BROWSER_CHROME_FOLD_ORDER.length)
|
||||
})
|
||||
|
||||
it('leaves a hidden (zero-width) row alone', () => {
|
||||
let folded = -1
|
||||
layout.rowWidth = 0
|
||||
render(<Host onFolded={(count) => (folded = count)} />)
|
||||
expect(folded).toBe(0)
|
||||
})
|
||||
|
||||
it('measures when a hidden row becomes visible', () => {
|
||||
let folded = -1
|
||||
layout.rowWidth = 0
|
||||
render(<Host onFolded={(count) => (folded = count)} />)
|
||||
|
||||
resizeTo(300)
|
||||
expect(folded).toBe(expectedFolded(300))
|
||||
})
|
||||
|
||||
it('discards measurements when the available tools change', () => {
|
||||
let folded = -1
|
||||
layout.rowWidth = 300
|
||||
const view = render(<Host onFolded={(count) => (folded = count)} />)
|
||||
expect(folded).toBeGreaterThan(0)
|
||||
|
||||
view.rerender(<Host onFolded={(count) => (folded = count)} stages={['grab', 'annotate']} />)
|
||||
expect(folded).toBe(0)
|
||||
|
||||
resizeTo(150)
|
||||
expect(folded).toBe(2)
|
||||
})
|
||||
|
||||
it('does not restore an obsolete fold level when a tool set returns', () => {
|
||||
let folded = -1
|
||||
layout.rowWidth = 300
|
||||
const view = render(<Host onFolded={(count) => (folded = count)} />)
|
||||
expect(folded).toBeGreaterThan(0)
|
||||
|
||||
layout.rowWidth = 600
|
||||
view.rerender(<Host onFolded={(count) => (folded = count)} stages={['grab']} />)
|
||||
expect(folded).toBe(0)
|
||||
|
||||
view.rerender(<Host onFolded={(count) => (folded = count)} />)
|
||||
expect(folded).toBe(0)
|
||||
})
|
||||
})
|
||||
+115
@@ -0,0 +1,115 @@
|
||||
import { useLayoutEffect, useRef, useState, type RefObject } from 'react'
|
||||
import { BROWSER_CHROME_ADDRESS_SLOT_ATTRIBUTE } from './browser-chrome-address-slot'
|
||||
|
||||
/**
|
||||
* One step of toolbar compaction. Steps apply in this order as the row narrows, so the least-used
|
||||
* tools leave first and the page tools the chrome exists for (grab, annotate) leave last.
|
||||
*/
|
||||
export type BrowserChromeFoldStage =
|
||||
| 'import-label'
|
||||
| 'external'
|
||||
| 'devtools'
|
||||
| 'share'
|
||||
| 'import'
|
||||
| 'draw'
|
||||
| 'grab'
|
||||
| 'annotate'
|
||||
|
||||
export const BROWSER_CHROME_FOLD_ORDER: readonly BrowserChromeFoldStage[] = [
|
||||
'import-label',
|
||||
'external',
|
||||
'devtools',
|
||||
'share',
|
||||
'import',
|
||||
'draw',
|
||||
'grab',
|
||||
'annotate'
|
||||
]
|
||||
|
||||
/** Below this the URL stops being readable, so tools fold before the address shrinks further. */
|
||||
export const BROWSER_CHROME_ADDRESS_MIN_WIDTH_PX = 120
|
||||
|
||||
type RowMeasure = { address: number; overflow: number }
|
||||
|
||||
function measureRow(row: HTMLElement | null): RowMeasure | null {
|
||||
const slot = row?.querySelector(`[${BROWSER_CHROME_ADDRESS_SLOT_ATTRIBUTE}]`)
|
||||
// Why: a hidden pane measures zero; folding everything there would flash on reveal.
|
||||
if (!row || !slot || row.clientWidth === 0) {
|
||||
return null
|
||||
}
|
||||
return {
|
||||
address: slot.getBoundingClientRect().width,
|
||||
overflow: Math.max(0, row.scrollWidth - row.clientWidth)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Folds `stages` one at a time until the address slot keeps its minimum width and the row stops
|
||||
* overflowing, and unfolds as room returns.
|
||||
*
|
||||
* Why record the width each fold freed instead of re-measuring from zero on every resize: resetting
|
||||
* would re-render the toolbar once per stage per resize frame. A fold is undone only when the
|
||||
* address has that much slack, so an unfold can never immediately re-trigger its own fold.
|
||||
*/
|
||||
export function useBrowserChromeToolFold(
|
||||
rowRef: RefObject<HTMLElement | null>,
|
||||
stages: readonly BrowserChromeFoldStage[]
|
||||
): ReadonlySet<BrowserChromeFoldStage> {
|
||||
const stagesKey = stages.join(',')
|
||||
const [foldedCount, setFoldedCount] = useState(0)
|
||||
const level = Math.min(foldedCount, stages.length)
|
||||
const freedRef = useRef<{ stagesKey: string; widths: number[] }>({ stagesKey, widths: [] })
|
||||
const pendingRef = useRef<(RowMeasure & { level: number }) | null>(null)
|
||||
|
||||
const checkRef = useRef<() => void>(() => {})
|
||||
checkRef.current = () => {
|
||||
if (freedRef.current.stagesKey !== stagesKey) {
|
||||
freedRef.current = { stagesKey, widths: [] }
|
||||
pendingRef.current = null
|
||||
}
|
||||
const measure = measureRow(rowRef.current)
|
||||
if (!measure) {
|
||||
return
|
||||
}
|
||||
const freed = freedRef.current.widths
|
||||
const pending = pendingRef.current
|
||||
if (pending && pending.level === level) {
|
||||
freed[level - 1] = measure.address - pending.address + (pending.overflow - measure.overflow)
|
||||
pendingRef.current = null
|
||||
}
|
||||
const shortfall =
|
||||
Math.max(0, BROWSER_CHROME_ADDRESS_MIN_WIDTH_PX - measure.address) + measure.overflow
|
||||
if (shortfall > 0.5 && level < stages.length) {
|
||||
pendingRef.current = { ...measure, level: level + 1 }
|
||||
setFoldedCount(level + 1)
|
||||
return
|
||||
}
|
||||
const slack = measure.address - BROWSER_CHROME_ADDRESS_MIN_WIDTH_PX
|
||||
if (shortfall <= 0.5 && level > 0 && slack >= (freed[level - 1] ?? 0)) {
|
||||
setFoldedCount(level - 1)
|
||||
}
|
||||
}
|
||||
|
||||
// Why layout effect: each fold step re-measures before paint, so a squeeze never flashes clipped.
|
||||
useLayoutEffect(() => {
|
||||
checkRef.current()
|
||||
}, [level, stagesKey])
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const row = rowRef.current
|
||||
const slot = row?.querySelector(`[${BROWSER_CHROME_ADDRESS_SLOT_ATTRIBUTE}]`)
|
||||
if (!row || typeof ResizeObserver === 'undefined') {
|
||||
return
|
||||
}
|
||||
// Why both: the slot resizes when a sibling tool appears or hides, but stops resizing once it
|
||||
// hits zero while the row keeps overflowing.
|
||||
const observer = new ResizeObserver(() => checkRef.current())
|
||||
observer.observe(row)
|
||||
if (slot) {
|
||||
observer.observe(slot)
|
||||
}
|
||||
return () => observer.disconnect()
|
||||
}, [rowRef])
|
||||
|
||||
return new Set(stages.slice(0, level))
|
||||
}
|
||||
+13
-2
@@ -8,6 +8,10 @@ import {
|
||||
DropdownMenuTrigger
|
||||
} from '@/components/ui/dropdown-menu'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
import {
|
||||
BrowserChromeFoldedMenuItems,
|
||||
type BrowserChromeOverflowMenuProps
|
||||
} from '../assemble-chrome/browser-chrome-folded-tools'
|
||||
|
||||
/**
|
||||
* The preview's overflow menu. It carries the document actions rather than the browsing pane's
|
||||
@@ -18,7 +22,8 @@ export function DocPreviewOverflowMenu({
|
||||
onHardReload,
|
||||
onOpenSource,
|
||||
onCopyPath,
|
||||
onCopyRelativePath
|
||||
onCopyRelativePath,
|
||||
overflow
|
||||
}: {
|
||||
onReload: () => void
|
||||
onHardReload: () => void
|
||||
@@ -26,11 +31,13 @@ export function DocPreviewOverflowMenu({
|
||||
onCopyPath: () => void
|
||||
/** Why it lives here: the preview hides the editor's path header, which was the only way to copy it. */
|
||||
onCopyRelativePath: () => void
|
||||
overflow: BrowserChromeOverflowMenuProps
|
||||
}): React.JSX.Element {
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
ref={overflow.triggerRef}
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
className="h-7 w-7"
|
||||
@@ -42,7 +49,11 @@ export function DocPreviewOverflowMenu({
|
||||
<MoreHorizontal className="size-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuContent align="end" onCloseAutoFocus={overflow.onMenuCloseAutoFocus}>
|
||||
<BrowserChromeFoldedMenuItems
|
||||
tools={overflow.tools}
|
||||
deferUntilClose={overflow.deferUntilClose}
|
||||
/>
|
||||
<DropdownMenuItem onSelect={onReload}>
|
||||
<RefreshCw className="size-3.5" />
|
||||
{translate('auto.components.browser.pane.BrowserPane.0e080d820e', 'Reload')}
|
||||
|
||||
@@ -102,7 +102,8 @@ export function DocPreviewToolbar({
|
||||
label: translate(
|
||||
'auto.components.editor.HtmlDocPreview.openSourceControl',
|
||||
'Open source file'
|
||||
)
|
||||
),
|
||||
alreadyInOverflowMenu: true
|
||||
}}
|
||||
openExternal={{
|
||||
onSelect: onOpenExternally,
|
||||
@@ -111,15 +112,16 @@ export function DocPreviewToolbar({
|
||||
'Open with default app'
|
||||
)
|
||||
}}
|
||||
overflowMenu={
|
||||
overflowMenu={(overflow) => (
|
||||
<DocPreviewOverflowMenu
|
||||
onReload={onReload}
|
||||
onHardReload={onHardReload}
|
||||
onOpenSource={onOpenSource}
|
||||
onCopyPath={onCopyPath}
|
||||
onCopyRelativePath={onCopyRelativePath}
|
||||
overflow={overflow}
|
||||
/>
|
||||
}
|
||||
)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user