diff --git a/src/main/popups/titleCoachmark.test.ts b/src/main/popups/titleCoachmark.test.ts index ea96dbaf5..991b4d066 100644 --- a/src/main/popups/titleCoachmark.test.ts +++ b/src/main/popups/titleCoachmark.test.ts @@ -81,6 +81,36 @@ describe('positionCoachmark beak tracking', () => { return placement.x + COACHMARK_SHADOW_GUTTER + placement.beakFraction * cardWidth } + it.each([ + [200, { leftX: 500, rightX: 540 }], + [280, { leftX: 500, rightX: 540 }], + [280, { leftX: 1130, rightX: 1150 }] + ])( + 'reports a card centre that lands on the anchor (card %s, clamped or not)', + (width, anchor) => { + // Asserted against the ANCHOR, not against the formula. An earlier version of this test + // compared `cardLeftInView` to the gutter and re-used the same width it fed in, so both + // sides reduced to the same constant and it could not fail for any input. This varies + // the card width and the anchor — including one that clamps at the right edge — and + // checks the property that actually matters. + const placement = positionCoachmark({ + anchor: { ...anchor, bottomY: 36 }, + bubble: { width, height: 90 }, + parentBounds: { width: 1200, height: 800 } + }) + const anchorCentre = (anchor.leftX + anchor.rightX) / 2 + const cardCentreInWindow = placement.x + placement.cardCentreInView + const clamped = placement.x <= 0 || placement.x + placement.width >= 1200 + if (clamped) { + // Clamped, the card cannot sit on the anchor — but it must still be centred in its + // own view, which is what the beak fraction is then measured against. + expect(placement.cardCentreInView).toBeCloseTo(placement.width / 2, 5) + } else { + expect(cardCentreInWindow).toBeCloseTo(anchorCentre, 5) + } + } + ) + it('centres the beak when the card is not clamped', () => { const placement = positionCoachmark({ anchor: { leftX: 500, rightX: 540, bottomY: 36 }, diff --git a/src/main/popups/titleCoachmark.ts b/src/main/popups/titleCoachmark.ts index 3bdec61ce..4f80d1071 100644 --- a/src/main/popups/titleCoachmark.ts +++ b/src/main/popups/titleCoachmark.ts @@ -1,3 +1,4 @@ +import type { CoachmarkBeakPayload } from '../../types/ipc' import { ipcMain } from 'electron' import type { BrowserWindow, WebContents } from 'electron' import { TITLEBAR_HEIGHT } from '../lib/titleBarOverlay' @@ -91,6 +92,11 @@ export interface CoachmarkPlacement { width: number height: number beakFraction: number + /** Where the card's midpoint belongs within the view, in CSS px. Sent so placement comes + * from the geometry main already computed rather than from the page's own idea of its + * width — and as a centre rather than an edge, so it holds whatever width the card + * actually renders at. */ + cardCentreInView: number } /** Compute popup bounds centering the card under the anchor, clamped to the parent, plus where @@ -130,7 +136,20 @@ export function positionCoachmark(opts: { // the beak to the very corner the margin exists to keep it off. const beakMargin = Math.min(0.5, COACHMARK_BEAK_EDGE_MARGIN / cardWidth) const beakFraction = Math.min(1 - beakMargin, Math.max(beakMargin, rawFraction)) - return { x, y, width: viewWidth, height: viewHeight, beakFraction } + // The card's CENTRE inside the view, which is the view's own midpoint. + // + // Sent because the renderer cannot derive it safely. Centring with auto margins measures the + // page's own width, and that width is sometimes still the pre-resize value — the page had + // not processed the new bounds yet. Measured at 8px off, on a page still reporting 300 + // inside a 316-wide view. + // + // The CENTRE rather than the left edge, deliberately: a left offset is only correct while + // the card renders exactly as wide as `bubble.width` said it would, so it would trade a + // stale-viewport failure for a stale-width one — including on the fallback show, where the + // view is sized before any measurement exists. Pinning the centre is right for any rendered + // width, because the renderer offsets by half of whatever the card actually is. + const cardCentreInView = viewWidth / 2 + return { x, y, width: viewWidth, height: viewHeight, beakFraction, cardCentreInView } } let _coachmarkTokenSeq = 0 @@ -255,14 +274,15 @@ function repositionAndShow( ): void { if (!entry.pendingAnchor || entry.view.isDestroyed()) return const parentBounds = entry.view.parentWindow.getContentBounds() - const { beakFraction, ...bounds } = positionCoachmark({ + const { beakFraction, cardCentreInView, ...bounds } = positionCoachmark({ anchor: entry.pendingAnchor, bubble, parentBounds }) entry.view.popup.setBounds(bounds) // Tell the card where to draw its beak now that the final, possibly clamped, x is known. - entry.view.popup.webContents.send('comfy-titletooltip:set-beak', { beakFraction }) + const beakPayload: CoachmarkBeakPayload = { beakFraction, cardCentreInView } + entry.view.popup.webContents.send('comfy-titletooltip:set-beak', beakPayload) // Focus so the dismiss button is keyboard-reachable. entry.view.showOnTop({ focus: true }) } diff --git a/src/preload/comfyTitleTooltipPreload.ts b/src/preload/comfyTitleTooltipPreload.ts index bf868f364..7a9bde35d 100644 --- a/src/preload/comfyTitleTooltipPreload.ts +++ b/src/preload/comfyTitleTooltipPreload.ts @@ -1,3 +1,4 @@ +import type { CoachmarkBeakPayload } from '../types/ipc' import { contextBridge, ipcRenderer } from 'electron' import type { IpcRendererEvent } from 'electron' @@ -33,7 +34,7 @@ export interface ComfyTitleTooltipBridge { onConfig(cb: (config: TitleTooltipConfig) => void): () => void /** Beak position, pushed after main has measured the card and settled its final (possibly * clamped) bounds. Separate from the config push because it is only knowable then. */ - onBeak(cb: (payload: { beakFraction: number }) => void): () => void + onBeak(cb: (payload: CoachmarkBeakPayload) => void): () => void /** Coachmark dismiss button; no-op for the tooltip variant. `configToken` names the card * the click landed on, so main can discard a click from a card it has since replaced. */ dismissCoachmark(configToken: string): void @@ -74,8 +75,20 @@ const bridge: ComfyTitleTooltipBridge = { }, onBeak: (cb) => { const handler = (_event: IpcRendererEvent, data: unknown): void => { - const raw = (data as { beakFraction?: unknown } | undefined)?.beakFraction - if (typeof raw === 'number' && Number.isFinite(raw)) cb({ beakFraction: raw }) + const payload = data as { beakFraction?: unknown; cardCentreInView?: unknown } | undefined + const raw = payload?.beakFraction + const centre = payload?.cardCentreInView + if (typeof raw === 'number' && Number.isFinite(raw)) { + cb({ + beakFraction: raw, + // `null` rather than a guess: an older main that does not send it must fall back to + // CSS centring, not to a bogus offset. + // Non-negative as well as finite: a negative or NaN centre would place the card + // off its own view, and the renderer treats null as "fall back to CSS centring". + cardCentreInView: + typeof centre === 'number' && Number.isFinite(centre) && centre >= 0 ? centre : null + }) + } } ipcRenderer.on('comfy-titletooltip:set-beak', handler) return () => ipcRenderer.removeListener('comfy-titletooltip:set-beak', handler) diff --git a/src/renderer/src/comfyTitleTooltip/TitleTooltipApp.vue b/src/renderer/src/comfyTitleTooltip/TitleTooltipApp.vue index c97a46263..dc4afe19c 100644 --- a/src/renderer/src/comfyTitleTooltip/TitleTooltipApp.vue +++ b/src/renderer/src/comfyTitleTooltip/TitleTooltipApp.vue @@ -1,4 +1,5 @@