Complete Framework7 swipe gestures
This commit is contained in:
@@ -8,7 +8,7 @@
|
|||||||
|
|
||||||
// Which swipe actions a card gets in a given view. Pure, so it is testable.
|
// Which swipe actions a card gets in a given view. Pure, so it is testable.
|
||||||
function cardActions(viewType,canDelete){
|
function cardActions(viewType,canDelete){
|
||||||
const left=viewType==='queue'?[]:[{act:'queue',label:'Queue'}];
|
const left=[{act:'queue',label:'Queue'}];
|
||||||
const right=[{act:'playlist',label:'Playlist'}];
|
const right=[{act:'playlist',label:'Playlist'}];
|
||||||
if(canDelete)right.push({act:'remove',label:'Remove',danger:true});
|
if(canDelete)right.push({act:'remove',label:'Remove',danger:true});
|
||||||
return {left,right};
|
return {left,right};
|
||||||
@@ -21,6 +21,10 @@
|
|||||||
if(ay>ax*1.4)return dy<0?'up':'down';
|
if(ay>ax*1.4)return dy<0?'up':'down';
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
function tabSwipeIndex(active,count,direction){
|
||||||
|
if(direction!=='left'&&direction!=='right'||count<1)return -1;
|
||||||
|
return Math.max(0,Math.min(count-1,active+(direction==='left'?1:-1)));
|
||||||
|
}
|
||||||
|
|
||||||
function mount(doc){
|
function mount(doc){
|
||||||
const html=doc.documentElement;let app=null,loading=null,active=false,sheet=null,cardsObserver=null;
|
const html=doc.documentElement;let app=null,loading=null,active=false,sheet=null,cardsObserver=null;
|
||||||
@@ -49,7 +53,22 @@
|
|||||||
|
|
||||||
// ---- Swipeout cards ----
|
// ---- Swipeout cards ----
|
||||||
// Framework7 only animates <a> children of the actions box, so these are links acting as buttons.
|
// Framework7 only animates <a> children of the actions box, so these are links acting as buttons.
|
||||||
function actionBtn(a){const b=doc.createElement('a');b.setAttribute('role','button');b.tabIndex=0;b.className='f7s-act'+(a.danger?' f7s-danger swipeout-overswipe':'');b.dataset.act=a.act;b.textContent=a.label;return b;}
|
function actionBtn(a){const b=doc.createElement('a');b.setAttribute('role','button');b.tabIndex=0;b.className='swipeout-action f7s-act'+(a.danger?' f7s-danger swipeout-overswipe':'');b.dataset.act=a.act;b.textContent=a.label;return b;}
|
||||||
|
function closeRow(li){
|
||||||
|
if(app){try{app.swipeout.close(li);return;}catch{}}
|
||||||
|
li.classList.remove('f7s-touch-open','swipeout-opened');li.querySelector('.swipeout-content')?.style.removeProperty('transform');
|
||||||
|
li.querySelectorAll('.swipeout-actions-opened').forEach(el=>el.classList.remove('swipeout-actions-opened'));
|
||||||
|
li.querySelectorAll('.f7s-act').forEach(el=>el.style.removeProperty('transform'));
|
||||||
|
}
|
||||||
|
function openRow(li,side){
|
||||||
|
const box=li.querySelector(`.swipeout-actions-${side}`);if(!box)return;
|
||||||
|
doc.querySelectorAll('li.f7s-row.f7s-touch-open').forEach(other=>{if(other!==li)closeRow(other);});
|
||||||
|
if(app){try{app.swipeout.open(li,side);if(li.classList.contains('swipeout-opened'))return;}catch{}}
|
||||||
|
const width=[...box.children].reduce((sum,el)=>sum+el.offsetWidth,0)+16;
|
||||||
|
li.classList.add('f7s-touch-open','swipeout-opened');box.classList.add('swipeout-actions-opened');
|
||||||
|
li.querySelector('.swipeout-content').style.transform=`translate3d(${side==='right'?-width:width}px,0,0)`;
|
||||||
|
box.querySelectorAll('.f7s-act').forEach(el=>el.style.transform='scale(1)');
|
||||||
|
}
|
||||||
function wrapCard(card){
|
function wrapCard(card){
|
||||||
if(!card.classList||!card.classList.contains('card')||card.closest('li.swipeout')||card.classList.contains('skeleton-card')||!card._video)return;
|
if(!card.classList||!card.classList.contains('card')||card.closest('li.swipeout')||card.classList.contains('skeleton-card')||!card._video)return;
|
||||||
const li=doc.createElement('li');li.className='swipeout f7s-row';
|
const li=doc.createElement('li');li.className='swipeout f7s-row';
|
||||||
@@ -67,7 +86,7 @@
|
|||||||
function onAction(e){
|
function onAction(e){
|
||||||
const btn=e.target.closest('.f7s-act');if(!btn)return;
|
const btn=e.target.closest('.f7s-act');if(!btn)return;
|
||||||
const li=btn.closest('li.swipeout'),card=li&&li.querySelector('.card'),v=card&&card._video;
|
const li=btn.closest('li.swipeout'),card=li&&li.querySelector('.card'),v=card&&card._video;
|
||||||
e.stopPropagation();if(app)app.swipeout.close(li);if(!v)return;
|
e.stopPropagation();closeRow(li);if(!v)return;
|
||||||
if(btn.dataset.act==='queue')root.addToQueue&&root.addToQueue(v);
|
if(btn.dataset.act==='queue')root.addToQueue&&root.addToQueue(v);
|
||||||
else if(btn.dataset.act==='playlist')root.openCardMenu&&root.openCardMenu(v);
|
else if(btn.dataset.act==='playlist')root.openCardMenu&&root.openCardMenu(v);
|
||||||
else if(btn.dataset.act==='remove'){const del=card.querySelector('.card-del');if(del)del.click();}
|
else if(btn.dataset.act==='remove'){const del=card.querySelector('.card-del');if(del)del.click();}
|
||||||
@@ -81,8 +100,9 @@
|
|||||||
if(!items.length){const p=doc.createElement('p');p.className='f7s-empty';p.textContent='Nothing queued after this song.';list.append(p);return;}
|
if(!items.length){const p=doc.createElement('p');p.className='f7s-empty';p.textContent='Nothing queued after this song.';list.append(p);return;}
|
||||||
items.forEach((v,k)=>{
|
items.forEach((v,k)=>{
|
||||||
const li=doc.createElement('li');li.className='f7s-up';
|
const li=doc.createElement('li');li.className='f7s-up';
|
||||||
li.innerHTML='<img alt="" loading="lazy" decoding="async"><div class="f7s-up-info"><div class="f7s-up-title"></div><div class="f7s-up-ch"></div></div>';
|
li.innerHTML='<div class="f7s-up-info"><div class="f7s-up-title"></div><div class="f7s-up-ch"></div></div>';
|
||||||
li.querySelector('img').src=v.thumbnail||'';li.querySelector('.f7s-up-title').textContent=v.title||'';li.querySelector('.f7s-up-ch').textContent=v.channel||'';
|
if(v.thumbnail){const img=doc.createElement('img');img.alt='';img.loading='lazy';img.decoding='async';img.src=v.thumbnail;li.prepend(img);}
|
||||||
|
li.querySelector('.f7s-up-title').textContent=v.title||'';li.querySelector('.f7s-up-ch').textContent=v.channel||'';
|
||||||
li.addEventListener('click',()=>{const q=g('queue')||[],i=g('queueIndex');sheet.close();root.playFromList&&root.playFromList(q,(typeof i==='number'?i:-1)+1+k,g('queueSource'));});
|
li.addEventListener('click',()=>{const q=g('queue')||[],i=g('queueIndex');sheet.close();root.playFromList&&root.playFromList(q,(typeof i==='number'?i:-1)+1+k,g('queueSource'));});
|
||||||
list.append(li);
|
list.append(li);
|
||||||
});
|
});
|
||||||
@@ -98,17 +118,29 @@
|
|||||||
renderSheet();sheet.open();
|
renderSheet();sheet.open();
|
||||||
}).catch(()=>{});
|
}).catch(()=>{});
|
||||||
}
|
}
|
||||||
|
function closeSheet(){
|
||||||
|
const modal=sheet&&sheet.el&&sheet.el.f7Modal;if(!modal)return;
|
||||||
|
sheet.el.classList.remove('f7s-full');modal.setBreakpoint(0);
|
||||||
|
// F7 emits its initial `opened` transition after the sheet animation. If a
|
||||||
|
// very fast drag reaches the handle during that transition, repeat close
|
||||||
|
// after that event so its initial breakpoint cannot reopen the sheet.
|
||||||
|
setTimeout(()=>{if(modal.opened)modal.setBreakpoint(0);},360);
|
||||||
|
}
|
||||||
|
|
||||||
// ---- Mini bar + edge swipes (F7 has no component for these) ----
|
// ---- Mini bar + edge swipes (F7 has no component for these) ----
|
||||||
let drag=null,swallowClick=false;
|
let drag=null,swallowClick=false;
|
||||||
function down(e){
|
function down(e){
|
||||||
if(!on()||e.pointerType==='mouse'&&e.button!==0)return;
|
if(!on()||e.pointerType==='touch'||e.pointerType==='mouse'&&e.button!==0)return;
|
||||||
const mini=e.target.closest&&e.target.closest('#miniBar');
|
const mini=e.target.closest&&e.target.closest('#miniBar');
|
||||||
const sidebarOpen=!!doc.querySelector('.app.sidebar-open');
|
const sidebarOpen=!!doc.querySelector('.app.sidebar-open');
|
||||||
const edge=!mini&&e.clientX<22&&!e.target.closest('.player-stage');
|
const edge=!mini&&e.clientX<22&&!e.target.closest('.player-stage');
|
||||||
const inSidebar=sidebarOpen&&e.target.closest&&e.target.closest('#sidebar, #sidebarBackdrop');
|
const inSidebar=sidebarOpen&&e.target.closest&&e.target.closest('#sidebar, #sidebarBackdrop');
|
||||||
if(!mini&&!edge&&!inSidebar)return;
|
const inRow=!!(e.target.closest&&e.target.closest('.f7s-row, .card, input, textarea, select, button, a, [role="slider"], #seek, #volume'));
|
||||||
drag={x:e.clientX,y:e.clientY,kind:mini?'mini':edge?'edge':'sidebar'};
|
const inPlayer=e.target.closest&&e.target.closest('#playerPane:not(.empty)');
|
||||||
|
const inBody=e.target.closest&&e.target.closest('.body');
|
||||||
|
if(!mini&&!edge&&!inSidebar&&(!inBody||inRow)&&!(inPlayer&&!inRow))return;
|
||||||
|
const kind=mini?'mini':edge?'edge':inSidebar?'sidebar':inPlayer?'player':'tabs';
|
||||||
|
drag={x:e.clientX,y:e.clientY,kind};
|
||||||
}
|
}
|
||||||
function up(e){
|
function up(e){
|
||||||
if(!drag)return;const d=drag;drag=null;
|
if(!drag)return;const d=drag;drag=null;
|
||||||
@@ -118,10 +150,67 @@
|
|||||||
if(dir==='left')root.playNext&&root.playNext();
|
if(dir==='left')root.playNext&&root.playNext();
|
||||||
else if(dir==='right')root.playPrev&&root.playPrev();
|
else if(dir==='right')root.playPrev&&root.playPrev();
|
||||||
else if(dir==='up')openSheet();
|
else if(dir==='up')openSheet();
|
||||||
|
}else if(d.kind==='sheet'){
|
||||||
|
if(dir==='up'&&sheet){sheet.el.f7Modal.setBreakpoint(1);sheet.el.classList.add('f7s-full');}
|
||||||
|
else if(dir==='down')closeSheet();
|
||||||
}else if(d.kind==='edge'&&dir==='right')root.openSidebar&&root.openSidebar();
|
}else if(d.kind==='edge'&&dir==='right')root.openSidebar&&root.openSidebar();
|
||||||
else if(d.kind==='sidebar'&&dir==='left')root.closeSidebar&&root.closeSidebar();
|
else if(d.kind==='sidebar'&&dir==='left')root.closeSidebar&&root.closeSidebar();
|
||||||
|
else if(d.kind==='tabs'&&(dir==='left'||dir==='right')){
|
||||||
|
const tabs=[...doc.querySelectorAll('#bottomNav .bottom-nav-btn')].filter(b=>!b.disabled&&!b.hidden);
|
||||||
|
const active=Math.max(0,tabs.findIndex(b=>b.classList.contains('active'))),next=tabSwipeIndex(active,tabs.length,dir);
|
||||||
|
if(tabs[next]&&next!==active){swallowClick=true;setTimeout(()=>{swallowClick=false;},400);tabs[next].click();}
|
||||||
|
}else if(d.kind==='player'&&dir==='down'&&innerWidth<=860&&!doc.fullscreenElement&&!doc.querySelector('.orientation-fullscreen')){
|
||||||
|
const search=doc.querySelector('#bottomNav .bottom-nav-btn[data-view="search"]');if(search)search.click();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
function click(e){if(swallowClick&&e.target.closest&&e.target.closest('#miniBar')){e.stopPropagation();e.preventDefault();swallowClick=false;}}
|
function click(e){if(swallowClick&&e.target.closest&&e.target.closest('#miniBar')){e.stopPropagation();e.preventDefault();swallowClick=false;}}
|
||||||
|
let touchDrag=null,rowDrag=null;
|
||||||
|
function touchDown(e){
|
||||||
|
if(!on()||!e.changedTouches.length)return;
|
||||||
|
const t=e.changedTouches[0],target=e.target,mini=target.closest&&target.closest('#miniBar');
|
||||||
|
if(target.closest&&target.closest('.f7s-grab, .f7s-sheet-head')){touchDrag={x:t.clientX,y:t.clientY,kind:'sheet'};rowDrag=null;return;}
|
||||||
|
const row=target.closest&&target.closest('li.f7s-row');
|
||||||
|
if(row){rowDrag={row,x:t.clientX,y:t.clientY};touchDrag=null;return;}
|
||||||
|
const sidebarOpen=!!doc.querySelector('.app.sidebar-open'),edge=!mini&&t.clientX<22&&!target.closest('.player-stage');
|
||||||
|
const inSidebar=sidebarOpen&&target.closest&&target.closest('#sidebar, #sidebarBackdrop');
|
||||||
|
const inRow=!!(target.closest&&target.closest('.f7s-row, .card, input, textarea, select, button, a, [role="slider"], #seek, #volume'));
|
||||||
|
const inPlayer=target.closest&&target.closest('#playerPane:not(.empty)'),inBody=target.closest&&target.closest('.body');
|
||||||
|
if(!mini&&!edge&&!inSidebar&&(!inBody||inRow)&&!(inPlayer&&!inRow))return;
|
||||||
|
touchDrag={x:t.clientX,y:t.clientY,kind:mini?'mini':edge?'edge':inSidebar?'sidebar':inPlayer?'player':'tabs'};
|
||||||
|
}
|
||||||
|
function finish(d,x,y){
|
||||||
|
if(!d)return;const dir=swipeDirection(x-d.x,y-d.y,d.kind==='mini'?36:56);if(!dir)return;
|
||||||
|
if(d.kind==='mini'){
|
||||||
|
swallowClick=true;setTimeout(()=>{swallowClick=false;},400);
|
||||||
|
if(dir==='left')root.playNext&&root.playNext();else if(dir==='right')root.playPrev&&root.playPrev();else if(dir==='up')openSheet();
|
||||||
|
}else if(d.kind==='sheet'){
|
||||||
|
if(dir==='up'&&sheet){sheet.el.f7Modal.setBreakpoint(1);sheet.el.classList.add('f7s-full');}
|
||||||
|
else if(dir==='down')closeSheet();
|
||||||
|
}else if(d.kind==='edge'&&dir==='right')root.openSidebar&&root.openSidebar();
|
||||||
|
else if(d.kind==='sidebar'&&dir==='left')root.closeSidebar&&root.closeSidebar();
|
||||||
|
else if(d.kind==='tabs'&&(dir==='left'||dir==='right')){
|
||||||
|
const tabs=[...doc.querySelectorAll('#bottomNav .bottom-nav-btn')].filter(b=>!b.disabled&&!b.hidden);
|
||||||
|
const active=Math.max(0,tabs.findIndex(b=>b.classList.contains('active'))),next=tabSwipeIndex(active,tabs.length,dir);
|
||||||
|
if(tabs[next]&&next!==active){swallowClick=true;setTimeout(()=>{swallowClick=false;},400);tabs[next].click();}
|
||||||
|
}else if(d.kind==='player'&&dir==='down'&&innerWidth<=860&&!doc.fullscreenElement&&!doc.querySelector('.orientation-fullscreen')){
|
||||||
|
const search=doc.querySelector('#bottomNav .bottom-nav-btn[data-view="search"]');if(search)search.click();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function touchUp(e){
|
||||||
|
if(!e.changedTouches.length)return;const t=e.changedTouches[0];
|
||||||
|
if(rowDrag){const d=rowDrag;rowDrag=null;const dx=t.clientX-d.x,dy=t.clientY-d.y;
|
||||||
|
if(Math.abs(dx)>Math.max(56,Math.abs(dy)*1.4)){
|
||||||
|
const card=d.row.querySelector('.card');
|
||||||
|
if(dx< -170){const remove=card&&card.querySelector('.card-del');if(remove)remove.click();else {const v=card&&card._video;const del=d.row.querySelector('[data-act="remove"]');if(del)del.click();}}
|
||||||
|
else if(dx<0)openRow(d.row,'right');
|
||||||
|
else if(dx>0&&d.row.querySelector('.swipeout-actions-left'))openRow(d.row,'left');
|
||||||
|
else if(dx>0){const v=card&&card._video;v&&root.addToQueue&&root.addToQueue(v);}
|
||||||
|
else if(dx<0)openRow(d.row,'right');
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if(!touchDrag)return;const d=touchDrag;touchDrag=null;finish(d,t.clientX,t.clientY);
|
||||||
|
}
|
||||||
|
|
||||||
function activate(){
|
function activate(){
|
||||||
active=true;
|
active=true;
|
||||||
@@ -140,12 +229,16 @@
|
|||||||
doc.addEventListener('pointerdown',down,{passive:true});
|
doc.addEventListener('pointerdown',down,{passive:true});
|
||||||
doc.addEventListener('pointerup',up,{passive:true});
|
doc.addEventListener('pointerup',up,{passive:true});
|
||||||
doc.addEventListener('pointercancel',()=>{drag=null;},{passive:true});
|
doc.addEventListener('pointercancel',()=>{drag=null;},{passive:true});
|
||||||
|
doc.addEventListener('touchstart',touchDown,{passive:true,capture:true});
|
||||||
|
doc.addEventListener('touchend',touchUp,{passive:true,capture:true});
|
||||||
|
doc.addEventListener('touchcancel',()=>{touchDrag=null;rowDrag=null;},{passive:true,capture:true});
|
||||||
doc.addEventListener('click',click,true);
|
doc.addEventListener('click',click,true);
|
||||||
|
doc.addEventListener('click',e=>{if(e.target.classList?.contains('sheet-backdrop'))closeSheet();},true);
|
||||||
new root.MutationObserver(sync).observe(html,{attributes:true,attributeFilter:['data-layout']});
|
new root.MutationObserver(sync).observe(html,{attributes:true,attributeFilter:['data-layout']});
|
||||||
sync();
|
sync();
|
||||||
}
|
}
|
||||||
|
|
||||||
const api={cardActions,swipeDirection};
|
const api={cardActions,swipeDirection,tabSwipeIndex};
|
||||||
if(typeof module!=='undefined')module.exports=api;
|
if(typeof module!=='undefined')module.exports=api;
|
||||||
else {root.F7Swipe=api; if(root.document.readyState==='loading')root.document.addEventListener('DOMContentLoaded',()=>mount(root.document),{once:true});else mount(root.document);}
|
else {root.F7Swipe=api; if(root.document.readyState==='loading')root.document.addEventListener('DOMContentLoaded',()=>mount(root.document),{once:true});else mount(root.document);}
|
||||||
})(typeof window==='undefined'?globalThis:window);
|
})(typeof window==='undefined'?globalThis:window);
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
const test = require('node:test');
|
const test = require('node:test');
|
||||||
const assert = require('node:assert');
|
const assert = require('node:assert');
|
||||||
const { cardActions, swipeDirection } = require('./f7-layout');
|
const { cardActions, swipeDirection, tabSwipeIndex } = require('./f7-layout');
|
||||||
|
|
||||||
test('cardActions: queue view has no Queue action, removable rows get Remove', () => {
|
test('cardActions: every view gets Queue, removable rows get Playlist and Remove', () => {
|
||||||
assert.deepStrictEqual(cardActions('queue', true).left, []);
|
assert.deepStrictEqual(cardActions('queue', true).left.map((a) => a.act), ['queue']);
|
||||||
assert.deepStrictEqual(cardActions('queue', true).right.map((a) => a.act), ['playlist', 'remove']);
|
assert.deepStrictEqual(cardActions('queue', true).right.map((a) => a.act), ['playlist', 'remove']);
|
||||||
assert.deepStrictEqual(cardActions('search', false).left.map((a) => a.act), ['queue']);
|
assert.deepStrictEqual(cardActions('search', false).left.map((a) => a.act), ['queue']);
|
||||||
assert.deepStrictEqual(cardActions('search', false).right.map((a) => a.act), ['playlist']);
|
assert.deepStrictEqual(cardActions('search', false).right.map((a) => a.act), ['playlist']);
|
||||||
@@ -17,3 +17,11 @@ test('swipeDirection classifies clear swipes and ignores short or diagonal drags
|
|||||||
assert.strictEqual(swipeDirection(60, 60), null);
|
assert.strictEqual(swipeDirection(60, 60), null);
|
||||||
assert.strictEqual(swipeDirection(40, 0, 36), 'right');
|
assert.strictEqual(swipeDirection(40, 0, 36), 'right');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('tabSwipeIndex moves between bottom tabs and clamps at either end', () => {
|
||||||
|
assert.strictEqual(tabSwipeIndex(0, 5, 'left'), 1);
|
||||||
|
assert.strictEqual(tabSwipeIndex(3, 5, 'right'), 2);
|
||||||
|
assert.strictEqual(tabSwipeIndex(4, 5, 'left'), 4);
|
||||||
|
assert.strictEqual(tabSwipeIndex(0, 5, 'right'), 0);
|
||||||
|
assert.strictEqual(tabSwipeIndex(2, 5, 'up'), -1);
|
||||||
|
});
|
||||||
|
|||||||
BIN
plans/codex-shots-r2/05-song-row-actions.png
Normal file
BIN
plans/codex-shots-r2/05-song-row-actions.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 59 KiB |
BIN
plans/codex-shots-r2/05-song-row-swipe.png
Normal file
BIN
plans/codex-shots-r2/05-song-row-swipe.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 38 KiB |
BIN
plans/codex-shots-r2/06-card-action-menu.png
Normal file
BIN
plans/codex-shots-r2/06-card-action-menu.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 46 KiB |
BIN
plans/codex-shots-r2/06-up-next-sheet.png
Normal file
BIN
plans/codex-shots-r2/06-up-next-sheet.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 6.3 KiB |
BIN
plans/codex-shots-r2/07-up-next-half.png
Normal file
BIN
plans/codex-shots-r2/07-up-next-half.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 74 KiB |
BIN
plans/codex-shots-r2/08-up-next-full.png
Normal file
BIN
plans/codex-shots-r2/08-up-next-full.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 49 KiB |
Reference in New Issue
Block a user