fix(ui): keep tooltips on floating triggers

This commit is contained in:
Danila Poyarkov 2026-06-06 11:31:24 +03:00 committed by GitHub
parent ee2a7472ee
commit c4e21af7ee
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
9 changed files with 259 additions and 83 deletions

View file

@ -8,6 +8,7 @@
### Fixes
- Fix tooltips around inspector dropdowns/popovers without breaking floating menu anchoring.
- Harden MCP calls with bounded page-tree responses, oversized-result errors, JSON HTTP responses, and stale WebSocket cleanup.
- Improve Figma boolean imports by preserving XOR operations as editable exclude nodes and falling back to imported fill geometry when boolean path reconstruction cannot produce a path.
- Preserve rotated Figma transform origins for imported vector nodes.

View file

@ -33,7 +33,12 @@ export default defineConfig({
{
name: 'openpencil-webkit',
testDir: './tests/e2e',
testMatch: '**/*.webkit.spec.ts',
testMatch: [
'**/*.webkit.spec.ts',
'**/design/panel.spec.ts',
'**/export/basic.spec.ts',
'**/fonts/settings.spec.ts'
],
use: {
browserName: 'webkit'
}

View file

@ -1,6 +1,6 @@
<script setup lang="ts">
import { PopoverContent, PopoverPortal, PopoverRoot, PopoverTrigger } from 'reka-ui'
import { onMounted } from 'vue'
import { onMounted, ref } from 'vue'
import { isTauri } from '@/app/tauri/env'
import { useFontSettings } from '@/components/FontSettings/use'
@ -29,6 +29,7 @@ const primaryButton = useButtonUI({
ui: { base: 'w-full px-2 py-1.5 text-[10px] font-medium disabled:opacity-50' }
})
const showDownloadedFonts = isTauri()
const popoverOpen = ref(false)
const {
accessState,
@ -47,14 +48,19 @@ const {
setGoogleFontsEnabled
} = useFontSettings()
function setPopoverOpen(value: boolean) {
popoverOpen.value = value
if (value) void refreshSummary()
}
onMounted(() => {
void refreshSummary()
})
</script>
<template>
<PopoverRoot @update:open="$event && refreshSummary()">
<Tip :label="dialogs.fontSettings">
<PopoverRoot v-model:open="popoverOpen" @update:open="setPopoverOpen">
<Tip :label="dialogs.fontSettings" :disabled="popoverOpen">
<PopoverTrigger data-test-id="font-settings-trigger" :class="trigger.base">
<icon-lucide-settings class="size-3.5" />
</PopoverTrigger>
@ -62,6 +68,7 @@ onMounted(() => {
<PopoverPortal>
<PopoverContent
data-test-id="font-settings-panel"
side="left"
:side-offset="8"
align="start"

View file

@ -1,15 +1,5 @@
<script setup lang="ts">
import {
PopoverClose,
PopoverContent,
PopoverPortal,
PopoverRoot,
PopoverTrigger,
TooltipContent,
TooltipPortal,
TooltipRoot,
TooltipTrigger
} from 'reka-ui'
import { PopoverClose, PopoverContent, PopoverPortal, PopoverRoot, PopoverTrigger } from 'reka-ui'
import { ref } from 'vue'
import { useI18n } from '@open-pencil/vue'
@ -21,11 +11,10 @@ import ProviderSelectField from '@/components/chat/ProviderSelect/ProviderSelect
import StockPhotoKeysSection from '@/components/chat/ProviderSettings/StockPhotoKeysSection.vue'
import { provideProviderSettings } from '@/components/chat/ProviderSettings/context'
import { usePopoverUI } from '@/components/ui/popover'
import { useTooltipUI } from '@/components/ui/tooltip'
import Tip from '@/components/ui/Tip.vue'
const { dialogs } = useI18n()
const cls = usePopoverUI({ content: 'isolate z-[51] w-64 p-3' })
const tooltipCls = useTooltipUI({ content: 'animate-in zoom-in-95 fade-in' })
const popoverOpen = ref(false)
const providerSettings = provideProviderSettings()
@ -40,53 +29,46 @@ function onInteractOutside(e: Event) {
</script>
<template>
<TooltipRoot :open="popoverOpen ? false : undefined">
<PopoverRoot v-model:open="popoverOpen">
<TooltipTrigger as-child>
<PopoverTrigger as-child>
<button
data-test-id="provider-settings-trigger"
class="rounded p-0.5 text-muted hover:bg-hover hover:text-surface"
>
<icon-lucide-settings class="size-3" />
</button>
</PopoverTrigger>
</TooltipTrigger>
<TooltipPortal>
<TooltipContent side="top" :side-offset="4" :class="tooltipCls.content">
{{ dialogs.providerSettings }}
</TooltipContent>
</TooltipPortal>
<PopoverPortal>
<PopoverContent
side="top"
:side-offset="8"
align="end"
:collision-padding="16"
:avoid-collisions="true"
:class="cls.content"
@interact-outside="onInteractOutside"
<PopoverRoot v-model:open="popoverOpen">
<Tip :label="dialogs.providerSettings" :disabled="popoverOpen">
<PopoverTrigger as-child>
<button
data-test-id="provider-settings-trigger"
class="rounded p-0.5 text-muted hover:bg-hover hover:text-surface"
>
<div class="flex flex-col gap-2.5">
<h3 class="text-[11px] font-semibold text-surface">{{ dialogs.aiProvider }}</h3>
<ProviderSelectField test-id="provider-settings-provider" />
<MaxTokensSection />
<StockPhotoKeysSection />
<CustomEndpointSection />
<ApiTypeSection />
<ApiKeySection />
<icon-lucide-settings class="size-3" />
</button>
</PopoverTrigger>
</Tip>
<PopoverClose
class="mt-1 w-full rounded bg-accent px-2 py-1 text-center text-[11px] font-medium text-white hover:bg-accent/90"
data-test-id="provider-settings-done"
@click="providerSettings.save"
>
{{ dialogs.done }}
</PopoverClose>
</div>
</PopoverContent>
</PopoverPortal>
</PopoverRoot>
</TooltipRoot>
<PopoverPortal>
<PopoverContent
side="top"
:side-offset="8"
align="end"
:collision-padding="16"
:avoid-collisions="true"
:class="cls.content"
@interact-outside="onInteractOutside"
>
<div class="flex flex-col gap-2.5">
<h3 class="text-[11px] font-semibold text-surface">{{ dialogs.aiProvider }}</h3>
<ProviderSelectField test-id="provider-settings-provider" />
<MaxTokensSection />
<StockPhotoKeysSection />
<CustomEndpointSection />
<ApiTypeSection />
<ApiKeySection />
<PopoverClose
class="mt-1 w-full rounded bg-accent px-2 py-1 text-center text-[11px] font-medium text-white hover:bg-accent/90"
data-test-id="provider-settings-done"
@click="providerSettings.save"
>
{{ dialogs.done }}
</PopoverClose>
</div>
</PopoverContent>
</PopoverPortal>
</PopoverRoot>
</template>

View file

@ -1,26 +1,172 @@
<script setup lang="ts">
import { TooltipContent, TooltipPortal, TooltipRoot, TooltipTrigger } from 'reka-ui'
import { useEventListener } from '@vueuse/core'
import { computed, nextTick, onBeforeUnmount, ref, watch } from 'vue'
import { useTooltipUI } from '@/components/ui/tooltip'
const TOOLTIP_OPEN_DELAY_MS = 400
const TOOLTIP_SIDE_OFFSET = 4
const TOOLTIP_VIEWPORT_PADDING = 8
type TooltipSide = 'top' | 'bottom' | 'left' | 'right'
const cls = useTooltipUI({ content: 'animate-in zoom-in-95 fade-in' })
const { side = 'top', disabled = false } = defineProps<{
const {
side = 'top',
disabled = false,
label
} = defineProps<{
label?: string
side?: 'top' | 'bottom' | 'left' | 'right'
side?: TooltipSide
disabled?: boolean
}>()
const triggerRef = ref<HTMLElement>()
const contentRef = ref<HTMLElement>()
const open = ref(false)
const position = ref({ x: 0, y: 0 })
let openTimer: ReturnType<typeof setTimeout> | undefined
const canOpen = computed(() => Boolean(label) && !disabled)
const contentStyle = computed(() => ({
left: `${position.value.x}px`,
top: `${position.value.y}px`
}))
function clearOpenTimer() {
if (!openTimer) return
clearTimeout(openTimer)
openTimer = undefined
}
function anchorElement() {
const root = triggerRef.value
const child = root?.firstElementChild
return child instanceof HTMLElement ? child : root
}
function clamp(value: number, min: number, max: number) {
return Math.min(Math.max(value, min), max)
}
function refreshPosition() {
if (!open.value) return
const anchor = anchorElement()
const content = contentRef.value
if (!anchor || !content) return
const anchorRect = anchor.getBoundingClientRect()
const contentRect = content.getBoundingClientRect()
const centerX = anchorRect.left + anchorRect.width / 2
const centerY = anchorRect.top + anchorRect.height / 2
let x = centerX - contentRect.width / 2
let y = anchorRect.top - contentRect.height - TOOLTIP_SIDE_OFFSET
if (side === 'bottom') y = anchorRect.bottom + TOOLTIP_SIDE_OFFSET
if (side === 'left') {
x = anchorRect.left - contentRect.width - TOOLTIP_SIDE_OFFSET
y = centerY - contentRect.height / 2
}
if (side === 'right') {
x = anchorRect.right + TOOLTIP_SIDE_OFFSET
y = centerY - contentRect.height / 2
}
position.value = {
x: clamp(
x,
TOOLTIP_VIEWPORT_PADDING,
window.innerWidth - contentRect.width - TOOLTIP_VIEWPORT_PADDING
),
y: clamp(
y,
TOOLTIP_VIEWPORT_PADDING,
window.innerHeight - contentRect.height - TOOLTIP_VIEWPORT_PADDING
)
}
}
function show() {
if (!canOpen.value) return
clearOpenTimer()
openTimer = setTimeout(() => {
open.value = true
void nextTick(refreshPosition)
}, TOOLTIP_OPEN_DELAY_MS)
}
function hide() {
clearOpenTimer()
open.value = false
}
function containsRelatedTarget(event: PointerEvent | FocusEvent) {
const relatedTarget = event.relatedTarget
return relatedTarget instanceof Node && triggerRef.value?.contains(relatedTarget)
}
function onPointerOver(event: PointerEvent) {
if (containsRelatedTarget(event)) return
show()
}
function onPointerOut(event: PointerEvent) {
if (containsRelatedTarget(event)) return
hide()
}
function onFocusIn(event: FocusEvent) {
if (containsRelatedTarget(event)) return
show()
}
function onFocusOut(event: FocusEvent) {
if (containsRelatedTarget(event)) return
hide()
}
function onPointerDown() {
hide()
}
useEventListener(window, 'resize', refreshPosition)
useEventListener(window, 'scroll', refreshPosition, { capture: true, passive: true })
useEventListener(document, 'pointerdown', hide, { capture: true })
useEventListener(document, 'click', hide, { capture: true })
watch(canOpen, (value) => {
if (!value) hide()
})
onBeforeUnmount(hide)
</script>
<template>
<TooltipRoot :open="disabled ? false : undefined">
<TooltipTrigger as-child>
<slot />
</TooltipTrigger>
<TooltipPortal>
<TooltipContent v-if="label" :side="side" :side-offset="4" :class="cls.content">
{{ label }}
</TooltipContent>
</TooltipPortal>
</TooltipRoot>
<span
ref="triggerRef"
class="contents"
@focusin="onFocusIn"
@focusout="onFocusOut"
@pointerover="onPointerOver"
@pointerout="onPointerOut"
@pointerdown="onPointerDown"
@click="hide"
>
<slot />
</span>
<Teleport to="body">
<div
v-if="open && label"
ref="contentRef"
role="tooltip"
:class="cls.content"
class="pointer-events-none fixed"
:style="contentStyle"
>
{{ label }}
</div>
</Teleport>
</template>

View file

@ -1,4 +1,4 @@
import { expect, test, useEditorSetup } from '#tests/e2e/fixtures'
import { expect, expectInViewport, test, useEditorSetup } from '#tests/e2e/fixtures'
import { expectDefined } from '#tests/helpers/assert'
const editor = useEditorSetup()
@ -305,8 +305,19 @@ test('multi-select shows mixed header and boolean operations', async () => {
await expect(multiHeader).toContainText('Mixed')
await expect(multiHeader).toContainText('layers')
await editor.page.getByTestId('boolean-operations-trigger').click()
await expect(editor.page.getByTestId('boolean-operation-booleanUnion')).toBeVisible()
const booleanOperations = editor.page.getByTestId('boolean-operations-trigger')
await booleanOperations.hover()
await expect(
editor.page.locator('[role=tooltip]').filter({ hasText: 'Boolean operations' })
).toBeVisible()
await booleanOperations.click()
await expect(
editor.page.locator('[role=tooltip]').filter({ hasText: 'Boolean operations' })
).toHaveCount(0)
const booleanUnion = editor.page.getByTestId('boolean-operation-booleanUnion')
await expect(booleanUnion).toBeVisible()
await expectInViewport(editor.page, booleanUnion)
await expect(editor.page.getByTestId('boolean-operation-booleanSubtract')).toBeVisible()
await expect(editor.page.getByTestId('boolean-operation-booleanIntersect')).toBeVisible()
await expect(editor.page.getByTestId('boolean-operation-booleanExclude')).toBeVisible()

View file

@ -1,5 +1,6 @@
import { test, expect, type Page } from '@playwright/test'
import { expectInViewport } from '#tests/e2e/fixtures'
import { CanvasHelper } from '#tests/helpers/canvas'
let page: Page
@ -50,9 +51,15 @@ test('remove export row decreases row count', async () => {
test('format selector changes to JPG', async () => {
const formatTrigger = exportItems().first().getByTestId('app-select-trigger').last()
await formatTrigger.hover()
await expect(page.locator('[role=tooltip]').filter({ hasText: 'Export format' })).toBeVisible()
await formatTrigger.click()
await expect(page.locator('[role=tooltip]').filter({ hasText: 'Export format' })).toHaveCount(0)
await page.locator('[role="option"]').filter({ hasText: 'JPG' }).click()
const jpgOption = page.locator('[role="option"]').filter({ hasText: 'JPG' })
await expect(jpgOption).toBeVisible()
await expectInViewport(page, jpgOption)
await jpgOption.click()
await canvas.waitForRender()
await expect(formatTrigger).toHaveText('JPG')

View file

@ -1,4 +1,4 @@
import { test, expect, type Page } from '@playwright/test'
import { test, expect, type Locator, type Page } from '@playwright/test'
import { CanvasHelper } from '#tests/helpers/canvas'
@ -39,4 +39,17 @@ export function useEditorSetupWithClear(url = '/') {
return ctx
}
export async function expectInViewport(page: Page, locator: Locator) {
const box = await locator.boundingBox()
if (!box) throw new Error('Expected visible element to have a bounding box')
const viewport = page.viewportSize()
if (!viewport) throw new Error('Expected page to have a viewport')
expect(box.x).toBeGreaterThanOrEqual(0)
expect(box.y).toBeGreaterThanOrEqual(0)
expect(box.x + box.width).toBeLessThanOrEqual(viewport.width)
expect(box.y + box.height).toBeLessThanOrEqual(viewport.height)
}
export { test, expect }

View file

@ -18,7 +18,11 @@ test('font settings popover exposes web font access without desktop-only cache a
})
await expect(page.getByTestId('typography-section')).toBeVisible()
await page.getByTestId('font-settings-trigger').click()
const fontSettings = page.getByTestId('font-settings-trigger')
await fontSettings.hover()
await expect(page.locator('[role=tooltip]').filter({ hasText: 'Font settings' })).toBeVisible()
await fontSettings.click()
await expect(page.locator('[role=tooltip]').filter({ hasText: 'Font settings' })).toHaveCount(0)
await expect(page.getByText('Allow browser access to local fonts')).toBeVisible()
await expect(page.getByTestId('font-settings-request-access')).toBeVisible()