feat: 작업실 앱에 프로젝트별 파일 브라우저 + K2 CAD 편집기 연동

- routes-workshop.ts: 파일 목록/업로드/폴더생성/삭제 API 추가
  (case-storage.ts 재사용, appType='workshop', caseId=projectId)
- workshop-app.html: 파일 탭 추가 — 드래그앤드롭 업로드, 폴더 그룹핑,
  사진 썸네일, STL 클릭 시 Three.js CAD 편집기 팝업으로 연동
- cad-editor-view.js: ?path= 쿼리로 특정 STL 자동 로드 지원
- server.ts: registerWorkshopRoutes에 isPathInsideDir 전달
- stl-cad-core.ts: 평면뷰 렌더 카메라가 원점이 아닌 바운딩박스
  중심을 look-at하도록 수정 — 원점에서 떨어진 모델 위쪽 잘림 해결

Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
kim
2026-09-22 13:23:05 +09:00
co-authored by Claude Code
parent bdf87d824e
commit 80ea4322bc
5 changed files with 387 additions and 2 deletions
+156
View File
@@ -4,14 +4,38 @@
* routes-robot.ts/robot-app.html이었던 것을 여러 프로젝트를 오갈 수 있게 일반화했다,
* 2026-09-20). 저장 로직은 workshop-storage.ts에 있음 — 전용 채팅도구
* (src/tools/workshop-project.ts)와 공유한다.
*
* 09-22: 프로젝트별 첨부파일(사진/CAD/STL 등) 폴더 기능 추가 — 저장소 레이아웃/업로드
* 파싱은 doctor·lawyer 앱이 쓰는 case-storage.ts를 그대로 재사용(appType='workshop',
* caseId=projectId). 파일은 <workspace>/workshop/<projectId>/... 에 저장되고 기존
* 범용 /api/files/{*filePath} 라우트로 서빙된다.
*/
import express from 'express';
import fs from 'fs';
import path from 'path';
import { getUserWorkspace } from '../../config/config';
import { loadWorkshop, saveWorkshop } from './workshop-storage';
import { caseFilesDir, resolveUploadPath, sanitizePathSegment, walkCaseFiles, fileCategory } from './case-storage';
const APP_TYPE = 'workshop';
// walkCaseFiles만으로는 파일이 하나도 없는 빈 폴더가 안 보이므로, 디렉토리 구조를 따로 순회.
function listCaseFolders(dir: string, relBase = ''): string[] {
if (!fs.existsSync(dir)) return [];
const out: string[] = [];
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
if (!entry.isDirectory()) continue;
const rel = relBase ? `${relBase}/${entry.name}` : entry.name;
out.push(rel);
out.push(...listCaseFolders(path.join(dir, entry.name), rel));
}
return out;
}
export function registerWorkshopRoutes(
app: express.Application,
getSessionUser: (req: express.Request) => any,
isPathInsideDir: (base: string, target: string) => boolean,
): void {
app.get('/api/workshop/data', (req, res) => {
const session = getSessionUser(req);
@@ -29,4 +53,136 @@ export function registerWorkshopRoutes(
saveWorkshop(getUserWorkspace(session.username), { projects, activeProjectId: String(activeProjectId || '') });
res.json({ success: true });
});
app.get('/api/workshop/files', (req, res) => {
const session = getSessionUser(req);
if (!session) return res.status(401).json({ error: 'Unauthorized' });
const projectId = String(req.query.projectId || '');
if (!/^[a-zA-Z0-9_-]+$/.test(projectId)) return res.status(400).json({ error: 'invalid projectId' });
const dir = caseFilesDir(session.username, APP_TYPE, projectId);
const files = walkCaseFiles(dir).map(e => {
const segs = e.relPath.split('/');
return {
name: e.name,
relPath: e.relPath,
folder: segs.length > 1 ? segs.slice(0, -1).join('/') : '',
size: e.size,
mtime: e.mtime,
url: `/api/files/${APP_TYPE}/${projectId}/${segs.map(encodeURIComponent).join('/')}`,
category: fileCategory(e.name),
};
});
res.json({ files, folders: listCaseFolders(dir) });
});
app.post('/api/workshop/files/folder', (req, res) => {
const session = getSessionUser(req);
if (!session) return res.status(401).json({ error: 'Unauthorized' });
const projectId = String(req.body?.projectId || '');
const folder = String(req.body?.folder || '');
const segments = folder.split('/').filter(Boolean).map(sanitizePathSegment);
if (!/^[a-zA-Z0-9_-]+$/.test(projectId) || !segments.length) {
return res.status(400).json({ error: 'invalid' });
}
const dir = caseFilesDir(session.username, APP_TYPE, projectId);
const targetPath = path.join(dir, ...segments);
if (!isPathInsideDir(dir, targetPath)) return res.status(403).json({ error: 'Forbidden' });
fs.mkdirSync(targetPath, { recursive: true });
res.json({ success: true, folder: segments.join('/') });
});
app.post('/api/workshop/upload', (req, res) => {
const session = getSessionUser(req);
if (!session) { res.status(401).json({ success: false, error: 'Unauthorized' }); return; }
const projectId = String(req.query.projectId || '');
if (!/^[a-zA-Z0-9_-]+$/.test(projectId)) { res.status(400).json({ success: false, error: 'invalid projectId' }); return; }
const folder = String(req.query.folder || '').split('/').filter(Boolean).map(sanitizePathSegment).join('/');
const contentType = String(req.headers['content-type'] || '');
if (!contentType.includes('multipart/form-data')) {
res.status(400).json({ success: false, error: 'Content-Type must be multipart/form-data' }); return;
}
const boundary = contentType.split('boundary=')[1];
if (!boundary) { res.status(400).json({ success: false, error: 'Missing boundary' }); return; }
const chunks: Buffer[] = [];
req.on('data', (chunk: Buffer) => chunks.push(chunk));
req.on('end', () => {
const raw = Buffer.concat(chunks).toString('binary');
const boundaryDelim = '--' + boundary;
let filename = 'upload.bin';
let filetype = 'application/octet-stream';
let fileData: Buffer | null = null;
const parts = raw.split(boundaryDelim);
for (const part of parts) {
if (!part || part.trim() === '--' || part.trim() === '') continue;
const headerEnd = part.indexOf('\r\n\r\n');
if (headerEnd === -1) continue;
const header = part.substring(0, headerEnd);
if (!header.includes('name="file"')) continue;
const fnMatch = header.match(/filename\*=UTF-8''([^\r\n]+)/i)
?? header.match(/filename="([^"]+)"/);
if (fnMatch) {
const raw8 = decodeURIComponent(fnMatch[1]) === fnMatch[1]
? Buffer.from(fnMatch[1], 'binary').toString('utf-8')
: decodeURIComponent(fnMatch[1]);
filename = raw8;
}
const ctMatch = header.match(/Content-Type:\s*([^\r\n]+)/i);
if (ctMatch) filetype = ctMatch[1].trim();
const bodyStart = headerEnd + 4;
const bodyEnd = part.lastIndexOf('\r\n');
if (bodyEnd <= bodyStart) continue;
fileData = Buffer.from(part.substring(bodyStart, bodyEnd), 'binary');
break;
}
if (!fileData) { res.status(400).json({ success: false, error: 'No file found in upload' }); return; }
if (fileData.length > 100 * 1024 * 1024) { res.status(400).json({ success: false, error: 'File too large (max 100MB)' }); return; }
const dir = caseFilesDir(session.username, APP_TYPE, projectId);
fs.mkdirSync(dir, { recursive: true });
const targetName = folder ? `${folder}/${filename}` : filename;
const { relPath, absPath } = resolveUploadPath(dir, targetName);
fs.writeFileSync(absPath, fileData);
res.json({
success: true,
name: path.basename(relPath),
relPath,
size: fileData.length,
type: filetype,
url: `/api/files/${APP_TYPE}/${projectId}/${relPath.split('/').map(encodeURIComponent).join('/')}`,
category: fileCategory(relPath),
});
});
req.on('error', (err: any) => { res.status(500).json({ success: false, error: String(err?.message || err) }); });
});
app.delete('/api/workshop/files', (req, res) => {
const session = getSessionUser(req);
if (!session) return res.status(401).json({ error: 'Unauthorized' });
const projectId = String(req.body?.projectId || '');
const relPath = String(req.body?.relPath || '');
const segments = relPath.split('/').filter(Boolean);
if (!/^[a-zA-Z0-9_-]+$/.test(projectId) || !segments.length || segments.some(s => s === '..' || s === '.')) {
return res.status(400).json({ error: 'invalid' });
}
const dir = caseFilesDir(session.username, APP_TYPE, projectId);
const targetPath = path.join(dir, ...segments);
if (!isPathInsideDir(dir, targetPath)) return res.status(403).json({ error: 'Forbidden' });
if (!fs.existsSync(targetPath)) { res.json({ success: true, alreadyGone: true }); return; }
try {
const stat = fs.statSync(targetPath);
if (stat.isDirectory()) fs.rmSync(targetPath, { recursive: true, force: true });
else fs.unlinkSync(targetPath);
} catch (e: any) {
res.status(500).json({ success: false, error: String(e?.message || e) }); return;
}
res.json({ success: true });
});
}
+1 -1
View File
@@ -4613,7 +4613,7 @@ registerLawyerCaseRoutes(app, getSessionUser, isPathInsideDir);
registerWopiRoutes(app, '/api/lawyer');
registerDoctorCaseRoutes(app, getSessionUser, isPathInsideDir);
registerWopiRoutes(app, '/api/doctor');
registerWorkshopRoutes(app, getSessionUser);
registerWorkshopRoutes(app, getSessionUser, isPathInsideDir);
registerCadRoutes(app);
+8 -1
View File
@@ -70,6 +70,13 @@ export async function renderStlPng(stlPath: string, view: string, outPngPath: st
try { bbox = readStlBBox(stlPath); } catch (e: any) { return { ok: false, detail: String(e?.message || e) }; }
const maxExtent = Math.max(bbox.max[0] - bbox.min[0], bbox.max[1] - bbox.min[1], bbox.max[2] - bbox.min[2]);
const dist = Math.max(50, maxExtent * 3);
// 카메라 시선 중심(look-at) — 원점이 아니라 실제 바운딩박스 중심으로 잡아야 원점에서
// 떨어진 모델(예: 바닥이 Z=0이고 위로 솟은 조립체)도 위쪽이 잘리지 않는다.
const center = [
(bbox.min[0] + bbox.max[0]) / 2,
(bbox.min[1] + bbox.max[1]) / 2,
(bbox.min[2] + bbox.max[2]) / 2,
];
const scadPath = outPngPath.replace(/\.png$/i, '.scad');
fs.writeFileSync(scadPath, `import(${JSON.stringify(stlPath)});\n`);
@@ -80,7 +87,7 @@ export async function renderStlPng(stlPath: string, view: string, outPngPath: st
} else {
const rot = VIEW_ROTATIONS[view];
if (!rot) { try { fs.unlinkSync(scadPath); } catch { /* noop */ } return { ok: false, detail: `알 수 없는 view: ${view}` }; }
scadArgs.push(`--camera=0,0,0,${rot[0]},${rot[1]},${rot[2]},${dist}`, '--projection=ortho');
scadArgs.push(`--camera=${center[0]},${center[1]},${center[2]},${rot[0]},${rot[1]},${rot[2]},${dist}`, '--projection=ortho');
}
scadArgs.push(scadPath);
+216
View File
@@ -157,6 +157,33 @@ body{background:var(--bg);color:var(--text);font-family:system-ui,sans-serif;dis
/* ── 메모 ── */
.rb-notes{width:100%;height:calc(100dvh - 220px);box-sizing:border-box;background:var(--panel);border:1px solid var(--line);border-radius:8px;padding:12px 14px;font-size:12px;color:var(--text);font-family:inherit;resize:none;outline:none;line-height:1.7;}
.rb-notes:focus{border-color:#f59e0b;}
/* ── 파일 ── */
.rb-files-toolbar{display:flex;gap:8px;align-items:center;margin-bottom:10px;flex-wrap:wrap;}
.rb-files-toolbar select{background:var(--panel-2);border:1px solid var(--line);border-radius:6px;padding:5px 8px;font-size:12px;color:var(--text);font-family:inherit;outline:none;}
.rb-upload-btn{background:rgba(245,158,11,.1);border:1px solid rgba(245,158,11,.3);border-radius:6px;padding:5px 12px;font-size:11px;color:#f59e0b;cursor:pointer;font-weight:700;display:inline-flex;align-items:center;gap:4px;}
.rb-upload-btn:hover{background:rgba(245,158,11,.25);}
.rb-files-dropzone{border:2px dashed var(--line);border-radius:10px;padding:18px;text-align:center;font-size:12px;color:var(--muted);margin-bottom:14px;transition:.15s;}
.rb-files-dropzone.dragover{border-color:#f59e0b;color:#f59e0b;background:rgba(245,158,11,.05);}
.rb-files-empty{font-size:12px;color:var(--muted);padding:20px 0;text-align:center;}
.rb-files-group{margin-bottom:18px;}
.rb-files-group-hdr{display:flex;align-items:center;gap:8px;margin-bottom:8px;}
.rb-files-group-name{font-size:12px;font-weight:700;color:var(--text);}
.rb-files-group-count{font-size:10px;color:var(--muted);}
.rb-files-group-del{margin-left:auto;background:none;border:none;cursor:pointer;font-size:11px;color:var(--muted);}
.rb-files-group-del:hover{color:#ef4444;}
.rb-files-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(120px,1fr));gap:10px;}
.rb-file-card{border:1px solid var(--line);border-radius:8px;overflow:hidden;background:var(--panel);position:relative;}
.rb-file-thumb{width:100%;aspect-ratio:1/1;background:var(--panel-2);display:flex;align-items:center;justify-content:center;overflow:hidden;}
.rb-file-thumb img{width:100%;height:100%;object-fit:cover;}
.rb-file-thumb .rb-file-icon{font-size:28px;opacity:.6;}
.rb-file-info{padding:6px 8px;}
.rb-file-name{font-size:11px;color:var(--text);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;}
.rb-file-size{font-size:10px;color:var(--muted);}
.rb-file-del{position:absolute;top:4px;right:4px;background:rgba(0,0,0,.55);border:none;border-radius:5px;color:#fff;font-size:10px;padding:2px 5px;cursor:pointer;opacity:0;transition:.15s;}
.rb-file-card:hover .rb-file-del{opacity:1;}
.rb-file-del:hover{background:#ef4444;}
.rb-file-card a{display:block;color:inherit;text-decoration:none;}
</style>
</head>
<body>
@@ -185,6 +212,7 @@ body{background:var(--bg);color:var(--text);font-family:system-ui,sans-serif;dis
<button class="rb-tab-btn active" data-tab="parts" onclick="switchTab('parts')">부품</button>
<button class="rb-tab-btn" data-tab="tasks" onclick="switchTab('tasks')">작업</button>
<button class="rb-tab-btn" data-tab="equip" onclick="switchTab('equip')">장비</button>
<button class="rb-tab-btn" data-tab="files" onclick="switchTab('files')">파일</button>
<button class="rb-tab-btn" data-tab="notes" onclick="switchTab('notes')">메모</button>
</div>
@@ -222,6 +250,16 @@ body{background:var(--bg);color:var(--text);font-family:system-ui,sans-serif;dis
</div>
</div>
<div class="rb-pane" id="pane-files">
<div class="rb-files-toolbar">
<select id="upload-folder-select"></select>
<button class="hdr-btn" onclick="promptNewFolder()">+ 새 폴더</button>
<label class="rb-upload-btn">📎 파일 업로드<input type="file" id="file-input" multiple style="display:none" onchange="handleFileInputChange(event)"></label>
</div>
<div class="rb-files-dropzone" id="files-dropzone">여기로 파일을 끌어다 놓거나 위 버튼으로 업로드 (사진/CAD/STL 등, 최대 100MB)</div>
<div id="files-list-container"></div>
</div>
<div class="rb-pane" id="pane-notes">
<textarea class="rb-notes" id="notes-textarea" placeholder="자유 메모..." oninput="onNotesChange()"></textarea>
</div>
@@ -320,6 +358,7 @@ function switchProject(id){
loadLocalFromProject();
refreshAllPanes();
scheduleSave();
if(document.getElementById('pane-files').classList.contains('active'))loadFiles();
}
function createProjectPrompt(){
@@ -390,6 +429,7 @@ function switchTab(tab){
document.querySelectorAll('.rb-pane').forEach(p=>p.classList.remove('active'));
document.getElementById('pane-'+tab).classList.add('active');
if(tab==='equip')startEquipTimers();else stopEquipTimers();
if(tab==='files')loadFiles();
}
// ── 장비 위젯(K2/밀링) ────────────────────────────────────────────────────────
@@ -731,6 +771,182 @@ function deleteTask(phaseId,taskId){
scheduleSave();
}
// ── 파일 ──────────────────────────────────────────────────────────────────────
// 부품/작업/메모와 달리 workshopData(JSON)에 안 담고, 서버 디스크에 실제 파일로
// 저장된다(case-storage.ts, doctor/lawyer 앱과 동일 패턴) — /api/workshop/files 로
// 목록을 받아 그때그때 다시 그린다.
const FILE_ICONS={photo:'🖼️',video:'🎬',document:'📄',other:'📦'};
let filesState={files:[],folders:[]};
async function loadFiles(){
const proj=currentProject();
if(!proj)return;
const container=document.getElementById('files-list-container');
try{
const r=await fetch('/api/workshop/files?projectId='+encodeURIComponent(proj.id),{headers:authH()});
if(!r.ok)throw new Error('불러오기 실패');
const d=await r.json();
filesState.files=Array.isArray(d.files)?d.files:[];
filesState.folders=Array.isArray(d.folders)?d.folders:[];
}catch{
container.innerHTML='<div class="rb-files-empty">파일 목록을 불러오지 못했습니다.</div>';
return;
}
renderFiles();
}
function renderFolderSelect(){
const sel=document.getElementById('upload-folder-select');
const prev=sel.value;
sel.innerHTML='<option value="">(폴더 없음)</option>'+filesState.folders.map(f=>`<option value="${escAttr(f)}">${escAttr(f)}</option>`).join('');
if([...sel.options].some(o=>o.value===prev))sel.value=prev;
}
function renderFiles(){
renderFolderSelect();
const container=document.getElementById('files-list-container');
if(!filesState.files.length&&!filesState.folders.length){
container.innerHTML='<div class="rb-files-empty">아직 첨부된 파일이 없습니다. 사진/CAD/STL 등을 업로드해 보세요.</div>';
return;
}
// 폴더별로 묶기 — 최상위(폴더 없음) 파일은 "미분류"로.
const groups=new Map();
for(const f of filesState.folders)groups.set(f,[]);
for(const file of filesState.files){
const key=file.folder||'';
if(!groups.has(key))groups.set(key,[]);
groups.get(key).push(file);
}
const keys=[...groups.keys()].sort((a,b)=>{
if(a==='')return 1; if(b==='')return -1;
return a.localeCompare(b);
});
container.innerHTML=keys.map(key=>{
const files=groups.get(key)||[];
const label=key||'미분류';
const delBtn=key?`<button class="rb-files-group-del" onclick="deleteFolder('${escAttr(key)}')">🗑 폴더 삭제</button>`:'';
const grid=files.length?`<div class="rb-files-grid">${files.map(renderFileCard).join('')}</div>`
:'<div class="rb-files-empty" style="padding:8px 0">(빈 폴더)</div>';
return `<div class="rb-files-group">
<div class="rb-files-group-hdr">
<span class="rb-files-group-name">📁 ${escAttr(label)}</span>
<span class="rb-files-group-count">${files.length}개</span>
${delBtn}
</div>
${grid}
</div>`;
}).join('');
}
const STL_RE=/\.stl$/i;
function renderFileCard(file){
const isStl=STL_RE.test(file.name);
const icon=isStl?'🔧':(FILE_ICONS[file.category]||FILE_ICONS.other);
const thumb=file.category==='photo'
? `<img src="${escAttr(file.url)}" alt="${escAttr(file.name)}" loading="lazy">`
: `<span class="rb-file-icon">${icon}</span>`;
// STL은 새탭 다운로드 대신 Three.js CAD 편집기 팝업으로 연다(작업실↔K2 CAD 플러그인 연동).
const openAttrs=isStl?`href="javascript:void(0)" onclick="openCadEditorForFile('${escAttr(file.relPath)}')"`:`href="${escAttr(file.url)}" target="_blank" rel="noopener"`;
return `<div class="rb-file-card" data-rel="${escAttr(file.relPath)}">
<button class="rb-file-del" onclick="deleteFile('${escAttr(file.relPath)}')" title="삭제">✕</button>
<a ${openAttrs}>
<div class="rb-file-thumb">${thumb}</div>
<div class="rb-file-info">
<div class="rb-file-name" title="${escAttr(file.name)}">${escAttr(file.name)}</div>
<div class="rb-file-size">${fmtFileSize(file.size)}${isStl?' · CAD 편집기에서 열기':''}</div>
</div>
</a>
</div>`;
}
// K2 프린터 앱의 "🔧 STL 편집기"(Three.js, cad-editor-app.html)를 그대로 재사용 — STL 목록은
// 워크스페이스 전체를 훑으므로(findStlFiles) 작업실 파일도 이미 그 목록에 잡힌다. ?path= 로
// 어느 파일을 열지 지정하면 편집기가 자동으로 로드한다(cad-editor-view.js 09-22 추가).
function openCadEditorForFile(relPath){
const proj=currentProject();
if(!proj)return;
const workspacePath='workshop/'+proj.id+'/'+relPath;
const w=1320,h=860;
const left=Math.max(0,(screen.width-w)/2), top=Math.max(0,(screen.height-h)/2);
window.open('/html/cad-editor-app.html?path='+encodeURIComponent(workspacePath),'cad-editor',`width=${w},height=${h},left=${left},top=${top},menubar=no,toolbar=no,location=no,status=no,resizable=yes`);
}
function fmtFileSize(n){
if(!n&&n!==0)return '';
if(n<1024)return n+'B';
if(n<1024*1024)return (n/1024).toFixed(1)+'KB';
return (n/1024/1024).toFixed(1)+'MB';
}
async function promptNewFolder(){
const name=(prompt('새 폴더 이름을 입력하세요 (예: 사진, CAD, STL):')||'').trim();
if(!name)return;
const proj=currentProject();
if(!proj)return;
try{
const r=await fetch('/api/workshop/files/folder',{method:'POST',headers:authH({'Content-Type':'application/json'}),body:JSON.stringify({projectId:proj.id,folder:name})});
if(!r.ok)throw new Error();
await loadFiles();
}catch{alert('폴더 생성 실패');}
}
async function deleteFolder(folder){
if(!confirm(`"${folder}" 폴더와 그 안의 파일이 모두 삭제됩니다. 계속할까요?`))return;
const proj=currentProject();
if(!proj)return;
try{
await fetch('/api/workshop/files',{method:'DELETE',headers:authH({'Content-Type':'application/json'}),body:JSON.stringify({projectId:proj.id,relPath:folder})});
}catch{}
await loadFiles();
}
async function deleteFile(relPath){
if(!confirm('이 파일을 삭제할까요?'))return;
const proj=currentProject();
if(!proj)return;
try{
await fetch('/api/workshop/files',{method:'DELETE',headers:authH({'Content-Type':'application/json'}),body:JSON.stringify({projectId:proj.id,relPath})});
}catch{}
await loadFiles();
}
async function uploadFiles(fileList){
const proj=currentProject();
if(!proj||!fileList||!fileList.length)return;
const folder=document.getElementById('upload-folder-select').value;
const zone=document.getElementById('files-dropzone');
const origText=zone.textContent;
for(const file of fileList){
zone.textContent=`업로드 중… ${file.name}`;
const fd=new FormData();
fd.append('file',file);
try{
const qs='projectId='+encodeURIComponent(proj.id)+(folder?'&folder='+encodeURIComponent(folder):'');
const r=await fetch('/api/workshop/upload?'+qs,{method:'POST',headers:authH(),body:fd});
if(!r.ok){const d=await r.json().catch(()=>({}));throw new Error(d.error||'업로드 실패');}
}catch(e){
alert(`"${file.name}" 업로드 실패: ${e.message}`);
}
}
zone.textContent=origText;
await loadFiles();
}
function handleFileInputChange(ev){
uploadFiles(ev.target.files);
ev.target.value='';
}
(function initDropzone(){
const zone=document.getElementById('files-dropzone');
['dragenter','dragover'].forEach(evt=>zone.addEventListener(evt,e=>{e.preventDefault();zone.classList.add('dragover');}));
['dragleave','drop'].forEach(evt=>zone.addEventListener(evt,e=>{e.preventDefault();zone.classList.remove('dragover');}));
zone.addEventListener('drop',e=>{
const files=e.dataTransfer?.files;
if(files&&files.length)uploadFiles(files);
});
})();
// ── 메모 ──────────────────────────────────────────────────────────────────────
function onNotesChange(){
notes=document.getElementById('notes-textarea').value;
+6
View File
@@ -612,6 +612,12 @@
const ro = new ResizeObserver(() => resize());
ro.observe(vpEl);
requestAnimationFrame(() => { resize(); requestAnimationFrame(resize); });
// 다른 앱(작업실 파일 탭 등)에서 특정 STL을 바로 열도록 ?path= 로 넘겨줄 수 있다.
const preselect = new URLSearchParams(location.search).get('path');
if (preselect && files.includes(preselect)) {
panelEl.querySelector('#ce-file').value = preselect;
loadSelected();
}
}
if (document.readyState === 'loading') {