feat: 浮动编辑器 — 拖拽+缩放+全屏+文件树

功能:
- 拖拽: 按住标题栏拖动编辑器面板
- 缩放: 右下角拖拽调整大小
- 最大化(): 占满屏幕留边距
- 全屏(⤢): 完全覆盖页面
- 最小化(—): 收到底部标签栏
- 关闭(✕): 关闭全部标签
- 文件树(🌳): 左侧可折叠文件树, 点击文件打开/目录导航
- 文件树同步: 浏览新目录时自动更新

布局:
┌──────────────────────────────────┐
│ [🌳] [📄a.conf×][📄b.py×]  [][⤢][—][✕] │ ← 可拖拽
├────────┬─────────────────────────┤
│ 📂 ..  │                         │
│ 📁 dir │    Monaco Editor        │
│ 📄 a   │                         │
│ 📄 b   │                         │
│        │ Ln1,Col1|ini|UTF-8|file │
└────────┴─────────────────────────┘  ← 右下角可缩放
This commit is contained in:
Your Name
2026-05-31 02:26:25 +08:00
parent caef4a216b
commit cba8b3d8f6
+135 -22
View File
@@ -59,21 +59,34 @@
</main>
</div>
<!-- Floating Editor Panel -->
<div id="editorPanel" class="hidden fixed z-40 bg-slate-900 border border-slate-700 rounded-xl shadow-2xl flex flex-col" style="right:24px;bottom:60px;width:70vw;height:65vh;min-width:500px;min-height:300px">
<!-- Header: tabs + controls -->
<div class="flex items-center border-b border-slate-800 shrink-0">
<div id="editorTabBar" class="flex flex-1 overflow-x-auto"></div>
<div class="flex items-center gap-1 px-2 shrink-0">
<button onclick="toggleEditorSize()" class="text-slate-500 hover:text-white px-1.5 py-1 text-xs rounded hover:bg-slate-700" title="最大化/还原"></button>
<button onclick="minimizeEditor()" class="text-slate-500 hover:text-white px-1.5 py-1 text-xs rounded hover:bg-slate-700" title="最小化"></button>
<button onclick="closeAllTabs()" class="text-slate-500 hover:text-red-400 px-1.5 py-1 text-xs rounded hover:bg-red-900/30" title="关闭全部"></button>
<!-- Floating Editor Panel (draggable + resizable + file tree) -->
<div id="editorPanel" class="hidden fixed z-40 flex flex-col" style="right:24px;bottom:60px;width:75vw;height:65vh;min-width:500px;min-height:300px;border-radius:12px;overflow:hidden;box-shadow:0 25px 50px -12px rgba(0,0,0,0.5);border:1px solid rgb(51 65 85);background:rgb(15 23 42)">
<!-- Drag handle + tabs + buttons -->
<div id="editorHeader" class="flex items-center bg-slate-900 border-b border-slate-800 shrink-0 cursor-move select-none">
<!-- File tree toggle -->
<button onclick="toggleEditorTree()" id="treeToggleBtn" class="text-slate-500 hover:text-white px-2.5 py-2 text-sm hover:bg-slate-700 shrink-0" title="文件树">🌳</button>
<!-- Tabs -->
<div id="editorTabBar" class="flex flex-1 overflow-x-auto min-w-0"></div>
<!-- Right buttons -->
<div class="flex items-center shrink-0">
<button onclick="toggleEditorSize()" id="sizeBtn" class="text-slate-500 hover:text-white px-2 py-2 text-sm hover:bg-slate-700" title="最大化/还原"></button>
<button onclick="toggleEditorFullscreen()" id="fsBtn" class="text-slate-500 hover:text-white px-2 py-2 text-sm hover:bg-slate-700" title="全屏"></button>
<button onclick="minimizeEditor()" class="text-slate-500 hover:text-white px-2 py-2 text-sm hover:bg-slate-700" title="最小化"></button>
<button onclick="closeAllTabs()" class="text-slate-500 hover:text-red-400 px-2 py-2 text-sm hover:bg-red-900/30" title="关闭全部"></button>
</div>
</div>
<!-- Monaco container -->
<div id="editorMonacoWrap" class="flex-1 relative min-h-0"></div>
<!-- Body: file tree + editor -->
<div class="flex flex-1 min-h-0">
<!-- File tree sidebar -->
<div id="editorTree" class="w-52 bg-slate-900 border-r border-slate-800 flex flex-col shrink-0 overflow-hidden">
<div class="px-3 py-2 border-b border-slate-800 text-xs text-slate-500 font-medium">📁 文件列表</div>
<div id="editorTreeList" class="flex-1 overflow-y-auto text-sm"></div>
</div>
<!-- Monaco container -->
<div id="editorMonacoWrap" class="flex-1 relative min-w-0"></div>
</div>
<!-- Status bar -->
<div class="flex items-center gap-4 px-4 py-1.5 bg-slate-800 border-t border-slate-700 text-xs text-slate-500 shrink-0 rounded-b-xl">
<div class="flex items-center gap-4 px-4 py-1.5 bg-slate-800 border-t border-slate-700 text-xs text-slate-500 shrink-0">
<span id="editorPos">Ln 1, Col 1</span>
<span id="editorLang">plaintext</span>
<span>UTF-8</span>
@@ -81,6 +94,8 @@
<div class="flex-1"></div>
<span id="editorFileName" class="text-slate-400 truncate max-w-xs"></span>
</div>
<!-- Resize handle (bottom-right corner) -->
<div id="editorResizeHandle" class="absolute bottom-0 right-0 w-4 h-4 cursor-nwse-resize z-10" style="background:linear-gradient(135deg,transparent 50%,rgb(100 116 139) 50%)"></div>
</div>
<!-- Minimized editor bar -->
@@ -387,7 +402,8 @@
updateBreadcrumb(_currentPath);
_allEntries=d.entries||[];
renderFileList(_allEntries);
preloadFiles(_allEntries); // Background preload small text files
preloadFiles(_allEntries);
if(_editorVisible)renderFileTree(); // Background preload small text files
}catch(e){
fileListEl.innerHTML=`<div class="px-4 py-12 text-center text-red-400">连接失败: ${esc(e.message)}</div>`;
toast('error','连接失败');
@@ -720,17 +736,25 @@
}
let _editorMaximized=false;
let _editorFullscreen=false;
let _editorTreeVisible=true;
const _defaultStyle='right:24px;bottom:60px;width:75vw;height:65vh;min-width:500px;min-height:300px';
const _maxStyle='left:16px;right:16px;top:16px;bottom:60px;width:auto;height:auto;min-width:0;min-height:0';
const _fsStyle='left:0;right:0;top:0;bottom:0;width:100vw;height:100vh;min-width:0;min-height:0;border-radius:0';
function showEditorPanel(){
_editorVisible=true;_editorMinimized=false;
document.getElementById('editorPanel').classList.remove('hidden');
const p=document.getElementById('editorPanel');
p.classList.remove('hidden');
document.getElementById('editorMinBar').classList.add('hidden');
if(!_editorFullscreen&&!_editorMaximized)p.style.cssText=_defaultStyle;
renderFileTree();
setTimeout(()=>{if(_activeTab>=0&&_openTabs[_activeTab].editor)_openTabs[_activeTab].editor.layout()},50);
}
function hideEditorPanel(){
_editorVisible=false;_editorMaximized=false;
_editorVisible=false;_editorMaximized=false;_editorFullscreen=false;
const p=document.getElementById('editorPanel');
p.classList.add('hidden');
p.style.cssText=''; // Reset inline styles
p.classList.add('hidden');p.style.cssText='';
document.getElementById('editorMinBar').classList.add('hidden');
renderMinBar();
}
@@ -744,18 +768,107 @@
_editorMinimized=false;
document.getElementById('editorPanel').classList.remove('hidden');
document.getElementById('editorMinBar').classList.add('hidden');
if(!_editorFullscreen&&!_editorMaximized)document.getElementById('editorPanel').style.cssText=_defaultStyle;
renderFileTree();
if(_activeTab>=0&&_openTabs[_activeTab].editor)setTimeout(()=>_openTabs[_activeTab].editor.layout(),50);
}
function toggleEditorSize(){
const p=document.getElementById('editorPanel');
_editorFullscreen=false;
_editorMaximized=!_editorMaximized;
if(_editorMaximized){
p.style.cssText='left:16px;right:16px;top:16px;bottom:60px;width:auto;height:auto;min-width:0;min-height:0';
}else{
p.style.cssText='right:24px;bottom:60px;width:70vw;height:65vh;min-width:500px;min-height:300px';
}
p.style.cssText=_editorMaximized?_maxStyle:_defaultStyle;
p.style.borderRadius=_editorMaximized?'12px':'12px';
if(_activeTab>=0&&_openTabs[_activeTab].editor)setTimeout(()=>_openTabs[_activeTab].editor.layout(),50);
}
function toggleEditorFullscreen(){
const p=document.getElementById('editorPanel');
_editorMaximized=false;
_editorFullscreen=!_editorFullscreen;
p.style.cssText=_editorFullscreen?_fsStyle:_defaultStyle;
p.style.borderRadius=_editorFullscreen?'0':'12px';
if(_activeTab>=0&&_openTabs[_activeTab].editor)setTimeout(()=>_openTabs[_activeTab].editor.layout(),50);
}
function toggleEditorTree(){
_editorTreeVisible=!_editorTreeVisible;
document.getElementById('editorTree').classList.toggle('hidden',!_editorTreeVisible);
if(_activeTab>=0&&_openTabs[_activeTab].editor)setTimeout(()=>_openTabs[_activeTab].editor.layout(),50);
}
function renderFileTree(){
const list=document.getElementById('editorTreeList');
if(!list)return;
let html=`<div class="px-3 py-1.5 text-xs text-slate-600 cursor-pointer hover:bg-slate-800" onclick="browseDir('${esc(_currentPath.replace(/\/[^/]+\/?$/,'')||'/')}')">📂 ..</div>`;
const sorted=sortEntries(_allEntries);
sorted.forEach(e=>{
const fp=_currentPath.replace(/\/$/,'')+'/'+e.name;
if(e.is_dir){
html+=`<div class="px-3 py-1 text-xs text-slate-400 cursor-pointer hover:bg-slate-800 truncate" onclick="browseDir('${esc(fp)}')">📁 ${esc(e.name)}</div>`;
}else{
const fi=fileIcon(e.name,false,e.is_link);
const inTab=_openTabs.findIndex(t=>t.path===fp);
const active=inTab===_activeTab;
html+=`<div class="px-3 py-1 text-xs cursor-pointer hover:bg-slate-800 truncate ${active?'bg-slate-800 text-white':'text-slate-400'}" onclick="openFileFromTree('${esc(fp)}','${esc(e.name)}')">
${fi.icon} ${esc(e.name)}${inTab>=0?'<span class="text-brand-light ml-1">●</span>':''}
</div>`;
}
});
list.innerHTML=html;
}
async function openFileFromTree(path,name){
const cached=_fileCache[path];
if(cached){openFileInEditor(path,name,cached.content);return}
const r=await apiFetch(API+'/sync/read-file',{method:'POST',headers:apiHeadersJSON(),body:JSON.stringify({server_id:_currentServerId,path})});
if(r&&r.ok){const d=await r.json();_fileCache[path]={content:d.content,size:d.size};openFileInEditor(path,name,d.content)}
else{const e=r?await r.json().catch(()=>({})):{};toast('error','读取失败: '+(e.detail||''))}
}
// ── Drag ──
(function initDrag(){
const header=document.getElementById('editorHeader');
const panel=document.getElementById('editorPanel');
let dragging=false,startX,startY,startLeft,startTop;
header.addEventListener('mousedown',e=>{
if(e.target.closest('button')||e.target.closest('#editorTabBar'))return;
dragging=true;
const rect=panel.getBoundingClientRect();
startX=e.clientX;startY=e.clientY;
startLeft=rect.left;startTop=rect.top;
panel.style.right='auto';panel.style.bottom='auto';
panel.style.left=startLeft+'px';panel.style.top=startTop+'px';
document.body.style.userSelect='none';
});
document.addEventListener('mousemove',e=>{
if(!dragging)return;
panel.style.left=(startLeft+e.clientX-startX)+'px';
panel.style.top=(startTop+e.clientY-startY)+'px';
});
document.addEventListener('mouseup',()=>{dragging=false;document.body.style.userSelect=''});
})();
// ── Resize ──
(function initResize(){
const handle=document.getElementById('editorResizeHandle');
const panel=document.getElementById('editorPanel');
let resizing=false,startX,startY,startW,startH;
handle.addEventListener('mousedown',e=>{
e.stopPropagation();
resizing=true;
startX=e.clientX;startY=e.clientY;
startW=panel.offsetWidth;startH=panel.offsetHeight;
document.body.style.userSelect='none';
});
document.addEventListener('mousemove',e=>{
if(!resizing)return;
const newW=Math.max(500,startW+e.clientX-startX);
const newH=Math.max(300,startH+e.clientY-startY);
panel.style.width=newW+'px';panel.style.height=newH+'px';
});
document.addEventListener('mouseup',()=>{
if(resizing){
resizing=false;document.body.style.userSelect='';
if(_activeTab>=0&&_openTabs[_activeTab].editor)_openTabs[_activeTab].editor.layout();
}
});
})();
function renderTabBar(){
const bar=document.getElementById('editorTabBar');