mirror of
https://github.com/SteamFramelyHomebrew/framely.git
synced 2026-10-17 20:01:25 +02:00
Add fullscreen media viewer and download unsupported files on open
This commit is contained in:
1 parent
6cb70b8098
commit
cb67ac5607
9 files changed
+176
-11
No files matched your search
@@ -62,10 +62,12 @@ Select an image or video to preview it. PNG, JPEG, GIF, WebP, AVIF, BMP, MP4, We
|
||||
|
||||
## Access and privacy
|
||||
|
||||
Terminal, file and transfer routes use the existing authenticated manager connection and port. Mutating routes and the terminal WebSocket require an exact matching Origin; plugin sandboxes cannot use them. The service runs as the configured Steam user, independently of the root daemon. Preview/download content is read only after explicit selection. Test media are generated fixtures; Framely does not automatically read or upload Arcturus recordings.
|
||||
Terminal, file and transfer routes use the existing authenticated manager connection and port. Mutating routes and the terminal WebSocket require an exact matching Origin; plugin sandboxes cannot use them. The service runs as the configured Steam user, independently of the root daemon. Full previews and downloads read content after explicit selection; icon views read only visible media to generate lazy thumbnails. There is no background directory media scan. Test media are generated fixtures; development and validation do not read or upload private Arcturus recordings.
|
||||
|
||||
## Diagnostic export
|
||||
|
||||
**Settings → General → Export logs** and the installer’s **Export logs** use the same collector. The archive includes recent APK operation logs, Lepton’s saved Android logs, and a limited snapshot of running containers: Android boot, user unlock, storage mounting, PackageManager readiness and recent main/system/crash logs. Rootless containers are inspected as the configured Steam session user. Stopped containers are not started. App state is summarized without tokens or icons; APK files, saves, media and raw memory dumps are excluded. File and archive limits retain recent output; missing sources, truncation and command timeouts are listed in `report.json`. Logs can contain application information; review before sharing.
|
||||
|
||||
File sorting (name, size or modification time, including ascending/descending order) is remembered separately for each folder. The **View** menu switches between a list and small, medium or large icons; this choice applies to all folders. Preferences are saved on Frame and restored when reopening the panel in a browser or headset. Icon views lazily generate thumbnails for visible images and videos using browser decoding, with at most two concurrent jobs. Videos use a frame around one second. Thumbnails are cached in bounded memory while the page remains open and refreshed when file metadata changes. Scrolling out of view cancels pending work; unsupported formats or decoding failures retain the file-type icon. No FFmpeg or whole-folder media scan is used.
|
||||
|
||||
Clicking an image or video opens a full-panel viewer. Use the wheel or zoom buttons to zoom, drag the content to pan, and use Previous/Next or the arrow keys to browse the image/video files in the current displayed list. Fit to window resets the view; Esc closes it. Video playback controls remain available. If browser decoding fails, download the file from the viewer. UTF-8 text up to 5 MiB still opens in the editor; binary files, unsupported encodings and larger files download directly when clicked.
|
||||
@@ -64,10 +64,12 @@
|
||||
|
||||
## 访问与隐私
|
||||
|
||||
终端、文件与传输使用现有管理面板登录和端口;修改接口及终端 WebSocket 严格匹配 Origin,插件沙箱不可调用。接口运行于 Steam 用户会话服务,不借用 root 本体执行文件操作。预览与下载仅在用户主动选择后读取内容;测试媒体为生成的样本,不自动读取或上传 Arcturus 录制文件。
|
||||
终端、文件与传输使用现有管理面板登录和端口;修改接口及终端 WebSocket 严格匹配 Origin,插件沙箱不可调用。接口运行于 Steam 用户会话服务,不借用 root 本体执行文件操作。完整预览与下载在用户主动选择后读取内容;图标视图仅为当前可见的媒体按需读取并生成缩略图,不后台扫描目录媒体。测试媒体为生成的样本,开发及验证不读取或上传私人 Arcturus 录制文件。
|
||||
|
||||
## 导出诊断日志
|
||||
|
||||
**设置 → 通用 → 导出日志** 和安装器的 **导出日志** 使用同一收集流程。日志包包含近期 APK 操作日志、Lepton 已保存的 Android 日志,以及运行中容器的有限快照:Android 启动、用户解锁、存储挂载、包管理器就绪状态和近期 main/system/crash 日志。以配置的 Steam 会话用户检查无 root 容器,不启动已停止的容器。应用状态只导出摘要,排除令牌和图标;不读取 APK、存档、媒体或原始内存转储。单文件及总大小受限,保留近期输出;缺失来源、截断和命令超时记录在 `report.json`。日志可能包含应用信息,分享前请检查。
|
||||
|
||||
文件排序(名称、大小或修改时间,以及升序/降序)按文件夹分别记忆。“展示方式”可切换列表、小图标、中图标和大图标,此选项对所有文件夹统一生效。偏好保存在 Frame,重新打开浏览器或头显管理面板时恢复。图标视图仅为当前可见的图片和视频按需生成缩略图,使用浏览器解码,最多同时处理两项。视频截取约第 1 秒的画面。缩略图使用有上限的页面内存缓存,文件元数据变化后重新生成;滚出可视区域会取消待处理任务。不支持的格式或解码失败时保留文件类型图标。不使用 FFmpeg,也不扫描整个文件夹的媒体。
|
||||
|
||||
点击图片或视频会打开全屏遮罩预览。使用滚轮或缩放按钮缩放,拖动画面移动,通过上一个/下一个或方向键切换当前展示列表中的图片、视频。“适应窗口”恢复初始视图,Esc 关闭;视频保留播放控制。浏览器无法解码时,可在预览中下载文件。5 MiB 以内的 UTF-8 文本仍在编辑器中打开;二进制文件、不支持的编码及更大的文件点击后直接下载。
|
||||
@@ -8,8 +8,9 @@ const wait=(ms=40)=>new Promise(r=>setTimeout(r,ms));
|
||||
const until=async(f:()=>unknown)=>{for(let n=0;n<150;n++){if(f())return;await wait();}throw Error('Timeout '+document.body.innerText.slice(-800));};
|
||||
const home='/home/steamos',downloads=home+'/Downloads';
|
||||
const longFolder='一个很长的文件夹名称 - Project documents and archived screenshots';
|
||||
let binaryMode=false;
|
||||
let preferences:any={view:'list',folders:{[home]:{sort:'size',descending:true},[downloads]:{sort:'modified',descending:false}}};
|
||||
const calls:any[]=[];
|
||||
const calls:any[]=[],downloadsClicked:string[]=[];const anchorClick=HTMLAnchorElement.prototype.click;HTMLAnchorElement.prototype.click=function(){downloadsClicked.push(this.getAttribute('href')??'');};
|
||||
const files=(directory:string)=>[
|
||||
{name:'Photos',path:directory+'/Photos',directory:true,symlink:false,size:0,mode:493,uid:1000,gid:1000},
|
||||
{name:'A very long photograph name with spaces.png',path:directory+'/picture.png',directory:false,symlink:false,size:8192,modified:1791360000,mode:420,uid:1000,gid:1000},
|
||||
@@ -26,7 +27,7 @@ window.fetch=async(url,init)=>{
|
||||
result=structuredClone(preferences);
|
||||
}else if(p.operation==='list'){
|
||||
let directory=p.path||home;if(directory==='/alias')directory=home;
|
||||
let entries=files(directory);
|
||||
let entries=files(directory);if(binaryMode)entries=[{...entries[2],name:'sample.bin',path:directory+'/sample.bin'}];
|
||||
if(p.hidden){ // Tree requests do not consume the sort checks below.
|
||||
const child=directory==='/'?{name:'home',path:'/home'}:directory==='/home'?{name:'steamos',path:home}:directory===home?{name:longFolder,path:home+'/'+longFolder}:null;
|
||||
entries=child?[{...child,directory:true,symlink:false,size:0,mode:493,uid:1000,gid:1000}]:[];
|
||||
@@ -34,7 +35,8 @@ window.fetch=async(url,init)=>{
|
||||
result={path:directory,parent:'/',entries,total:entries.length,roots:[{name:'Home',path:home},{name:'Downloads',path:downloads}]};
|
||||
}else if(p.operation==='preview'){const canvas=document.createElement('canvas');canvas.width=80;canvas.height=60;const ctx=canvas.getContext('2d')!;ctx.fillStyle='#8bb4ce';ctx.fillRect(0,0,80,60);result={url:p.path.endsWith('.png')?canvas.toDataURL(): 'data:video/webm;base64,AA==',token:p.path};}
|
||||
else if(p.operation==='preview.release')result=true;
|
||||
else if(p.operation==='read')result={text:'fixture text',revision:'revision'};
|
||||
else if(p.operation==='read'){if(p.path.endsWith('notes.txt'))result={text:'fixture text',revision:'revision'};else return new Response(JSON.stringify({error:'Binary files cannot be edited as text'}));}
|
||||
else if(p.operation==='download')result={url:'/fixture-download'};
|
||||
else throw Error('Unexpected operation '+p.operation);
|
||||
return new Response(JSON.stringify({result}));
|
||||
};
|
||||
@@ -71,12 +73,14 @@ const changeAddress=async(value:string)=>{const input=document.querySelector<HTM
|
||||
await changeAddress('/alias');await until(()=>ready()&&document.querySelector<HTMLInputElement>('.file-address input')!.value===home);
|
||||
if(listing().sort!=='size')throw Error('Canonical folder preference not restored');
|
||||
const tile=document.querySelector<HTMLButtonElement>('.file-tile-open[title="notes.txt"]')!;tile.click();await until(()=>document.querySelector('.file-editor'));button('Close').click();
|
||||
document.querySelector<HTMLButtonElement>('.file-tile-open[title="A very long photograph name with spaces.png"]')!.click();await until(()=>document.querySelector('.file-media-overlay img'));await until(()=>document.querySelector('.file-media-overlay img')?.getAttribute('src'));document.querySelector<HTMLButtonElement>('.file-media-overlay [aria-label="Close"]')!.click();await until(()=>!document.querySelector('.file-media-overlay'));
|
||||
await wait(400);console.log('FRAMELY_PREVIEW_INSTALL_FILE_MANAGER_ICONS_EN');await wait(300);
|
||||
configureLanguage('zh-CN',[]);render('three');await wait();await until(()=>ready()&&document.querySelector('.icons-medium'));await select('展示方式','大图标');await until(()=>document.querySelector('.icons-large'));
|
||||
await wait(400);console.log('FRAMELY_PREVIEW_INSTALL_FILE_MANAGER_ICONS_ZH');await wait(300);
|
||||
await select('展示方式','列表');await until(()=>document.querySelector('.file-table'));document.querySelector<HTMLInputElement>('[aria-label="选择本页"]')!.click();await until(()=>document.querySelectorAll('.file-table tr.selected').length===4);
|
||||
if(calls.some(p=>p.operation==='preview'&&!/\.(png|webm)$/.test(p.path)))throw Error('Non-media fetched for thumbnails');
|
||||
if(!calls.some(p=>p.operation==='preview.release'))throw Error('Source preview grants not released');
|
||||
console.log('FRAMELY_BRIDGE_PASS');
|
||||
binaryMode=true;document.querySelector<HTMLButtonElement>('[aria-label="刷新"]')!.click();await until(()=>document.querySelector('.file-name')?.textContent?.includes('sample.bin'));document.querySelector<HTMLButtonElement>('.file-name')!.click();await until(()=>downloadsClicked.includes('/fixture-download'));if(document.querySelector('.modal'))throw Error('Unsupported file opened a dialog');
|
||||
HTMLAnchorElement.prototype.click=anchorClick;console.log('FRAMELY_BRIDGE_PASS');
|
||||
}catch(e){console.error('FRAMELY_BRIDGE_FAIL '+String(e)+' '+(e as Error).stack);}};
|
||||
console.log('FRAMELY_VIEW_READY');
|
||||
@@ -0,0 +1,41 @@
|
||||
import React from 'react';
|
||||
import {createRoot} from 'react-dom/client';
|
||||
import {FileMediaPreview} from '../ui/src/file-media-preview';
|
||||
import {configureLanguage} from '../ui/src/i18n';
|
||||
import '../ui/src/style.css';
|
||||
import '../ui/src/file-manager.css';
|
||||
const wait=(ms=40)=>new Promise(r=>setTimeout(r,ms));
|
||||
const until=async(f:()=>unknown)=>{for(let n=0;n<200;n++){if(f())return;await wait();}throw Error('Preview timeout '+document.body.innerText);};
|
||||
const root=createRoot(document.getElementById('root')!);
|
||||
let imageUrl='',videoUrl='',closed=0;const released:string[]=[],downloads:string[]=[];
|
||||
window.fetch=async(_url,init)=>{const p=JSON.parse(String(init?.body));if(p.operation==='preview.release'){released.push(p.token);return new Response(JSON.stringify({result:true}));}if(p.operation!=='preview')throw Error('Unexpected operation');await wait(60);return new Response(JSON.stringify({result:{url:p.path==='/broken.png'?'data:image/png;base64,AA==':p.path.endsWith('.webm')?videoUrl:imageUrl,token:p.path}}));};
|
||||
const entries=[{path:'/first.png',name:'Generated landscape.png'},{path:'/clip.webm',name:'Generated video.webm'},{path:'/last.png',name:'Second generated image.png'},{path:'/broken.png',name:'Unsupported image.png'}];
|
||||
const render=(initial=0)=>root.render(<><button id="opener">Files</button><FileMediaPreview entries={entries} initial={initial} onClose={()=>{closed++;root.render(<button id="opener">Files</button>);}} onDownload={path=>downloads.push(path)}/></>);
|
||||
const button=(label:string)=>document.querySelector<HTMLButtonElement>(`.file-media-overlay [aria-label="${label}"]`)!;
|
||||
const loaded=()=>document.querySelector<HTMLElement>('.file-media-canvas')?.style.visibility==='visible'&&!document.querySelector('.file-media-loading')&&!document.querySelector('.file-media-error');
|
||||
const transform=()=>document.querySelector<HTMLElement>('.file-media-canvas')!.style.transform;
|
||||
async function samples(){const canvas=document.createElement('canvas');canvas.width=640;canvas.height=360;const ctx=canvas.getContext('2d')!;ctx.fillStyle='#63869b';ctx.fillRect(0,0,640,360);ctx.fillStyle='#bcc4c9';ctx.beginPath();ctx.moveTo(0,360);ctx.lineTo(220,100);ctx.lineTo(450,360);ctx.fill();imageUrl=canvas.toDataURL();const stream=canvas.captureStream(10),recorder=new MediaRecorder(stream,{mimeType:'video/webm;codecs=vp8'}),chunks:Blob[]=[];recorder.ondataavailable=e=>chunks.push(e.data);const stopped=new Promise<void>(r=>recorder.onstop=()=>r());recorder.start();const timer=setInterval(()=>ctx.fillRect(500,100,40,40),70);await wait(1200);recorder.stop();await stopped;clearInterval(timer);stream.getTracks().forEach(t=>t.stop());videoUrl=URL.createObjectURL(new Blob(chunks,{type:'video/webm'}));}
|
||||
(window as any).runInstallReviewChecks=async()=>{try{
|
||||
configureLanguage('en-US',[]);root.render(<div className="app manager" style={{height:'100vh',background:'#222'}}><button>Files</button></div>);await wait(300);await samples();render();await until(loaded);
|
||||
const overlay=document.querySelector<HTMLElement>('.file-media-overlay')!,bounds=overlay.getBoundingClientRect();if(bounds.width!==innerWidth||bounds.height!==innerHeight)throw Error('Viewer not fullscreen');
|
||||
if(!button('Previous file').disabled)throw Error('Previous not disabled on first file');
|
||||
button('Zoom in').click();await until(()=>document.querySelector('output')?.textContent==='125%');
|
||||
const stage=document.querySelector<HTMLElement>('.file-media-stage')!;stage.dispatchEvent(new WheelEvent('wheel',{deltaY:-100,clientX:180,clientY:150,bubbles:true,cancelable:true}));await until(()=>transform().includes('scale(1.5'));
|
||||
// Synthetic events cannot capture native pointers. Exercise the same pan handler
|
||||
// while retaining real browser media decoding and layout.
|
||||
const capture=Element.prototype.setPointerCapture;Element.prototype.setPointerCapture=function(){};
|
||||
stage.dispatchEvent(new PointerEvent('pointerdown',{pointerId:7,clientX:100,clientY:100,button:0,bubbles:true,cancelable:true}));const before=transform();stage.dispatchEvent(new PointerEvent('pointermove',{pointerId:7,clientX:140,clientY:120,bubbles:true,cancelable:true}));await until(()=>transform()!==before);stage.dispatchEvent(new PointerEvent('pointerup',{pointerId:7,bubbles:true}));Element.prototype.setPointerCapture=capture;
|
||||
button('Fit to window').click();await until(()=>transform()==='translate(0px, 0px) scale(1)');
|
||||
overlay.dispatchEvent(new KeyboardEvent('keydown',{key:'ArrowRight',bubbles:true}));await until(()=>document.querySelector('video')&&loaded());
|
||||
if(!document.querySelector('video')!.controls||!released.includes('/first.png'))throw Error('Video controls/grant release missing');
|
||||
button('Download').click();if(downloads[0]!=='/clip.webm')throw Error('Wrong video downloaded');
|
||||
button('Next file').click();await until(()=>document.querySelector('img')?.getAttribute('alt')===entries[2].name&&loaded());
|
||||
if(!transform().includes('scale(1)'))throw Error('Switch retained transform');
|
||||
button('Next file').click();await until(()=>document.querySelector('.file-media-error'));if(!button('Next file').disabled)throw Error('Next not disabled on last file');
|
||||
document.querySelector<HTMLButtonElement>('.file-media-error button')!.click();if(!downloads.includes('/broken.png'))throw Error('Decode failure download missing');
|
||||
button('Previous file').click();await until(loaded);await wait(200);console.log('FRAMELY_PREVIEW_INSTALL_FILE_MEDIA_EN');await wait(250);
|
||||
overlay.dispatchEvent(new KeyboardEvent('keydown',{key:'Escape',bubbles:true}));await until(()=>closed===1&&!document.querySelector('.file-media-overlay'));await until(()=>released.includes('/last.png'));
|
||||
configureLanguage('zh-CN',[]);render();await until(loaded);if(!button('放大')||!button('下一个文件'))throw Error('Chinese controls missing');await wait(200);console.log('FRAMELY_PREVIEW_INSTALL_FILE_MEDIA_ZH');await wait(250);
|
||||
button('关闭').click();await until(()=>closed===2);URL.revokeObjectURL(videoUrl);console.log('FRAMELY_BRIDGE_PASS');
|
||||
}catch(e){console.error('FRAMELY_BRIDGE_FAIL '+String(e)+' '+(e as Error).stack);}};
|
||||
console.log('FRAMELY_VIEW_READY');
|
||||
@@ -992,6 +992,13 @@
|
||||
"选择 {0}": "Select {0}",
|
||||
"Invalid file view": "Invalid file view",
|
||||
"Invalid file sort": "Invalid file sort",
|
||||
"Invalid file sort direction": "Invalid file sort direction"
|
||||
"Invalid file sort direction": "Invalid file sort direction",
|
||||
"缩小": "Zoom out",
|
||||
"放大": "Zoom in",
|
||||
"缩放比例": "Zoom level",
|
||||
"适应窗口": "Fit to window",
|
||||
"上一个文件": "Previous file",
|
||||
"下一个文件": "Next file",
|
||||
"滚轮缩放 · 拖动移动 · ← → 切换 · Esc 关闭": "Scroll to zoom · Drag to pan · ← → Browse · Esc Close"
|
||||
}
|
||||
}
|
||||
@@ -992,6 +992,13 @@
|
||||
"选择 {0}": "选择 {0}",
|
||||
"Invalid file view": "无效的文件展示方式",
|
||||
"Invalid file sort": "无效的文件排序方式",
|
||||
"Invalid file sort direction": "无效的文件排序方向"
|
||||
"Invalid file sort direction": "无效的文件排序方向",
|
||||
"缩小": "缩小",
|
||||
"放大": "放大",
|
||||
"缩放比例": "缩放比例",
|
||||
"适应窗口": "适应窗口",
|
||||
"上一个文件": "上一个文件",
|
||||
"下一个文件": "下一个文件",
|
||||
"滚轮缩放 · 拖动移动 · ← → 切换 · Esc 关闭": "滚轮缩放 · 拖动移动 · ← → 切换 · Esc 关闭"
|
||||
}
|
||||
}
|
||||
@@ -185,3 +185,22 @@
|
||||
.file-manager .file-thumbnail{position:relative;width:100%;height:100%;display:grid;place-items:center}
|
||||
.file-manager .file-media-thumbnail{width:100%;height:100%;object-fit:contain;border-radius:5px;user-select:none;pointer-events:none}
|
||||
.file-manager .file-video-marker{position:absolute;bottom:2px;left:2px;color:#f4f4f6;filter:drop-shadow(0 1px 2px #000);pointer-events:none}
|
||||
/* Media viewer covers the entire panel or browser, independently of file layout. */
|
||||
.file-manager.file-media-overlay{position:fixed;inset:0;z-index:150;display:flex;flex-direction:column;background:#111113f5;color:#ededf0;outline:none;--file-text:#ededf0;--file-muted:#a6a6af}
|
||||
.file-media-header{position:relative;z-index:2;display:flex;align-items:center;justify-content:space-between;gap:16px;padding:14px 20px;border-bottom:1px solid #ffffff12;background:#19191b}
|
||||
.file-media-header>div:first-child{display:flex;align-items:baseline;gap:14px;min-width:0}
|
||||
.file-media-header strong{font-size:16px;font-weight:500;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
|
||||
.file-media-header span,.file-media-actions output{color:#a6a6af;font-size:13px;white-space:nowrap}
|
||||
.file-media-actions{display:flex;align-items:center;gap:5px;flex-shrink:0}
|
||||
.file-manager .file-media-actions button,.file-manager .file-media-prev,.file-manager .file-media-next{display:grid;place-items:center;width:40px;height:40px;padding:0;border:1px solid #ffffff14;border-radius:10px;background:#29292de0;color:#ececef}
|
||||
.file-media-actions output{min-width:44px;text-align:center;font-variant-numeric:tabular-nums}
|
||||
.file-media-stage{position:relative;flex:1;min-height:0;overflow:hidden;touch-action:none;user-select:none;cursor:grab}
|
||||
.file-media-stage:active{cursor:grabbing}
|
||||
.file-media-canvas{position:absolute;inset:0;display:flex;align-items:center;justify-content:center;transform-origin:center;will-change:transform;pointer-events:none}
|
||||
.file-media-canvas img,.file-media-canvas video{max-width:calc(100% - 120px);max-height:calc(100% - 32px);object-fit:contain;display:block}
|
||||
.file-media-canvas video{pointer-events:auto;cursor:auto;touch-action:auto}
|
||||
.file-media-loading,.file-media-error{position:absolute;inset:0;display:flex;flex-direction:column;gap:14px;align-items:center;justify-content:center;padding:40px;text-align:center;color:#b1b1bb;cursor:default}
|
||||
.file-manager .file-media-prev,.file-manager .file-media-next{position:absolute;top:50%;z-index:3;width:44px;height:52px}
|
||||
.file-media-prev{left:16px}.file-media-next{right:16px}
|
||||
.file-media-footer{padding:9px 16px;text-align:center;font-size:12px;color:#9999a5;border-top:1px solid #ffffff0a}
|
||||
@media(max-width:700px){.file-media-header{flex-wrap:wrap;padding:10px 12px;gap:8px}.file-media-header>div:first-child{width:100%;justify-content:space-between}.file-media-actions{margin-left:auto}.file-media-canvas img,.file-media-canvas video{max-width:calc(100% - 76px)}.file-media-prev{left:6px}.file-media-next{right:6px}.file-manager .file-media-prev,.file-manager .file-media-next{width:32px;height:44px}.file-media-footer{font-size:11px}}
|
||||
+14
-3
@@ -2,6 +2,7 @@ import React,{useEffect,useRef,useState} from 'react';
|
||||
import {IconFolder,IconFile,IconArrowUp,IconRefresh,IconUpload,IconDownload,IconStar,IconTrash,IconChevronRight,IconChevronDown,IconSearch,IconFolderPlus,IconPlus,IconCopy,IconCut,IconClipboard,IconDots,IconPencil,IconX,IconArrowDown,IconHome,IconDeviceSdCard,IconFileText,IconPhoto,IconMovie,IconFileZip,IconExternalLink,IconInfoCircle,IconArrowLeft} from '@tabler/icons-react';
|
||||
import {managerApi,ManagerError} from './manager-api';
|
||||
import {FileThumbnail} from './file-thumbnail';
|
||||
import {FileMediaPreview,mediaKind,MediaEntry} from './file-media-preview';
|
||||
import {api} from './api';
|
||||
import {uploadFile} from './upload';
|
||||
import {Select} from './localized-select';
|
||||
@@ -33,7 +34,7 @@ function FolderTree({name,path,onOpen,current,depth=0}:{name:string;path:string;
|
||||
return <div><div ref={row} className="file-side-entry file-tree-entry"><button className="file-tree-toggle" disabled={depth>=20} aria-expanded={expanded} aria-label={t(expanded?'收起文件夹':'展开文件夹')} onClick={()=>{if(expanded){request.current++;setExpanded(false);}else void expand();}}>{expanded?<IconChevronDown size={16}/>:<IconChevronRight size={16}/>}</button><button title={path} aria-current={current===path?'location':undefined} className={current===path?'active':''} onClick={()=>onOpen(path)}><IconFolder size={18}/><span>{name}</span></button></div>{error&&expanded&&<small>{error}</small>}{expanded&&<div className="file-tree">{children.map(e=><FolderTree key={e.path} name={e.name} path={e.path} current={current} onOpen={onOpen} depth={depth+1}/>)}{more&&<button onClick={()=>void expand(offset)}>{t('加载更多')}</button>}</div>}</div>;
|
||||
}
|
||||
export function FileManager({active=true}:{active?:boolean}){
|
||||
const[data,setData]=useState<Listing>({path:'',entries:[],total:0,roots:[]}),[path,setPath]=useState(''),[address,setAddress]=useState(''),[query,setQuery]=useState(''),[hidden,setHidden]=useState(false),[offset,setOffset]=useState(0),[selected,setSelected]=useState<string[]>([]),[bookmarks,setBookmarks]=useState<Bookmark[]>([]),[error,setError]=useState(''),[busy,setBusy]=useState(false),[loading,setLoading]=useState(true),[dialog,setDialog]=useState<Dialog|null>(null),[value,setValue]=useState(''),[policy,setPolicy]=useState('keep'),[permanent,setPermanent]=useState(false),[job,setJob]=useState<Job|null>(null),[clipboard,setClipboard]=useState<{paths:string[];cut:boolean}|null>(null),[preview,setPreview]=useState<{entry:Entry;url:string;video:boolean}|null>(null),[editor,setEditor]=useState<{path:string;text:string;revision:string}|null>(null),[modified,setModified]=useState(false),[trash,setTrash]=useState<any[]|null>(null),[upload,setUpload]=useState<{name:string;received:number;total:number}|null>(null),[results,setResults]=useState<any[]|null>(null),[counter,setCounter]=useState(0),[context,setContext]=useState(false),[bookmarkMenu,setBookmarkMenu]=useState<string|null>(null),[outcomes,setOutcomes]=useState<any[]>([]),[menu,setMenu]=useState<{x:number;y:number;blank:boolean}|null>(null);
|
||||
const[data,setData]=useState<Listing>({path:'',entries:[],total:0,roots:[]}),[path,setPath]=useState(''),[address,setAddress]=useState(''),[query,setQuery]=useState(''),[hidden,setHidden]=useState(false),[offset,setOffset]=useState(0),[selected,setSelected]=useState<string[]>([]),[bookmarks,setBookmarks]=useState<Bookmark[]>([]),[error,setError]=useState(''),[busy,setBusy]=useState(false),[loading,setLoading]=useState(true),[dialog,setDialog]=useState<Dialog|null>(null),[value,setValue]=useState(''),[policy,setPolicy]=useState('keep'),[permanent,setPermanent]=useState(false),[job,setJob]=useState<Job|null>(null),[clipboard,setClipboard]=useState<{paths:string[];cut:boolean}|null>(null),[preview,setPreview]=useState<{entries:MediaEntry[];initial:number}|null>(null),[editor,setEditor]=useState<{path:string;text:string;revision:string}|null>(null),[modified,setModified]=useState(false),[trash,setTrash]=useState<any[]|null>(null),[upload,setUpload]=useState<{name:string;received:number;total:number}|null>(null),[results,setResults]=useState<any[]|null>(null),[counter,setCounter]=useState(0),[context,setContext]=useState(false),[bookmarkMenu,setBookmarkMenu]=useState<string|null>(null),[outcomes,setOutcomes]=useState<any[]>([]),[menu,setMenu]=useState<{x:number;y:number;blank:boolean}|null>(null);
|
||||
const[filePreferences,setFilePreferences]=useState<FilePreferences>({view:'list',folders:{}}),[preferencesReady,setPreferencesReady]=useState(false);
|
||||
const preferenceWrites=useRef<Promise<unknown>>(Promise.resolve());
|
||||
const sort=filePreferences.folders[path]?.sort??'name',descending=filePreferences.folders[path]?.descending??false,view=filePreferences.view;
|
||||
@@ -68,7 +69,17 @@ export function FileManager({active=true}:{active?:boolean}){
|
||||
async function run(p:any){try{await perform(p);}catch{}}
|
||||
function download(url:string){const a=document.createElement('a');a.href=url;a.download='';document.body.appendChild(a);a.click();a.remove();}
|
||||
async function getDownload(paths=selected){try{if(paths.length===1&&!shown.find(e=>e.path===paths[0])?.directory){const v=await perform({operation:'download',path:paths[0]});download(v.url);}else{const v=await perform({operation:'download.batch',paths});download(v.url);}}catch{}}
|
||||
async function open(e:Entry){if(e.directory){navigate(e.path);return;}const ext=e.name.split('.').pop()?.toLowerCase()??'';if(['png','jpg','jpeg','gif','webp','avif','bmp','mp4','webm','mov','m4v'].includes(ext)){try{const v=await perform({operation:'preview',path:e.path});setPreview({entry:e,url:v.url,video:['mp4','webm','mov','m4v'].includes(ext)});}catch{}return;}try{const v=await perform({operation:'read',path:e.path});setEditor({path:e.path,...v});setModified(false);}catch{openDialog({title:t('文件属性'),kind:'properties',entry:e,value:e.mode.toString(8)});}}
|
||||
async function open(e:Entry){
|
||||
if(e.directory){navigate(e.path);return;}
|
||||
if(mediaKind(e)){const entries=shown.filter(item=>!item.directory&&mediaKind(item));setMenu(null);setError('');setPreview({entries,initial:Math.max(0,entries.findIndex(item=>item.path===e.path))});return;}
|
||||
// UTF-8 text remains editable. Other formats download instead of opening properties.
|
||||
if(e.size>5*1024*1024){await getDownload([e.path]);return;}
|
||||
setBusy(true);setError('');
|
||||
try{const v=await managerApi('files',{operation:'read',path:e.path});setEditor({path:e.path,...v});setModified(false);}
|
||||
catch(error){const raw=error instanceof ManagerError?error.raw:String(error);if(['Binary files cannot be edited as text','Only UTF-8 text can be edited','Text editor supports files up to 5 MiB'].some(message=>raw.includes(message)))await getDownload([e.path]);else setError(String(error));}
|
||||
finally{setBusy(false);}
|
||||
}
|
||||
|
||||
useEffect(()=>()=>cancelUpload.current?.abort(),[]);
|
||||
async function uploads(files:File[]){
|
||||
setBusy(true);setError('');const controller=new AbortController();cancelUpload.current=controller;let id='';
|
||||
@@ -173,7 +184,7 @@ export function FileManager({active=true}:{active?:boolean}){
|
||||
{dialog.kind==='delete'&&<><p>{t('请选择删除方式。')}</p><Select label={t('删除方式')} value={permanent?'permanent':'trash'} onChange={v=>setPermanent(v==='permanent')} options={[{value:'trash',label:t('移至回收站(30 天)')},{value:'permanent',label:t('永久删除')}]}/>{permanent&&<p>{t('永久删除无法撤销。')}</p>}</>}
|
||||
{['paste','upload','compress','extract','trash.restore'].includes(dialog.kind)&&<><p>{t('遇到同名文件时')}</p><Select label={t('同名处理')} value={policy} onChange={setPolicy} options={[{value:'keep',label:t('保留两份')},{value:'skip',label:t('跳过')},...(dialog.kind==='trash.restore'?[]:[{value:'overwrite',label:t('覆盖')}])]}/>{policy==='overwrite'&&<p>{t('确认后会覆盖同名文件或整个同名文件夹。')}</p>}</>}{dialog.kind==='compress'&&<p>{t('支持 .zip、.tar 和 .tar.gz。')}</p>}{dialog.kind==='trash.purge'&&<p>{t('永久删除无法撤销。')}</p>}{dialog.kind==='editor.discard'&&<p>{t('关闭将丢弃未保存的修改。')}</p>}{dialog.kind==='editor.overwrite'&&<p>{t('文件已被外部修改。可重新加载,或确认覆盖。')}</p>}
|
||||
<footer>{dialog.kind==='editor.overwrite'&&<button onClick={()=>void perform({operation:'read',path:editor?.path}).then(v=>{setEditor(e=>e?{...e,...v}:null);setModified(false);setDialog(null);}).catch(()=>{})}>{t('重新加载')}</button>}<button disabled={busy} onClick={()=>setDialog(null)}>{t('取消')}</button><button disabled={busy} className={permanent||dialog.kind==='trash.purge'?'danger':'primary'} onClick={()=>void submit()}>{t('确认')}</button></footer></div>}
|
||||
{active&&preview&&<div className="modal file-preview" role="dialog" aria-modal="true" aria-label={preview.entry.name}><h2>{preview.entry.name}</h2>{error&&<p className="error">{error}</p>}{preview.video?<video src={preview.url} controls playsInline onError={()=>setError(t('浏览器无法播放此格式,请下载后打开。'))}/>:<img src={preview.url} alt={preview.entry.name} onError={()=>setError(t('浏览器无法预览此格式,请下载后打开。'))}/>}<footer><button onClick={()=>void getDownload([preview.entry.path])}>{t('下载文件')}</button><button onClick={()=>setPreview(null)}>{t('关闭')}</button></footer></div>}
|
||||
{active&&preview&&<FileMediaPreview entries={preview.entries} initial={preview.initial} onClose={()=>setPreview(null)} onDownload={path=>void getDownload([path])}/>}
|
||||
{active&&editor&&!dialog&&<div className="modal file-editor-modal" role="dialog" aria-modal="true"><h2>{editor.path.split('/').pop()}{modified?' *':''}</h2>{error&&<p className="error">{error}</p>}<textarea aria-label={t('文本内容')} className="file-editor" value={editor.text} onChange={e=>{setEditor(v=>v?{...v,text:e.target.value}:null);setModified(true);}}/><footer><button disabled={busy} onClick={()=>void saveText()}>{t('保存')}</button><button onClick={()=>{if(!modified)setEditor(null);else openDialog({title:t('未保存的修改'),kind:'editor.discard'});}}>{t('关闭')}</button></footer></div>}
|
||||
</section>;
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
import React,{useEffect,useRef,useState} from 'react';
|
||||
import {createPortal} from 'react-dom';
|
||||
import {IconChevronLeft,IconChevronRight,IconDownload,IconX,IconZoomIn,IconZoomOut,IconArrowsMaximize} from '@tabler/icons-react';
|
||||
import {managerApi} from './manager-api';
|
||||
import {t} from './i18n';
|
||||
export type MediaEntry={path:string;name:string};
|
||||
const images=new Set(['png','jpg','jpeg','gif','webp','avif','bmp']),videos=new Set(['mp4','webm','mov','m4v']);
|
||||
export function mediaKind(entry:MediaEntry){const ext=entry.name.split('.').pop()?.toLowerCase()??'';return images.has(ext)?'image':videos.has(ext)?'video':'';}
|
||||
type Transform={scale:number;x:number;y:number};
|
||||
const fitted:Transform={scale:1,x:0,y:0};
|
||||
export function FileMediaPreview({entries,initial,onClose,onDownload}:{entries:MediaEntry[];initial:number;onClose:()=>void;onDownload:(path:string)=>void}){
|
||||
const[index,setIndex]=useState(initial),[source,setSource]=useState<{path:string;url:string}|null>(null),[error,setError]=useState(''),[ready,setReady]=useState(false),[transform,setTransform]=useState(fitted);
|
||||
const entry=entries[index],video=mediaKind(entry)==='video',url=source?.path===entry.path?source.url:'';
|
||||
const overlay=useRef<HTMLDivElement>(null),stage=useRef<HTMLDivElement>(null),view=useRef(fitted),points=useRef(new Map<number,{x:number;y:number}>());
|
||||
const callbacks=useRef({onClose,onDownload});callbacks.current={onClose,onDownload};
|
||||
function apply(next:Transform){view.current=next;setTransform(next);}
|
||||
function zoom(scale:number,anchor={x:0,y:0}){const current=view.current,next=Math.min(8,Math.max(.25,scale)),ratio=next/current.scale;apply({scale:next,x:anchor.x-(anchor.x-current.x)*ratio,y:anchor.y-(anchor.y-current.y)*ratio});}
|
||||
function move(direction:number){setIndex(current=>Math.max(0,Math.min(entries.length-1,current+direction)));}
|
||||
useEffect(()=>{
|
||||
const previous=document.activeElement as HTMLElement|null;overlay.current?.focus();
|
||||
const overflow=document.body.style.overflow;document.body.style.overflow='hidden';
|
||||
return()=>{document.body.style.overflow=overflow;if(previous?.isConnected)previous.focus();};
|
||||
},[]);
|
||||
useEffect(()=>{
|
||||
setSource(null);setError('');setReady(false);apply(fitted);points.current.clear();let live=true,token:string|undefined;
|
||||
const release=()=>{if(token){void managerApi('files',{operation:'preview.release',token}).catch(()=>{});token=undefined;}};
|
||||
// A completed request is always released, even if the user closes while it is pending.
|
||||
void managerApi<{url:string;token?:string}>('files',{operation:'preview',path:entry.path}).then(result=>{token=result.token;if(live)setSource({path:entry.path,url:result.url});else release();}).catch(e=>{if(live)setError(String(e));});
|
||||
return()=>{live=false;release();};
|
||||
},[entry.path]);
|
||||
function keyDown(event:React.KeyboardEvent){
|
||||
if(event.key==='Tab'){
|
||||
const buttons=Array.from(overlay.current!.querySelectorAll<HTMLElement>('button:not(:disabled),video[controls]'));
|
||||
const first=buttons[0],last=buttons.at(-1);if(event.shiftKey&&(document.activeElement===first||document.activeElement===overlay.current)){event.preventDefault();last?.focus();}else if(!event.shiftKey&&(document.activeElement===last||document.activeElement===overlay.current)){event.preventDefault();first?.focus();}return;
|
||||
}
|
||||
// Let the native player retain its own keyboard controls.
|
||||
if((event.target as HTMLElement).tagName==='VIDEO'&&event.key!=='Escape')return;
|
||||
if(event.key==='Escape'){event.preventDefault();event.stopPropagation();callbacks.current.onClose();}
|
||||
else if(event.key==='ArrowLeft'){event.preventDefault();move(-1);}
|
||||
else if(event.key==='ArrowRight'){event.preventDefault();move(1);}
|
||||
else if(event.key==='+'||event.key==='='){event.preventDefault();zoom(view.current.scale*1.25);}
|
||||
else if(event.key==='-'){event.preventDefault();zoom(view.current.scale/1.25);}
|
||||
else if(event.key==='0'){event.preventDefault();apply(fitted);}
|
||||
}
|
||||
useEffect(()=>{const element=stage.current!;const wheel=(event:WheelEvent)=>{event.preventDefault();event.stopPropagation();const bounds=element.getBoundingClientRect();zoom(view.current.scale*Math.exp(-event.deltaY*.002),{x:event.clientX-bounds.left-bounds.width/2,y:event.clientY-bounds.top-bounds.height/2});};element.addEventListener('wheel',wheel,{passive:false});return()=>element.removeEventListener('wheel',wheel);},[]);
|
||||
function position(event:React.PointerEvent){return{x:event.clientX,y:event.clientY};}
|
||||
function geometry(){const p=[...points.current.values()];return{center:{x:(p[0].x+(p[1]?.x??p[0].x))/2,y:(p[0].y+(p[1]?.y??p[0].y))/2},distance:p[1]?Math.hypot(p[1].x-p[0].x,p[1].y-p[0].y):0};}
|
||||
function start(event:React.PointerEvent){if(event.button!==0||!ready||(event.target as HTMLElement).closest('button'))return;const player=(event.target as HTMLElement).closest('video');if(player&&event.clientY>player.getBoundingClientRect().bottom-48)return;event.preventDefault();points.current.set(event.pointerId,position(event));event.currentTarget.setPointerCapture(event.pointerId);}
|
||||
function drag(event:React.PointerEvent){if(!points.current.has(event.pointerId))return;event.preventDefault();const before=geometry();points.current.set(event.pointerId,position(event));const after=geometry(),current=view.current;
|
||||
if(before.distance&&after.distance){const bounds=stage.current!.getBoundingClientRect(),anchor={x:before.center.x-bounds.left-bounds.width/2,y:before.center.y-bounds.top-bounds.height/2};zoom(current.scale*after.distance/before.distance,anchor);}
|
||||
apply({...view.current,x:view.current.x+after.center.x-before.center.x,y:view.current.y+after.center.y-before.center.y});
|
||||
}
|
||||
function end(event:React.PointerEvent){points.current.delete(event.pointerId);if(event.currentTarget.hasPointerCapture(event.pointerId))event.currentTarget.releasePointerCapture(event.pointerId);}
|
||||
return createPortal(<div className="file-manager file-media-overlay" data-framely-no-scroll-drag ref={overlay} role="dialog" aria-modal="true" aria-label={entry.name} tabIndex={-1} onKeyDown={keyDown}>
|
||||
<header className="file-media-header"><div><strong title={entry.name}>{entry.name}</strong><span>{index+1} / {entries.length}</span></div><div className="file-media-actions">
|
||||
<button aria-label={t('缩小')} title={t('缩小')} disabled={!ready||transform.scale<=.25} onClick={()=>zoom(view.current.scale/1.25)}><IconZoomOut size={21}/></button><output aria-label={t('缩放比例')}>{Math.round(transform.scale*100)}%</output>
|
||||
<button aria-label={t('放大')} title={t('放大')} disabled={!ready||transform.scale>=8} onClick={()=>zoom(view.current.scale*1.25)}><IconZoomIn size={21}/></button>
|
||||
<button aria-label={t('适应窗口')} title={t('适应窗口')} disabled={!ready} onClick={()=>apply(fitted)}><IconArrowsMaximize size={21}/></button>
|
||||
<button aria-label={t('下载文件')} title={t('下载文件')} onClick={()=>callbacks.current.onDownload(entry.path)}><IconDownload size={21}/></button>
|
||||
<button aria-label={t('关闭')} title={t('关闭')} onClick={()=>callbacks.current.onClose()}><IconX size={22}/></button>
|
||||
</div></header>
|
||||
<div className="file-media-stage" ref={stage} onPointerDown={start} onPointerMove={drag} onPointerUp={end} onPointerCancel={end} onLostPointerCapture={event=>points.current.delete(event.pointerId)} onDoubleClick={event=>{if((event.target as HTMLElement).tagName!=='VIDEO')view.current.scale===1?zoom(2):apply(fitted);}}>
|
||||
{!ready&&!error&&<div className="file-media-loading" role="status">{t('加载中…')}</div>}
|
||||
{error?<div className="file-media-error" role="alert"><p>{error}</p><button onClick={()=>callbacks.current.onDownload(entry.path)}><IconDownload size={20}/>{t('下载文件')}</button></div>:url&&<div className="file-media-canvas" style={{transform:`translate(${transform.x}px,${transform.y}px) scale(${transform.scale})`,visibility:ready?'visible':'hidden'}}>
|
||||
{video?<video key={url} src={url} controls playsInline preload="metadata" onLoadedData={()=>setReady(true)} onError={()=>setError(t('浏览器无法播放此格式,请下载后打开。'))}/>:<img key={url} src={url} alt={entry.name} draggable={false} onLoad={()=>setReady(true)} onError={()=>setError(t('浏览器无法预览此格式,请下载后打开。'))}/>}
|
||||
</div>}
|
||||
</div>
|
||||
<button className="file-media-prev" aria-label={t('上一个文件')} disabled={index===0} onClick={()=>move(-1)}><IconChevronLeft size={26}/></button>
|
||||
<button className="file-media-next" aria-label={t('下一个文件')} disabled={index===entries.length-1} onClick={()=>move(1)}><IconChevronRight size={26}/></button>
|
||||
<footer className="file-media-footer">{t('滚轮缩放 · 拖动移动 · ← → 切换 · Esc 关闭')}</footer>
|
||||
</div>,document.body);
|
||||
}
|
||||
Reference in new issue
Block a user