From 896a8fd363b267e1dab783d46098a8793acad33b Mon Sep 17 00:00:00 2001 From: haa Date: Wed, 7 Oct 2026 07:48:06 +0200 Subject: [PATCH 1/2] docs: manual sort fix + hide fields brief --- .../task-brief-manual-sort-and-hide-fields.md | 129 ++++++++++++++++++ 1 file changed, 129 insertions(+) create mode 100644 .loom/design/task-brief-manual-sort-and-hide-fields.md diff --git a/.loom/design/task-brief-manual-sort-and-hide-fields.md b/.loom/design/task-brief-manual-sort-and-hide-fields.md new file mode 100644 index 0000000..66bab3e --- /dev/null +++ b/.loom/design/task-brief-manual-sort-and-hide-fields.md @@ -0,0 +1,129 @@ +# Devin Brief: 手动拖拽排序失效 + 删除两个不该暴露的字段 + +## 项目 +`/home/haa/sites/promptkey`,当前 `master`(HEAD `330e7c4` = PR #13 squash)。新建分支 `fix/wheel-manual-drag-and-hide-fields`。 + +## 背景 + +用户已安装 2.0.3 并实测。原话: + +> 轮盘很好用,现在是那个排序时的手动排序,我看到你做的有拖拽啊改变排序但是我发现拖不动没法手动调整顺序,请你做调整... + +> 另外我看到的每个提示词的这个,可用范围和那个内部的用编号排序的这个就删掉吧,不显示,这个不需要被调整就不要给出去 + +所以两个任务: + +--- + +## Task 1:手动拖拽排序拖不动(必须修好,真机可用) + +### 现状代码(已核实) + +拖拽实现在 `src/js/views/prompts.js` 的 `bindMirrorDrag()`,绑定在**左侧「轮盘」镜像列表** `#wheelMirror` 上: + +```js +handle.addEventListener('mousedown', () => { row.draggable = true; }); +handle.addEventListener('mouseup', () => { row.draggable = false; }); +row.addEventListener('dragstart', e => { + if (state.wheelSort !== 'manual') { e.preventDefault(); toast('info', t('wheel.sortSwitchToManual')); return; } + dragId = +row.dataset.wheelId; + row.classList.add('dragging'); + e.dataTransfer.effectAllowed = 'move'; +}); +``` + +### 已知问题(我逐行读过,至少这些) + +1. **`mousedown`/`mouseup` 设置 `draggable` 的做法在 Windows WebView2 上极不可靠**——`draggable` 属性的**时序**决定拖拽能否启动,某些情况下 `dragstart` 根本不触发。这是 HTML5 原生拖拽在 WebView2 上的长期坑。 +2. **`row.dataset.wheelId` 若渲染时没写 `data-wheel-id`(而是 `data-id` 之类),`dragId` 恒为 `NaN`** —— 那么 `dragstart` 里 `dragId = NaN`,`drop` 里 `if (!dragId || dragId === overId) return;`(NaN 是 truthy 但 `NaN === NaN` 为 false,能过)但 `pins.indexOf(NaN)` = -1 → `if (from < 0 || to < 0) return;` → **完全静默失败**。**先核实 attribute 名字到底叫什么**。 +3. **非 manual 模式时只 toast 不进入拖拽**,用户可能连"拖不动"和"不让拖"都分不清。 +4. `mousedown` 才置 `draggable=true` 意味着**鼠标按下后到移动之间的窗口很短**,慢一点或先停顿就拖不起来。 + +### 要求 + +**直接换成不依赖 HTML5 原生拖拽的实现。** 推荐**指针事件(pointerdown/pointermove/pointerup)+ 手动视觉反馈**的自制排序: + +- 按住行 → 行进入拖拽态(视觉:抬升/半透明/影子),可**上下移动** +- 拖动过程中给出**清晰的插入位置指示**(一条分割线或目标行高亮) +- 松手 → 计算目标 index → `ipc('set_pin_order', { ids })` → 重新加载 +- **必须支持鼠标和触摸**(`pointerdown` 天然覆盖) +- 保留 `wheel` 镜像列表的**滚动**(拖拽时不能把滚动吞了):可约定"按住句柄区才拖"或"长按/位移超过阈值才进入拖拽" +- 必须**真的拖得动**。不要"看起来能拖但实现是 HTML5 原生那套" + +顺带: + +- **拖拽态下禁止 wheel 事件冒泡到行的点击**(拖完误触发预览/注入) +- 排序模式非 manual 时:**要么自动切到 manual 再拖,要么给出明确引导**(哪个体验好你论证后选一个,说明理由) +- 键盘可达性:方向键 + 空格/回车也能移动行(可选,但做更好) +- 视觉风格遵循既有设计系统,动效轻量不廉价 + +--- + +## Task 2:删掉两个不该给出去的字段(UI 层,用户明确要求) + +用户要删的: + +| 要删的 | 具体位置 | +|---|---| +| **可用范围** | `src/index.html:202` `
` —— 编辑抽屉里的「应用范围」输入框 | +| **内部用编号排序** | `src/index.html:203` `
` —— 编辑抽屉里的「轮盘位置」编号输入框 | + +用户原话:「**这个不需要被调整就不要给出去**」—— 意思是这是内部机制,不该裸露给用户,**UI 不显示即可**。 + +### 要求 + +- **从 UI 移除这两个字段**(抽屉不再显示) +- `src/js/views/prompts.js` 里对应的**读取/写入/监听**(`setV('#fApps', ...)`、`setV('#fOrder', ...)`、`payload` 里的 `app_scopes_json` / `inject_order`、`['fName','fContent','fTags','fApps','fOrder']` 的 dirty 监听数组)**全部清理干净** +- 同时清理 i18n 里不再使用的 key(`f.apps`、`f.appsPh`、`f.order` 等,中英双语都要删) +- **不要动后端/数据库/schema**:`app_scopes_json` 和 `inject_order` 字段**保留**(后端与其他流程可能仍依赖,尤其 `set_pin_order` 写的就是 pin order)。只是**前端不再让用户直接编辑它们** +- 注意:`src/js/store.js:92` 有 `inject_order` 的解析(`parseInt(p.inject_order, 10)`),那是内部排序逻辑,**保留** + +--- + +# 硬约束 + +- 无 cargo,本机不编译。**CI 必须全绿**(cargo check/clippy/test + frontend static checks + playwright) +- Playwright:**新增拖拽排序的真实验证用例**(pointer 事件驱动,不是 `fill()` 那种假交互)。断言:按住→移动→松手后 DOM 顺序变化**且** `set_pin_order` 被调用 +- 既有测试不得回归 +- i18n 中英双语同步,删掉的 key 两侧一致 +- 一个 commit `fix(ui): real pointer-based manual sort + hide internal fields from drawer`,push,PR `--base master`,**不合并** +- 遵守 LOOM 流程 + +--- + +## 最终交付 + +``` +## 拖拽为什么拖不动 +(先给逐条证据链:mousedown/draggable 时序问题、dataset attribute 名核实结果、以及任何你发现的其他断点。**先证明你找到了真根因再动手**) + +## 实现方案 +(为什么选 pointer 事件而非修 HTML5 原生拖拽;插入位置指示怎么做的;滚动冲突怎么解;非 manual 模式的处理策略及理由) + +## 删除字段 +(删了哪些 UI 元素 + 清理了哪些 JS/i18n;确认后端字段保留未被破坏) + +## 测试 +(CI 结果 + 新拖拽用例的断言细节 + 回归结果) + +## 真机验证清单 +(用户在 Windows 上按什么顺序试,每步预期) + +## 未编译验证清单 +``` + +```json +COMPLETION_NOTIFY +source: assistant +task: promptkey-manual-sort-fix +deliverables: +- root cause of "drag does not move" proven with evidence +- pointer-event based manual sort implemented and visually verified +- insertion indicator + scroll coexistence + click-suppression handled +- app scope & inject order fields removed from drawer UI, JS, and i18n +- backend fields preserved +- CI green incl. a real pointer-driven drag test +- branch fix/wheel-manual-drag-and-hide-fields + PR against master (not merged) +status: success +errors: none +``` From 40e312a25700bb3ea2e2f33c78845efcf7c9d741 Mon Sep 17 00:00:00 2001 From: haa Date: Wed, 7 Oct 2026 08:10:08 +0200 Subject: [PATCH 2/2] fix(ui): real pointer-based manual sort + hide internal fields from drawer Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- .github/workflows/ci.yml | 5 + .loom/state.json | 2 +- .loom/tasks.json | 94 ++++++++++++ src/index.html | 2 - src/js/i18n/en-US.js | 5 +- src/js/i18n/zh-CN.js | 5 +- src/js/views/prompts.js | 205 ++++++++++++++++++++++----- src/styles.css | 20 ++- tests/e2e/wheel_sort_drag_e2e.py | 236 +++++++++++++++++++++++++++++++ 9 files changed, 524 insertions(+), 50 deletions(-) create mode 100644 tests/e2e/wheel_sort_drag_e2e.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index bc0eca9..21133e9 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -92,6 +92,11 @@ jobs: - name: Hotkey recorder e2e run: python tests/e2e/hotkey_recorder_e2e.py + # Real pointer input (mouse.down/move/up) — a fill()-style test would + # have passed while WebView2 refused every drag. + - name: Wheel mirror manual-sort drag e2e + run: python tests/e2e/wheel_sort_drag_e2e.py + # No __TAURI__ mock — the previous blind spot. Asserts the app fails # LOUDLY (persistent banner / wheel error card) instead of silently. - name: Missing-Tauri visibility e2e (no mock) diff --git a/.loom/state.json b/.loom/state.json index fe794e8..599279e 100644 --- a/.loom/state.json +++ b/.loom/state.json @@ -4,7 +4,7 @@ "name": "promptkey", "status": "complete", "created_at": "2026-10-06T08:16:56.246Z", - "updated_at": "2026-10-07T04:30:31.807Z" + "updated_at": "2026-10-07T06:08:09.960Z" }, "understanding": { "confirmed": [ diff --git a/.loom/tasks.json b/.loom/tasks.json index 9bf97c9..aa4929b 100644 --- a/.loom/tasks.json +++ b/.loom/tasks.json @@ -940,6 +940,100 @@ "created_at": "2026-10-07T04:17:40.992Z", "updated_at": "2026-10-07T04:30:31.807Z", "completed_at": "2026-10-07T04:30:31.807Z" + }, + { + "id": "TASK-010", + "title": "Fix wheel mirror manual drag (pointer-based) + hide app-scope/order fields from drawer", + "outcome": "Wheel mirror list in the prompts view supports real pointer-driven reorder (mouse+touch) with insertion indicator, auto-switching to manual sort; the edit drawer no longer exposes app_scopes_json / inject_order inputs while backend fields stay intact.", + "acceptance": [ + { + "criterion": "Dragging a wheel-mirror row by pointer reorders the DOM and calls set_pin_order with the new id sequence; verified by a Playwright test using real mouse.down/move/up events", + "verify_by": "python tests/e2e/wheel_sort_drag_e2e.py asserts DOM order change + set_pin_order payload", + "evidence": "tests/e2e/wheel_sort_drag_e2e.py: mouse.down on Delta row, move to above Beta, mouse.up -> __CALLS__ records set_pin_order ids=[1,4,2,3], DOM shows Alpha,Delta,Beta,Gamma. Mid-drag assertions verify .dragging lift + .wm-insert slot marker." + }, + { + "criterion": "Drag works when wheelSort starts in 'auto' (default): first successful drag switches mode to manual, persists pk-wheel-sort, and the dropped order survives reload", + "verify_by": "e2e asserts wheelSortBtn label flips to manual + set_pin_order called once with final order", + "evidence": "e2e asserts localStorage pk-wheel-sort === 'manual' and sortBtn label 手动 after the drop; DOM order Alpha,Delta,Beta,Gamma is read back after loadPrompts re-render (mock writes inject_order per set_pin_order)." + }, + { + "criterion": "Edit drawer shows no '应用范围'/'轮盘位置' inputs; saving an existing prompt preserves its app_scopes_json and inject_order instead of wiping them", + "verify_by": "e2e opens drawer: #fApps/#fOrder absent; update_prompt payload echoes stored values", + "evidence": "e2e: #fApps/#fOrder count 0, drawer text lacks both labels; update_prompt payload echoes app_scopes_json='[\"Code.exe\"]' and inject_order='1'." + }, + { + "criterion": "i18n keys f.apps / f.order removed from both zh-CN.js and en-US.js; no dangling references; node --check clean on all JS", + "verify_by": "grep -rn 'f\\.apps\\|f\\.order' src/ returns nothing; find src -name '*.js' | node --check loop", + "evidence": "grep -rn 'f\\.apps\\|f\\.order\\|fApps\\|fOrder' src/ -> only a code comment remains; node --check loop clean." + } + ], + "done_when": [], + "boundaries": [ + "Does not change backend/schema: prompts.app_scopes_json and prompts.inject_order columns, set_pin_order IPC, and store.js pinOrder parsing stay untouched", + "Does not change wheel.html/wheel.js runtime wheel ordering behavior" + ], + "depends_on": [], + "reads": [ + ".loom/design/task-brief-manual-sort-and-hide-fields.md", + "src/js/views/prompts.js", + "src/js/store.js", + "src/index.html", + "src/styles.css", + "src/js/i18n/zh-CN.js", + "src/js/i18n/en-US.js", + "tests/e2e/hotkey_status_e2e.py" + ], + "touches": [ + "src/js/views/prompts.js", + "src/index.html", + "src/styles.css", + "src/js/i18n/zh-CN.js", + "src/js/i18n/en-US.js", + "tests/e2e/wheel_sort_drag_e2e.py", + ".github/workflows/ci.yml" + ], + "implements": ".loom/design/task-brief-manual-sort-and-hide-fields.md", + "design_exemption": "", + "capability_hooks": [], + "capability_exemption": "Interaction fix within existing design-system tokens; no new professional field is engaged", + "integrity_version": 1, + "covers": [], + "status": "done", + "progress": { + "completed": [], + "current": "complete", + "next": "" + }, + "evidence": [ + "Pointer-based manual sort replaces HTML5 DnD in bindMirrorDrag (prompts.js): mouse drags from anywhere on a row after 5px vertical threshold; touch drags only from the grip (touch-action:none) so row presses still scroll .leftcol. Dragged row lifts via position:fixed + translate3d; dashed .wm-insert slot marks the landing position through plain flex reflow. Drop commits via set_pin_order; commits from auto mode flip wheelSort to manual only after the write succeeds.", + "Drawer no longer renders 应用范围/轮盘位置 inputs; update_prompt now passes app_scopes_json/inject_order through unchanged so a save cannot wipe set_pin_order output or stored scopes. i18n keys f.apps/f.order removed in zh-CN + en-US; wheel.sortAutoSwitch added on both sides.", + "Local e2e: wheel_sort_drag_e2e.py 17/17, hotkey_status 10/10, hotkey_recorder 22/22, no_tauri 13/13; node --check clean on all src js; check_capabilities OK. Visual verification screenshots mid-drag/after-drop/drawer taken via Playwright.", + { + "type": "acceptance_results", + "results": [ + { + "criterion": "Dragging a wheel-mirror row by pointer reorders the DOM and calls set_pin_order with the new id sequence; verified by a Playwright test using real mouse.down/move/up events", + "evidence": "tests/e2e/wheel_sort_drag_e2e.py: mouse.down on Delta row, move to above Beta, mouse.up -> __CALLS__ records set_pin_order ids=[1,4,2,3], DOM shows Alpha,Delta,Beta,Gamma. Mid-drag assertions verify .dragging lift + .wm-insert slot marker." + }, + { + "criterion": "Drag works when wheelSort starts in 'auto' (default): first successful drag switches mode to manual, persists pk-wheel-sort, and the dropped order survives reload", + "evidence": "e2e asserts localStorage pk-wheel-sort === 'manual' and sortBtn label 手动 after the drop; DOM order Alpha,Delta,Beta,Gamma is read back after loadPrompts re-render (mock writes inject_order per set_pin_order)." + }, + { + "criterion": "Edit drawer shows no '应用范围'/'轮盘位置' inputs; saving an existing prompt preserves its app_scopes_json and inject_order instead of wiping them", + "evidence": "e2e: #fApps/#fOrder count 0, drawer text lacks both labels; update_prompt payload echoes app_scopes_json='[\"Code.exe\"]' and inject_order='1'." + }, + { + "criterion": "i18n keys f.apps / f.order removed from both zh-CN.js and en-US.js; no dangling references; node --check clean on all JS", + "evidence": "grep -rn 'f\\.apps\\|f\\.order\\|fApps\\|fOrder' src/ -> only a code comment remains; node --check loop clean." + } + ], + "at": "2026-10-07T06:08:09.959Z" + } + ], + "created_at": "2026-10-07T05:55:04.721Z", + "updated_at": "2026-10-07T06:08:09.959Z", + "completed_at": "2026-10-07T06:08:09.959Z" } ] } diff --git a/src/index.html b/src/index.html index 2c90c1c..4974b1b 100644 --- a/src/index.html +++ b/src/index.html @@ -199,8 +199,6 @@
加入轮盘
-
-
diff --git a/src/js/i18n/en-US.js b/src/js/i18n/en-US.js index 96ab3f8..07dba31 100644 --- a/src/js/i18n/en-US.js +++ b/src/js/i18n/en-US.js @@ -31,7 +31,8 @@ export default { "wheel.sortManual": "Order: manual", "wheel.sortSwitchToManual": "Switch to manual order", "wheel.sortSwitchToAuto": "Switch to auto order", - "wheel.dragHint": "Drag to reorder", + "wheel.sortAutoSwitch": "Switched to manual order", + "wheel.dragHint": "Drag to reorder (or focus and press ↑↓)", "wh.esc": "Esc", "wh.nomatch": "No match", "wh.filtering": "Filter", @@ -162,8 +163,6 @@ export default { "f.tags": "Tags", "f.tagsPh": "dev, writing…", "f.pinned": "Pin to wheel", - "f.apps": "App scopes (optional)", - "f.order": "Wheel position (manual order only)", "f.preview": "Preview", "f.vars": "Variables", "f.autoVars": "Auto: {{clipboard}} clipboard · {{date}} today", diff --git a/src/js/i18n/zh-CN.js b/src/js/i18n/zh-CN.js index 907ddf0..9c8578e 100644 --- a/src/js/i18n/zh-CN.js +++ b/src/js/i18n/zh-CN.js @@ -31,7 +31,8 @@ export default { "wheel.sortManual": "排序:手动", "wheel.sortSwitchToManual": "切换为手动排序", "wheel.sortSwitchToAuto": "切换为自动排序", - "wheel.dragHint": "拖动可调整顺序", + "wheel.sortAutoSwitch": "已切换为手动排序", + "wheel.dragHint": "拖动调整顺序(聚焦后可用 ↑↓ 键)", "wh.esc": "Esc 关闭", "wh.nomatch": "无匹配", "wh.filtering": "筛选", @@ -162,8 +163,6 @@ export default { "f.tags": "标签", "f.tagsPh": "dev, 写作…", "f.pinned": "加入轮盘", - "f.apps": "应用范围(可选)", - "f.order": "轮盘位置(手动排序时生效)", "f.preview": "预览", "f.vars": "变量", "f.autoVars": "自动:{{clipboard}} 剪贴板 · {{date}} 当前日期", diff --git a/src/js/views/prompts.js b/src/js/views/prompts.js index 67ebcb7..05f3a05 100644 --- a/src/js/views/prompts.js +++ b/src/js/views/prompts.js @@ -46,7 +46,7 @@ function renderWheelMirror() { mirror.innerHTML = pins.length ? pins.map((p, i) => `
${i + 1}${esc(p.name)} - ${icon('grip-vertical', 14)} + ${icon('grip-vertical', 14)}
`).join('') : `
${esc(t('wheel.empty'))}
`; const btn = $('#wheelSortBtn'); @@ -79,10 +79,6 @@ export function openDrawer(id, draft) { // drafts arriving from the wheel's quick-create gesture intend a petal, so // the pin switch starts on (the user can uncheck before saving). $('#fPin')?.classList.toggle('on', p ? !!p.is_pinned : !!draft?.pin); - let apps = []; - try { apps = p?.app_scopes_json ? JSON.parse(p.app_scopes_json) : []; } catch { apps = []; } - setV('#fApps', Array.isArray(apps) ? apps.join(', ') : ''); - setV('#fOrder', p?.inject_order || ''); const meta = $('#fMeta'); if (meta) meta.innerHTML = p ? `${esc(t('f.version', { v: p.version || 1, t: p.updated_at || '—' }))}` @@ -134,7 +130,6 @@ async function saveDrawer() { const content = contentEl.value; if (!name || !content.trim()) { toast('warn', t('err.generic', { e: t('f.name') + ' / ' + t('f.content') })); return; } const tags = ($('#fTags')?.value || '').split(/[,,]/).map(s => s.trim()).filter(Boolean); - const apps = ($('#fApps')?.value || '').split(/[,,]/).map(s => s.trim()).filter(Boolean); const p = state.prompts.find(x => x.id === editingId); const payload = { id: editingId, @@ -143,8 +138,10 @@ async function saveDrawer() { tags, content_type: p?.content_type || 'text', variables_json: p?.variables_json || null, - app_scopes_json: JSON.stringify(apps), - inject_order: ($('#fOrder')?.value || '').trim() || null, + // internal fields — not user-editable; preserve stored values so a save + // never wipes scopes or a manual pin order written by set_pin_order + app_scopes_json: p?.app_scopes_json ?? null, + inject_order: p?.inject_order ?? null, version: (p?.version || 0) + (editingId ? 1 : 0) || 1, updated_at: null, }; @@ -159,35 +156,171 @@ async function saveDrawer() { } catch { /* ipc already toasted */ } } +/* ---- wheel mirror manual reorder ---- + Pointer-event implementation, not HTML5 drag-and-drop: toggling `draggable` + inside mousedown races WebView2's drag detector (dragstart may never fire), + and the old path silently preventDefault'd whenever wheelSort was 'auto' + (the default) — users saw a grip icon but nothing moved. + + Model: pointerdown arms a gesture (mouse: anywhere on the row — the row has + no click action; touch/pen: only on the grip handle, so a vertical press on + a row still scrolls .leftcol via touch-action: pan-y). Once the pointer + passes DRAG_THRESHOLD the row lifts out of flow (position:fixed, follows + the pointer) and a .wm-insert placeholder marks the landing slot — sibling + rows reflow around it through normal flex layout, no FLIP math. Drop walks + the container children to build the id sequence, then commits via + set_pin_order. A reorder is itself an explicit ordering intent, so a commit + from auto mode flips wheelSort to 'manual' and persists it — the dropped + order must still be there after reload. Keyboard: focus the grip (or row) + and use ArrowUp/ArrowDown/Home/End for the same commit path. */ +const DRAG_THRESHOLD = 5; +let mirrorDragBound = false; +let mirrorDrag = null; +let suppressClickUntil = 0; + function bindMirrorDrag() { - let dragId = null; - $$('#wheelMirror [data-drag]').forEach(handle => { - const row = handle.closest('.wm-item'); - handle.addEventListener('mousedown', () => { row.draggable = true; }); - handle.addEventListener('mouseup', () => { row.draggable = false; }); - row.addEventListener('dragstart', e => { - if (state.wheelSort !== 'manual') { e.preventDefault(); toast('info', t('wheel.sortSwitchToManual')); return; } - dragId = +row.dataset.wheelId; - row.classList.add('dragging'); - e.dataTransfer.effectAllowed = 'move'; - }); - row.addEventListener('dragend', () => { row.classList.remove('dragging'); row.draggable = false; }); - row.addEventListener('dragover', e => { e.preventDefault(); row.classList.add('dragover'); }); - row.addEventListener('dragleave', () => row.classList.remove('dragover')); - row.addEventListener('drop', async e => { - e.preventDefault(); row.classList.remove('dragover'); row.draggable = false; - const overId = +row.dataset.wheelId; - if (!dragId || dragId === overId) return; - const pins = wheelPrompts().map(p => p.id); - const from = pins.indexOf(dragId), to = pins.indexOf(overId); - if (from < 0 || to < 0) return; - pins.splice(to, 0, pins.splice(from, 1)[0]); - try { - await ipc('set_pin_order', { ids: pins }); - await loadPrompts(); rebuildIndex(); renderPrompts(); - } catch { /* toasted */ } - }); + const mirror = $('#wheelMirror'); + if (!mirror || mirrorDragBound) return; + mirrorDragBound = true; + mirror.addEventListener('pointerdown', onMirrorPointerDown); + mirror.addEventListener('pointermove', onMirrorPointerMove); + mirror.addEventListener('pointerup', onMirrorPointerUp); + mirror.addEventListener('pointercancel', onMirrorPointerCancel); + mirror.addEventListener('lostpointercapture', onMirrorPointerCancel); + mirror.addEventListener('keydown', onMirrorKeyDown); + // a real drag must not end in a click landing on the row underneath + mirror.addEventListener('click', e => { + if (Date.now() < suppressClickUntil) { e.preventDefault(); e.stopPropagation(); } + }, true); +} + +function onMirrorPointerDown(e) { + if (mirrorDrag) return; + const row = e.target.closest('.wm-item'); + if (!row || !$('#wheelMirror')?.contains(row)) return; + if (e.pointerType === 'mouse' && e.button !== 0) return; + const onHandle = !!e.target.closest('.drag'); + if (e.pointerType !== 'mouse' && !onHandle) return; + if (e.pointerType !== 'mouse') e.preventDefault(); + mirrorDrag = { pointerId: e.pointerId, row, id: +row.dataset.wheelId, startY: e.clientY, active: false, marker: null }; + try { row.setPointerCapture(e.pointerId); } catch { /* pointer already gone */ } +} + +function onMirrorPointerMove(e) { + const d = mirrorDrag; + if (!d || e.pointerId !== d.pointerId) return; + const dy = e.clientY - d.startY; + if (!d.active) { + if (Math.abs(dy) < DRAG_THRESHOLD) return; + startMirrorDrag(d); + } + d.row.style.transform = `translate3d(0, ${dy}px, 0)`; + placeMirrorMarker(d, e.clientY); + autoScrollMirror(e.clientY); +} + +function startMirrorDrag(d) { + const mirror = $('#wheelMirror'); + d.active = true; + const rect = d.row.getBoundingClientRect(); + const marker = document.createElement('div'); + marker.className = 'wm-insert'; + marker.style.height = `${rect.height}px`; + marker.setAttribute('aria-hidden', 'true'); + d.marker = marker; + mirror.insertBefore(marker, d.row); + d.row.classList.add('dragging'); + Object.assign(d.row.style, { + left: `${rect.left}px`, top: `${rect.top}px`, width: `${rect.width}px`, }); + mirror.classList.add('drag-live'); +} + +function placeMirrorMarker(d, y) { + const mirror = $('#wheelMirror'); + let before = null; + for (const r of $$('.wm-item', mirror)) { + if (r === d.row) continue; + const rc = r.getBoundingClientRect(); + if (y < rc.top + rc.height / 2) { before = r; break; } + } + if (before) mirror.insertBefore(d.marker, before); + else mirror.appendChild(d.marker); +} + +// edge-scroll the .leftcol while the pointer hovers near its top/bottom +function autoScrollMirror(y) { + const col = $('#wheelMirror')?.closest('.leftcol'); + if (!col) return; + const rc = col.getBoundingClientRect(); + if (y < rc.top + 28) col.scrollTop -= 8; + else if (y > rc.bottom - 28) col.scrollTop += 8; +} + +async function onMirrorPointerUp(e) { + const d = mirrorDrag; + if (!d || e.pointerId !== d.pointerId) return; + mirrorDrag = null; + if (!d.active) return; + suppressClickUntil = Date.now() + 350; + const ids = []; + for (const child of $('#wheelMirror').children) { + if (child === d.marker) ids.push(d.id); + else if (child !== d.row && child.classList?.contains('wm-item')) ids.push(+child.dataset.wheelId); + } + cleanupMirrorDrag(d); + if (ids.join(',') === wheelPrompts().map(p => p.id).join(',')) return; + await commitPinOrder(ids); +} + +function onMirrorPointerCancel(e) { + const d = mirrorDrag; + if (!d || e.pointerId !== d.pointerId) return; + mirrorDrag = null; + cleanupMirrorDrag(d); +} + +function cleanupMirrorDrag(d) { + d.marker?.remove(); + d.row.classList.remove('dragging'); + d.row.style.transform = ''; + d.row.style.left = d.row.style.top = d.row.style.width = ''; + $('#wheelMirror')?.classList.remove('drag-live'); + try { d.row.releasePointerCapture(d.pointerId); } catch { /* released */ } +} + +async function commitPinOrder(ids) { + const wasAuto = state.wheelSort !== 'manual'; + try { + await ipc('set_pin_order', { ids }); + // the drop IS an ordering intent — only flip once it actually persisted + if (wasAuto) { state.wheelSort = 'manual'; storage.set('pk-wheel-sort', 'manual'); } + await loadPrompts(); rebuildIndex(); renderPrompts(); + if (wasAuto) toast('info', t('wheel.sortAutoSwitch')); + } catch { /* toasted */ } +} + +async function onMirrorKeyDown(e) { + if (e.defaultPrevented || mirrorDrag) return; + const row = e.target.closest?.('.wm-item'); + if (!row) return; + let j; + if (e.key === 'ArrowUp') j = -1; + else if (e.key === 'ArrowDown') j = 1; + else if (e.key === 'Home') j = -Infinity; + else if (e.key === 'End') j = Infinity; + else return; + const ids = wheelPrompts().map(p => p.id); + const i = ids.indexOf(+row.dataset.wheelId); + if (i < 0) return; + e.preventDefault(); + const to = Math.max(0, Math.min(ids.length - 1, i + j)); + if (to === i) return; + ids.splice(to, 0, ids.splice(i, 1)[0]); + const id = +row.dataset.wheelId; + await commitPinOrder(ids); + // renderPrompts rebuilt the list — hand focus back to the moved row's grip + $(`#wheelMirror .wm-item[data-wheel-id="${id}"] .drag`)?.focus(); } /* ---- events (wired once by app.js) ---- */ @@ -242,7 +375,7 @@ export function wirePrompts({ openPreview }) { $('#cancelBtn')?.addEventListener('click', closeDrawer); $('#drawerMask')?.addEventListener('click', closeDrawer); $('#fPin')?.addEventListener('click', e => e.currentTarget.classList.toggle('on')); - ['fName', 'fContent', 'fTags', 'fApps', 'fOrder'].forEach(id => + ['fName', 'fContent', 'fTags'].forEach(id => $('#' + id)?.addEventListener('input', () => { dirty = true; if (id === 'fContent') updateVarHint(); })); $('#saveBtn')?.addEventListener('click', saveDrawer); $('#delBtn')?.addEventListener('click', async () => { diff --git a/src/styles.css b/src/styles.css index 0325c4a..d142b3c 100644 --- a/src/styles.css +++ b/src/styles.css @@ -132,17 +132,27 @@ select:hover,.btn:hover{border-color:var(--border-strong)} } .sec-title{font-size:var(--fs-caption);color:var(--text-muted);font-weight:600;letter-spacing:.4px;text-transform:uppercase;padding:0 4px 8px;display:flex;align-items:center;gap:5px} .wheel-mirror{display:flex;flex-direction:column;gap:2px} +.wheel-mirror.drag-live,.wheel-mirror.drag-live *{cursor:grabbing!important} .wm-item{ display:flex;align-items:center;gap:8px;padding:7px 8px;border-radius:var(--r-sm); - font-size:var(--fs-small);color:var(--text-secondary);cursor:pointer;border:1px solid transparent; + font-size:var(--fs-small);color:var(--text-secondary);cursor:grab;border:1px solid transparent; + user-select:none;touch-action:pan-y; } .wm-item:hover{background:var(--bg-surface);color:var(--text-primary)} .wm-item .num{width:14px;font-size:10px;color:var(--text-muted);text-align:center;flex-shrink:0} .wm-item .nm{flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap} -.wm-item .drag{opacity:0;color:var(--text-muted);cursor:grab} -.wm-item:hover .drag{opacity:1} -.wm-item.dragging{opacity:.45;border-style:dashed;border-color:var(--border-strong)} -.wm-item.dragover{border-color:var(--accent)} +.wm-item .drag{opacity:0;color:var(--text-muted);cursor:grab;touch-action:none} +.wm-item:hover .drag,.wm-item:focus-within .drag{opacity:1} +/* pointer drag (not HTML5 DnD): the row lifts out of flow and tracks the + pointer; .wm-insert marks the landing slot so siblings reflow around it */ +.wm-item.dragging{ + position:fixed;z-index:40;pointer-events:none;cursor:grabbing; + background:var(--bg-elevated);border-color:var(--border-strong);box-shadow:var(--shadow-md); +} +.wm-insert{ + border:1.5px dashed var(--accent);border-radius:var(--r-sm); + background:var(--accent-soft);flex-shrink:0; +} .wm-empty{font-size:var(--fs-small);color:var(--text-muted);padding:8px} .wheel-sort-row{padding:2px 4px} .tags-cloud{display:flex;flex-wrap:wrap;gap:6px} diff --git a/tests/e2e/wheel_sort_drag_e2e.py b/tests/e2e/wheel_sort_drag_e2e.py new file mode 100644 index 0000000..d1f6cb2 --- /dev/null +++ b/tests/e2e/wheel_sort_drag_e2e.py @@ -0,0 +1,236 @@ +#!/usr/bin/env python3 +"""Wheel-mirror manual sort e2e — regression guard for the pointer-drag fix. + +The previous implementation armed `draggable` inside mousedown and relied on +HTML5 drag-and-drop: racy in WebView2, and hard-cancelled by preventDefault() +whenever wheelSort was 'auto' (the default) — users saw a grip icon but could +never drag. This test drives REAL pointer input (mouse.move/down/move/up), so +it fails if the rows do not physically reorder. + +Covers: + 1. plain click on a mirror row does not call set_pin_order + 2. dragging a row to a new slot reorders the DOM and calls set_pin_order + once with the exact new id sequence + 3. a drag landing in the original slot commits nothing + 4. committing from 'auto' (the default mode) flips wheelSort to manual, + persists pk-wheel-sort, and the new order survives the reload that + follows the drop + 5. the drawer no longer renders #fApps / #fOrder, and update_prompt passes + app_scopes_json / inject_order through instead of wiping them + 6. keyboard reorder (ArrowDown on the grip) commits via the same path + +Run: python3 tests/e2e/wheel_sort_drag_e2e.py +Reqs: python3 -m playwright (browsers installed) +""" + +import functools +import http.server +import json +import socketserver +import sys +import threading +from pathlib import Path + +from playwright.sync_api import sync_playwright + +SRC = Path(__file__).resolve().parents[2] / "src" + +PROMPTS = [ + {"id": 1, "name": "Alpha", "content": "alpha body", "tags": ["dev"], + "is_pinned": 1, "usage_count": 9, "frecency": 40.0, "last_used_at": 100, + "content_type": "text", "variables_json": None, + "app_scopes_json": '["Code.exe"]', "inject_order": "2", + "version": 3, "updated_at": "2026-10-01 10:00"}, + {"id": 2, "name": "Beta", "content": "beta body", "tags": [], + "is_pinned": 1, "usage_count": 5, "frecency": 30.0, "last_used_at": 90, + "content_type": "text", "variables_json": None, + "app_scopes_json": None, "inject_order": "1", + "version": 1, "updated_at": "2026-10-01 10:00"}, + {"id": 3, "name": "Gamma", "content": "gamma body", "tags": [], + "is_pinned": 1, "usage_count": 2, "frecency": 20.0, "last_used_at": 80, + "content_type": "text", "variables_json": None, + "app_scopes_json": None, "inject_order": "4", + "version": 1, "updated_at": "2026-10-01 10:00"}, + {"id": 4, "name": "Delta", "content": "delta body", "tags": [], + "is_pinned": 1, "usage_count": 1, "frecency": 10.0, "last_used_at": 70, + "content_type": "text", "variables_json": None, + "app_scopes_json": None, "inject_order": "3", + "version": 1, "updated_at": "2026-10-01 10:00"}, + {"id": 5, "name": "Unpinned", "content": "not on wheel", "tags": [], + "is_pinned": 0, "usage_count": 99, "frecency": 99.0, "last_used_at": 200, + "content_type": "text", "variables_json": None, + "app_scopes_json": None, "inject_order": None, + "version": 1, "updated_at": "2026-10-01 10:00"}, +] + +INIT = """ +localStorage.setItem('pk-lang', 'zh-CN'); +window.__CALLS__ = []; +window.__PROMPTS__ = %s; +window.__TAURI__ = { + core: { + invoke: (cmd, args) => { + const db = window.__PROMPTS__; + switch (cmd) { + case 'get_prompts_view': + return Promise.resolve(JSON.parse(JSON.stringify(db))); + case 'set_pin_order': + window.__CALLS__.push({ cmd, ids: [...args.ids] }); + args.ids.forEach((id, i) => { + const p = db.find(x => x.id === id); + if (p) p.inject_order = String(i + 1); + }); + return Promise.resolve(null); + case 'update_prompt': { + window.__CALLS__.push({ cmd, prompt: args.prompt }); + const p = db.find(x => x.id === args.prompt.id); + if (p) Object.assign(p, args.prompt); + return Promise.resolve(null); + } + case 'toggle_prompt_pin': { + const p = db.find(x => x.id === args.id); + if (p) p.is_pinned = p.is_pinned ? 0 : 1; + return Promise.resolve(null); + } + case 'check_service_status': return Promise.resolve(true); + default: return Promise.resolve(null); + } + } + }, + event: { listen: () => Promise.resolve(() => {}) } +}; +""" % json.dumps(PROMPTS) + + +class Quiet(http.server.SimpleHTTPRequestHandler): + def log_message(self, *a): + pass + + +def serve(): + handler = functools.partial(Quiet, directory=str(SRC)) + srv = socketserver.ThreadingTCPServer(("127.0.0.1", 0), handler) + threading.Thread(target=srv.serve_forever, daemon=True).start() + return srv, srv.server_address[1] + + +CHECKS = [] + + +def check(name, cond, extra=""): + CHECKS.append((name, bool(cond), extra)) + print(f" {'PASS' if cond else 'FAIL'} {name}" + (f" [{extra}]" if extra and not cond else "")) + + +def mirror_names(page): + return page.eval_on_selector_all( + "#wheelMirror .wm-item .nm", "els => els.map(e => e.textContent)") + + +def set_pin_order_calls(page): + return page.evaluate("window.__CALLS__.filter(c => c.cmd === 'set_pin_order')") + + +def main(): + srv, port = serve() + with sync_playwright() as p: + browser = p.chromium.launch() + page = browser.new_page() + page.add_init_script(INIT) + page.goto(f"http://127.0.0.1:{port}/index.html") + page.wait_for_selector("#wheelMirror .wm-item") + + # ---- 0. baseline: auto order is frecency desc -> Alpha Beta Gamma Delta ---- + check("mirror lists 4 pinned (unpinned hidden)", + mirror_names(page) == ["Alpha", "Beta", "Gamma", "Delta"], + str(mirror_names(page))) + check("sort toggle starts in auto", "按使用" in (page.locator("#wheelSortBtn").text_content() or "")) + + # ---- 1. plain click commits nothing ---- + row4 = page.locator('.wm-item[data-wheel-id="4"]') + box = row4.bounding_box() + page.mouse.move(box["x"] + box["width"] / 2, box["y"] + box["height"] / 2) + page.mouse.down() + page.mouse.up() + page.wait_for_timeout(150) + check("click without move: no set_pin_order", set_pin_order_calls(page) == []) + + # ---- 2. real pointer drag: Delta (last) -> above Beta (slot 2) ---- + row4 = page.locator('.wm-item[data-wheel-id="4"]') + row2 = page.locator('.wm-item[data-wheel-id="2"]') + b4, b2 = row4.bounding_box(), row2.bounding_box() + start_x, start_y = b4["x"] + b4["width"] / 2, b4["y"] + b4["height"] / 2 + target_y = b2["y"] + 3 # above Beta's midpoint -> insert before Beta + page.mouse.move(start_x, start_y) + page.mouse.down() + page.mouse.move(start_x, start_y - 20, steps=4) + check("drag lift: row gets .dragging", + "dragging" in (row4.get_attribute("class") or "")) + check("drag lift: insertion slot marker shown", + page.locator("#wheelMirror .wm-insert").count() == 1) + page.mouse.move(start_x, target_y, steps=12) + page.mouse.up() + page.wait_for_function("window.__CALLS__.filter(c => c.cmd === 'set_pin_order').length === 1") + + calls = set_pin_order_calls(page) + check("drop calls set_pin_order once with [1,4,2,3]", + calls == [{"cmd": "set_pin_order", "ids": [1, 4, 2, 3]}], str(calls)) + page.wait_for_function( + "[...document.querySelectorAll('#wheelMirror .wm-item .nm')]" + ".map(e => e.textContent).join() === 'Alpha,Delta,Beta,Gamma'") + check("DOM order persisted after reload", mirror_names(page) == ["Alpha", "Delta", "Beta", "Gamma"]) + check("auto -> manual flip persisted", + page.evaluate("localStorage.getItem('pk-wheel-sort')") == "manual") + check("sort toggle now shows manual", "手动" in (page.locator("#wheelSortBtn").text_content() or "")) + + # ---- 3. drag back to the same slot commits nothing ---- + row3 = page.locator('.wm-item[data-wheel-id="3"]') + b3 = row3.bounding_box() + page.mouse.move(b3["x"] + b3["width"] / 2, b3["y"] + b3["height"] / 2) + page.mouse.down() + page.mouse.move(b3["x"] + b3["width"] / 2, b3["y"] + b3["height"] / 2 + 8, steps=3) + page.mouse.up() + page.wait_for_timeout(150) + check("no-op drop: still exactly one set_pin_order", + len(set_pin_order_calls(page)) == 1) + + # ---- 4. drawer: internal fields gone, save preserves them ---- + page.click('.card[data-id="1"]') + page.wait_for_selector("#drawer.show") + check("drawer has no #fApps input", page.locator("#fApps").count() == 0) + check("drawer has no #fOrder input", page.locator("#fOrder").count() == 0) + body_text = page.locator("#drawer .drawer-body").text_content() or "" + check("drawer hides 应用范围/轮盘位置 labels", + "应用范围" not in body_text and "轮盘位置" not in body_text) + page.click("#saveBtn") + page.wait_for_function("window.__CALLS__.some(c => c.cmd === 'update_prompt')") + upd = page.evaluate("window.__CALLS__.find(c => c.cmd === 'update_prompt').prompt") + check("save preserves app_scopes_json", upd["app_scopes_json"] == '["Code.exe"]', str(upd)) + check("save preserves inject_order", upd["inject_order"] == "1", str(upd)) + + # ---- 5. keyboard reorder on the grip ---- + page.locator('.wm-item[data-wheel-id="1"] .drag').focus() + page.keyboard.press("ArrowDown") + page.wait_for_function("window.__CALLS__.filter(c => c.cmd === 'set_pin_order').length === 2") + calls = set_pin_order_calls(page) + check("ArrowDown on grip commits [4,1,2,3]", + calls[-1]["ids"] == [4, 1, 2, 3], str(calls[-1])) + page.wait_for_function( + "[...document.querySelectorAll('#wheelMirror .wm-item .nm')]" + ".map(e => e.textContent).join() === 'Delta,Alpha,Beta,Gamma'") + check("focus returned to moved row's grip", + page.evaluate("document.activeElement?.dataset?.drag") == "1") + + browser.close() + srv.shutdown() + + failed = [n for n, ok, _ in CHECKS if not ok] + print(f"\n{len(CHECKS) - len(failed)}/{len(CHECKS)} checks passed") + if failed: + print("FAILED:", ", ".join(failed)) + sys.exit(1) + print("ALL PASS") + + +if __name__ == "__main__": + main()