From 5cfe18b43a88c072b5d4aa15d359521e14dc0559 Mon Sep 17 00:00:00 2001 From: Max <112043822+Maxaubert@users.noreply.github.com> Date: Wed, 7 Oct 2026 04:00:16 +0200 Subject: [PATCH] fix(explorer): the drag label hangs off the cursor's bottom right (#310) Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01FHHaWKR4M5QtW7Wecyuk4t --- CLAUDE.md | 10 ++ package-lock.json | 4 +- package.json | 2 +- src/renderer/src/lib/dragBadgePlace.test.ts | 32 +++++++ src/renderer/src/lib/dragBadgePlace.ts | 34 +++++++ src/renderer/src/lib/internalFileDrag.ts | 9 +- tools/e2e/run.mjs | 100 ++++++++++++++++++++ 7 files changed, 185 insertions(+), 6 deletions(-) create mode 100644 src/renderer/src/lib/dragBadgePlace.test.ts create mode 100644 src/renderer/src/lib/dragBadgePlace.ts diff --git a/CLAUDE.md b/CLAUDE.md index da012e53..c9f60411 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -769,6 +769,16 @@ native ``, no Acrylic or Accent opacity slider. Spec and plan: over. Marks elsewhere are dropped for the same reason: right-clicking row A while B and C are marked leaves the verb going to A, and marks claiming otherwise are lying. Right- clicking INSIDE a multi-selection still acts on all of it. +- **THE DRAG LABEL HANGS OFF THE POINTER'S BOTTOM RIGHT, ATTACHED** (#310; owner, 2026-10-07: "when + you pick up an item with left click drag the label is to its bottom left but also not attached to + the cursor ... it should be attached and it should be from the bottom right"). The label is an + IN-PAGE element (`lib/internalFileDrag.ts`, which cancels the native drag so Ctrl+Tab still works), + not a drag image, so it is placed in CSS px from the pointer and the display scale never enters. + It sat its whole width LEFT of the pointer and 12 px below. `lib/dragBadgePlace.ts` puts its + top-left corner 8 px right of and below the tip (File Explorer's way: the pointer never covers the + name), flipping to the other side only where the window edge leaves no room. One rule for every + drag the hook carries: tree, Explorer list, several items, places. The `dragLabel` e2e measures it + mid-drag at 100%, 150% and 225% (`--force-device-scale-factor`). - **SETTINGS CONTROLS ARE NEUTRAL; ONLY SAVE WEARS THE ACCENT** (#202; owner, 2026-09-23: "i dont want settings buttons to be affected by the accent colour... grey based on the bg colour ... same colours as the drop down menus"; "the only ones to keep accented are the save buttons"). The diff --git a/package-lock.json b/package-lock.json index 4f6150d3..f26af95d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "prism", - "version": "0.92.1", + "version": "0.92.2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "prism", - "version": "0.92.1", + "version": "0.92.2", "license": "MIT", "dependencies": { "@codemirror/commands": "^6.11.0", diff --git a/package.json b/package.json index be068b68..ba1300f1 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "prism", - "version": "0.92.1", + "version": "0.92.2", "description": "A fast, universal media viewer for Windows.", "author": "Max", "license": "MIT", diff --git a/src/renderer/src/lib/dragBadgePlace.test.ts b/src/renderer/src/lib/dragBadgePlace.test.ts new file mode 100644 index 00000000..97dd6469 --- /dev/null +++ b/src/renderer/src/lib/dragBadgePlace.test.ts @@ -0,0 +1,32 @@ +import { describe, expect, it } from 'vitest' +import { DRAG_BADGE_GAP, dragBadgePlace } from './dragBadgePlace' + +describe('dragBadgePlace', () => { + it('hangs the label off the bottom right of the pointer, a small gap away', () => { + expect(dragBadgePlace(300, 200, 120, 34, 1200, 800)).toEqual({ + left: 300 + DRAG_BADGE_GAP, + top: 200 + DRAG_BADGE_GAP + }) + }) + it('the gap is a few pixels, not the 10 to 20 the owner saw', () => { + expect(DRAG_BADGE_GAP).toBeGreaterThanOrEqual(4) + expect(DRAG_BADGE_GAP).toBeLessThanOrEqual(8) + }) + it('never puts the label left of the pointer while there is room on the right', () => { + const { left } = dragBadgePlace(50, 50, 200, 34, 1200, 800) + expect(left).toBeGreaterThan(50) + }) + it('flips to the left near the right edge instead of sliding under the pointer', () => { + const { left } = dragBadgePlace(1150, 200, 120, 34, 1200, 800) + expect(left + 120).toBe(1150 - DRAG_BADGE_GAP) + }) + it('flips above near the bottom edge', () => { + const { top } = dragBadgePlace(300, 790, 120, 34, 1200, 800) + expect(top + 34).toBe(790 - DRAG_BADGE_GAP) + }) + it('a label too wide for either side stays inside the window', () => { + const { left } = dragBadgePlace(200, 100, 380, 34, 400, 800) + expect(left).toBeGreaterThanOrEqual(4) + expect(left + 380).toBeLessThanOrEqual(400) + }) +}) diff --git a/src/renderer/src/lib/dragBadgePlace.ts b/src/renderer/src/lib/dragBadgePlace.ts new file mode 100644 index 00000000..a1019276 --- /dev/null +++ b/src/renderer/src/lib/dragBadgePlace.ts @@ -0,0 +1,34 @@ +/** + * Where the drag label sits while a file is carried (#310; owner, 2026-10-07: + * "it should be attached and it should be from the bottom right"). + * + * The label is an in-page element placed in CSS pixels from the pointer's + * client coordinates, so the display scale does not enter into it: the same + * numbers hold at 100%, 150% and 225%. Its top-left corner hangs a small gap + * below and right of the cursor's tip, as File Explorer's does, so the pointer + * never covers the name. Only where the window's edge leaves no room does it + * flip to the other side of the pointer, never sliding under it. + */ +export const DRAG_BADGE_GAP = 8 +/** How far the label keeps from the window's edges. */ +export const DRAG_BADGE_MARGIN = 4 + +export function dragBadgePlace( + x: number, + y: number, + width: number, + height: number, + viewWidth: number, + viewHeight: number +): { left: number; top: number } { + const side = (at: number, size: number, view: number): number => { + const after = at + DRAG_BADGE_GAP + if (after + size <= view - DRAG_BADGE_MARGIN) return after + const before = at - DRAG_BADGE_GAP - size + if (before >= DRAG_BADGE_MARGIN) return before + // No room on either side (a label wider than half the window): keep it + // inside the window, which is all that is left to promise. + return Math.max(DRAG_BADGE_MARGIN, view - DRAG_BADGE_MARGIN - size) + } + return { left: side(x, width, viewWidth), top: side(y, height, viewHeight) } +} diff --git a/src/renderer/src/lib/internalFileDrag.ts b/src/renderer/src/lib/internalFileDrag.ts index 42cfaf53..35a34a90 100644 --- a/src/renderer/src/lib/internalFileDrag.ts +++ b/src/renderer/src/lib/internalFileDrag.ts @@ -1,4 +1,5 @@ import { useEffect } from 'react' +import { dragBadgePlace } from './dragBadgePlace' import { DRAG_MIME, getDrag, setDrag } from './dragDrop' import { QUICK_ACCESS_PIN_MIME } from './quickAccess' @@ -63,9 +64,11 @@ export function useInternalFileDrag(stepTab: (delta: number) => void): void { const action = effect === 'move' ? 'Move' : effect === 'copy' ? 'Copy' : '' carry.badge.textContent = action ? `${action} ${carry.label}` : carry.label const { width, height } = carry.badge.getBoundingClientRect() - // Hang the label below and left of the hand, keeping its drop target clear. - carry.badge.style.left = `${Math.max(4, Math.min(carry.x - width - 4, window.innerWidth - width - 4))}px` - carry.badge.style.top = `${Math.max(4, Math.min(carry.y + 12, window.innerHeight - height - 4))}px` + // Hang the label off the pointer's bottom right, attached (#310). It + // used to sit its whole width LEFT of the pointer and 12 px below it. + const place = dragBadgePlace(carry.x, carry.y, width, height, window.innerWidth, window.innerHeight) + carry.badge.style.left = `${place.left}px` + carry.badge.style.top = `${place.top}px` } const tick = (): void => { if (!carry) return diff --git a/tools/e2e/run.mjs b/tools/e2e/run.mjs index 036981dc..3643c9af 100644 --- a/tools/e2e/run.mjs +++ b/tools/e2e/run.mjs @@ -10954,6 +10954,105 @@ async function styleColoursScenario(fixtures) { } } +async function dragLabelScenario(fixtures) { + console.log('the drag label hangs off the cursor') + // #310 (owner, 2026-10-07): "when you pick up an item with left click drag + // the label is to its bottom left but also not attached to the cursor ... + // it should be attached and it should be from the bottom right". The label + // is an in-page element (internalFileDrag), so its box is read mid-drag + // against the pointer, in CSS pixels, at three display scales. A REAL press + // and travel, so the hook's own dragstart takes the drag. + const dir = join(fixtures, 'draglabel') + rmSync(dir, { recursive: true, force: true }) + mkdirSync(join(dir, 'folder'), { recursive: true }) + for (const n of ['alpha.txt', 'beta.txt']) writeFileSync(join(dir, n), `drag label ${n}\n`) + const badge = (win) => + win.evaluate(() => { + const el = document.querySelector('[data-file-drag-badge]') + const r = el?.getBoundingClientRect() + return r ? { left: r.left, top: r.top, width: r.width, text: el.textContent ?? '', dpr: devicePixelRatio } : null + }) + /** Press on `el`, travel to two places, read the label at each, let go with Escape. */ + const carry = async (win, el, what, shot) => { + const box = await el.boundingBox() + if (!box) { + ok(false, `${what}: the row is on screen`) + return null + } + const from = { x: Math.round(box.x + Math.min(24, box.width / 2)), y: Math.round(box.y + box.height / 2) } + await win.mouse.move(from.x, from.y) + await win.mouse.down() + const seen = [] + for (const [dx, dy] of [[70, 50], [140, 90]]) { + const at = { x: from.x + dx, y: from.y + dy } + await win.mouse.move(at.x, at.y, { steps: 8 }) + await sleep(120) + const b = await badge(win) + seen.push(b && { ...b, x: at.x, y: at.y, offX: b.left - at.x, offY: b.top - at.y }) + } + if (shot) await win.screenshot({ path: join(SHOTS, shot) }) + await win.keyboard.press('Escape') + await win.mouse.up() + await sleep(200) + const [a, b] = seen + ok(!!a && !!b, `${what}: a label is carried (${a?.text ?? 'none'})`) + if (!a || !b) return null + const near = (o) => o >= 4 && o <= 8 + ok( + near(a.offX) && near(a.offY), + `${what}: its top-left corner sits 4 to 8 px right of and below the pointer (${a.offX}, ${a.offY} at dpr ${a.dpr})` + ) + ok(Math.abs(a.offX - b.offX) < 0.5 && Math.abs(a.offY - b.offY) < 0.5, `${what}: and stays attached as the pointer travels (${b.offX}, ${b.offY})`) + ok(!(await badge(win)), `${what}: and goes when the drag ends`) + return a + } + for (const scale of [1, 1.5, 2.25]) { + // Forced at 100% too: this machine runs at 225%, and an unforced window + // would measure that twice. + EXTRA_ARGS = [`--force-device-scale-factor=${scale}`] + const { app, win } = await launch(join(dir, 'alpha.txt')) + try { + await win.waitForSelector('aside [role="treeitem"]:has-text("beta.txt")', { timeout: 10000 }) + await sleep(500) + const tree = (name) => win.locator(`aside [role="treeitem"]:has-text("${name}")`).first().locator('span.truncate').first() + const one = await carry(win, tree('beta.txt'), `${scale * 100}%, a file from the tree`, `drag-label-${scale * 100}.png`) + ok(!!one && Math.abs(one.dpr - scale) < 0.01, `the window really is at ${scale * 100}% (${one?.dpr})`) + ok(!!one && one.text.includes('beta.txt'), `and the label names the file (${one?.text})`) + if (scale !== 1) continue + await carry(win, tree('folder'), 'a folder from the tree') + + /* ---------- the Explorer list ---------- */ + await win.locator('[role="tablist"] [data-pinned] [role="tab"]').click() + await win.waitForSelector('[data-testid="browse-list"] .browse-row', { timeout: 10000 }) + await win.locator('[data-testid="browse-list"] [data-browse-path$="draglabel"]').dblclick() + ok( + await until(async () => (await win.locator('[data-testid="browse-list"] [data-browse-path$=".txt"]').count()) === 2, 10000), + 'the Explorer walked into the folder' + ) + await sleep(400) + const row = (n) => win.locator(`[data-testid="browse-list"] [data-browse-path$="${n}"] .browse-name`).first() + await carry(win, row('alpha.txt'), 'a file from the Explorer list', 'drag-label-list.png') + await carry(win, row('folder'), 'a folder from the Explorer list') + await row('alpha.txt').click() + await win.keyboard.down('Control') + await row('beta.txt').click() + await win.keyboard.up('Control') + await sleep(200) + const many = await carry(win, row('beta.txt'), 'two marked items from the Explorer list', 'drag-label-many.png') + ok(!!many && /2 items/.test(many.text), `and the label counts them (${many?.text})`) + + /* ---------- the places panel ---------- */ + const pin = win.locator('.quick-access-pin').first() + if (await pin.count()) await carry(win, pin, 'a place from the places panel') + else ok(false, 'the places panel shows a place to drag') + } finally { + await app.close() + EXTRA_ARGS = [] + } + await sleep(900) + } +} + async function dragScenario(fixtures) { console.log('drag and drop') // #70: a row dragged onto a folder MOVES; a member dragged out of an archive @@ -15443,6 +15542,7 @@ await run(accentOpacityScenario) await run(styleColoursScenario) await run(seeThroughScenario) await run(dragScenario) +await run(dragLabelScenario) await run(marqueeScenario) await run(marqueeQuietScenario) await run(markTintScenario)