* feat(sidebar): long-press project chips to open the context menu on touch (#3760) Project filter chips could only be deleted/renamed via the right-click context menu (oncontextmenu), which has no touch equivalent — so mobile/tablet users had no way to delete a project from the sidebar; the list grew forever. Adds a 500ms long-press gesture mirroring the existing session-item long-press pattern: touchstart schedules the menu, touchmove cancels on >10px drift, touchend suppresses the synthetic click when the long-press fired, touchcancel cleans up. `.project-chip.long-pressing` gives accent + slight-scale feedback; `touch-action:manipulation` + `user-select:none` + `-webkit-touch-callout:none` prevent the native callout/selection from competing. Maintainer fix on top of the contributor PR (multi-touch correctness, flagged in review): touchstart now clears any in-flight `_lpTimer` before scheduling a new one (a second finger / stray touchstart previously orphaned the prior timer, which then fired unsuppressed ~500ms later and popped the menu after the gesture was cancelled), and the timer body bails if `_lpHandled` is already set so a stale fire is a no-op — matching the session-item belt-and-suspenders. Also dropped a stale, unrelated issue reference from the original comment. Co-authored-by: reinocheong <[email protected]> * docs(changelog): stamp v0.51.310 — Release JZ (stage-3760 long-press project chips) --------- Co-authored-by: nesquena-hermes <[email protected]>
This commit is contained in:
parent
a20ef5e0c3
commit
4b390e115c
4 changed files with 114 additions and 1 deletions
|
|
@ -3,6 +3,11 @@
|
|||
|
||||
## [Unreleased]
|
||||
|
||||
## [v0.51.310] — 2026-06-07 — Release JZ (stage-3760 — long-press project chips to delete on touch)
|
||||
|
||||
### Fixed
|
||||
- **Project chips can now be managed on touch devices.** The project filter chips in the sidebar exposed Rename / Color / Delete only through the right-click context menu, which has no touch equivalent — so on phones/tablets there was no way to delete a project and the list grew forever. A 500ms long-press now opens the same context menu (mirroring the existing session-item long-press): a >10px drag cancels it, a short tap still filters, and the synthetic click after the press is suppressed. The chip shows accent + slight-scale feedback while held, and native callout/selection is disabled so it doesn't compete with the gesture. Includes a multi-touch correctness fix (a second finger / stray touchstart can no longer orphan the press timer and pop the menu after the gesture was cancelled). Verified live: long-press opens the menu once, short tap doesn't, and the multi-touch-then-cancel case no longer fires. (#3760, @reinocheong)
|
||||
|
||||
## [v0.51.309] — 2026-06-07 — Release JY (stage-a5b — restore reconnect replays live tool cards)
|
||||
|
||||
### Fixed
|
||||
|
|
|
|||
|
|
@ -4694,6 +4694,50 @@ function renderSessionListFromCache(){
|
|||
};
|
||||
chip.ondblclick=(e)=>{e.stopPropagation();clearTimeout(_pClickTimer);_pClickTimer=null;_startProjectRename(p,chip);};
|
||||
chip.oncontextmenu=(e)=>{e.preventDefault();_showProjectContextMenu(e,p,chip);};
|
||||
// Touch long-press → context menu (mobile UX: project chips can only be
|
||||
// deleted via the right-click menu, which has no touch equivalent).
|
||||
let _lpTimer=null;
|
||||
let _lpHandled=false;
|
||||
let _lpStartX=0,_lpStartY=0;
|
||||
chip.addEventListener('touchstart',(e)=>{
|
||||
const t=e.changedTouches&&e.changedTouches[0];
|
||||
if(!t) return;
|
||||
// Clear any in-flight timer before scheduling a new one, mirroring the
|
||||
// session-item long-press path (_clearLongPressTimer). Without this a
|
||||
// second finger / stray touchstart orphans the prior timer, which then
|
||||
// fires unsuppressed ~500ms later and pops the menu after the gesture
|
||||
// was cancelled.
|
||||
if(_lpTimer){clearTimeout(_lpTimer);_lpTimer=null;}
|
||||
_lpHandled=false;_lpStartX=t.clientX;_lpStartY=t.clientY;
|
||||
chip.classList.add('long-pressing');
|
||||
_lpTimer=setTimeout(()=>{
|
||||
_lpTimer=null;
|
||||
if(_lpHandled) return; // already consumed by another gesture — stale fire is a no-op
|
||||
_lpHandled=true;
|
||||
chip.classList.remove('long-pressing');
|
||||
clearTimeout(_pClickTimer);_pClickTimer=null;
|
||||
const syn={clientX:t.clientX,clientY:t.clientY,preventDefault:()=>{}};
|
||||
_showProjectContextMenu(syn,p,chip);
|
||||
},500);
|
||||
},{passive:true});
|
||||
chip.addEventListener('touchmove',(e)=>{
|
||||
if(!_lpTimer) return;
|
||||
const t=e.changedTouches&&e.changedTouches[0];
|
||||
if(!t) return;
|
||||
if(Math.abs(t.clientX-_lpStartX)>10||Math.abs(t.clientY-_lpStartY)>10){
|
||||
clearTimeout(_lpTimer);_lpTimer=null;
|
||||
chip.classList.remove('long-pressing');
|
||||
}
|
||||
},{passive:true});
|
||||
chip.addEventListener('touchend',(e)=>{
|
||||
clearTimeout(_lpTimer);_lpTimer=null;
|
||||
chip.classList.remove('long-pressing');
|
||||
if(_lpHandled){e.preventDefault();e.stopPropagation();}
|
||||
},{passive:false});
|
||||
chip.addEventListener('touchcancel',()=>{
|
||||
clearTimeout(_lpTimer);_lpTimer=null;_lpHandled=false;
|
||||
chip.classList.remove('long-pressing');
|
||||
},{passive:true});
|
||||
bar.appendChild(chip);
|
||||
}
|
||||
// Create button
|
||||
|
|
|
|||
|
|
@ -4086,9 +4086,10 @@ main.main > #mainPlugin{display:none;}
|
|||
.session-source-tab.active{background:var(--accent-bg);color:var(--accent-text);border-color:var(--accent-bg);}
|
||||
.session-empty-note{padding:20px 14px;color:var(--muted);font-size:12px;text-align:center;opacity:.7;}
|
||||
.project-bar{display:flex;gap:4px;padding:4px 10px 8px;flex-wrap:wrap;align-items:center;flex-shrink:0;}
|
||||
.project-chip{font-size:10px;font-weight:600;padding:3px 8px;border-radius:12px;cursor:pointer;border:1px solid var(--border2);background:var(--input-bg);color:var(--muted);transition:all .15s;white-space:nowrap;display:inline-flex;align-items:center;gap:4px;}
|
||||
.project-chip{font-size:10px;font-weight:600;padding:3px 8px;border-radius:12px;cursor:pointer;border:1px solid var(--border2);background:var(--input-bg);color:var(--muted);transition:all .15s;white-space:nowrap;display:inline-flex;align-items:center;gap:4px;-webkit-user-select:none;user-select:none;-webkit-touch-callout:none;touch-action:manipulation;}
|
||||
.project-chip:hover{background:rgba(255,255,255,.08);color:var(--text);}
|
||||
.project-chip.active{background:var(--accent-bg);color:var(--accent-text);border-color:var(--accent-bg);}
|
||||
.project-chip.long-pressing{background:var(--accent-bg);color:var(--accent-text);border-color:var(--accent-bg-strong);transform:scale(1.05);}
|
||||
/* "Unassigned" filter chip — dashed border distinguishes it from real
|
||||
project chips so it reads as a meta-filter ("things without a project")
|
||||
rather than another project. Keeps full color treatment in the active
|
||||
|
|
|
|||
|
|
@ -166,3 +166,66 @@ class TestResizeProjectInputHelper:
|
|||
assert "addEventListener('input'" in body, (
|
||||
"_startProjectCreate must wire input events to _resizeProjectInput"
|
||||
)
|
||||
|
||||
|
||||
class TestProjectChipLongPressTouch:
|
||||
"""Mobile long-press to open the project context menu (#3760).
|
||||
|
||||
Project chips were deletable only via the right-click context menu, which has
|
||||
no touch equivalent — so mobile users could never remove a project. A 500ms
|
||||
long-press now opens the same menu.
|
||||
"""
|
||||
|
||||
def _chip_touch_block(self):
|
||||
# The chip touch handlers live just after the oncontextmenu wiring in the
|
||||
# project-chip render loop.
|
||||
idx = SESSIONS_JS.find("Touch long-press")
|
||||
assert idx != -1, "project-chip long-press touch block not found"
|
||||
return SESSIONS_JS[idx: idx + 2300]
|
||||
|
||||
def test_long_press_opens_project_context_menu(self):
|
||||
block = self._chip_touch_block()
|
||||
assert "addEventListener('touchstart'" in block
|
||||
assert "setTimeout(" in block and "},500);" in block
|
||||
assert "_showProjectContextMenu(" in block
|
||||
# visual feedback + scroll-drift cancel, mirroring the session-item pattern
|
||||
assert "long-pressing" in block
|
||||
assert "addEventListener('touchmove'" in block
|
||||
assert ">10" in block # >10px drift cancels the press
|
||||
|
||||
def test_long_press_suppresses_synthetic_click_and_filter_tap(self):
|
||||
block = self._chip_touch_block()
|
||||
# touchend must be non-passive so it can preventDefault the synthetic click
|
||||
assert "addEventListener('touchend'" in block
|
||||
assert "{passive:false}" in block
|
||||
assert "e.preventDefault();e.stopPropagation();" in block
|
||||
# the long-press handler cancels the pending single-tap filter timer
|
||||
assert "clearTimeout(_pClickTimer)" in block
|
||||
|
||||
def test_touchstart_clears_inflight_timer_before_scheduling(self):
|
||||
"""Regression: a second finger / stray touchstart must not orphan the
|
||||
prior timer (which would then fire the menu after the gesture was
|
||||
cancelled). touchstart clears any in-flight _lpTimer before scheduling,
|
||||
and the timer body bails if the gesture was already consumed.
|
||||
"""
|
||||
block = self._chip_touch_block()
|
||||
# clear-before-schedule at the top of touchstart
|
||||
assert "if(_lpTimer){clearTimeout(_lpTimer);_lpTimer=null;}" in block, (
|
||||
"touchstart must clear any in-flight long-press timer before scheduling "
|
||||
"a new one (orphaned-timer fix)"
|
||||
)
|
||||
# stale-fire guard inside the timer body
|
||||
assert "if(_lpHandled) return;" in block, (
|
||||
"the long-press timer body must no-op if the gesture was already consumed"
|
||||
)
|
||||
|
||||
def test_long_pressing_style_feedback_present(self):
|
||||
assert ".project-chip.long-pressing" in STYLE_CSS
|
||||
# Target the base .project-chip rule (the one carrying the layout props),
|
||||
# not an unrelated theme override of the same selector.
|
||||
base_idx = STYLE_CSS.find(".project-chip{font-size")
|
||||
assert base_idx != -1, "base .project-chip rule not found"
|
||||
chip_rule = STYLE_CSS[base_idx: STYLE_CSS.find("}", base_idx) + 1]
|
||||
# touch tuning so the native callout/selection doesn't compete with the gesture
|
||||
assert "touch-action:manipulation" in chip_rule
|
||||
assert "user-select:none" in chip_rule
|
||||
|
|
|
|||
Loading…
Reference in a new issue