diff --git a/web-ui/html/workshop-app.html b/web-ui/html/workshop-app.html index 90efb87..e924b6c 100644 --- a/web-ui/html/workshop-app.html +++ b/web-ui/html/workshop-app.html @@ -316,7 +316,7 @@ function escAttr(s){return String(s||'').replace(/'/g,''').replace(/"/g,'&qu // onclick="fn('...')" 안의 JS 문자열 컨텍스트용 — escAttr의 HTML 엔티티(')는 // HTML 파서가 속성값을 JS로 넘기기 전에 다시 ' 로 디코딩해버려 따옴표 주입을 못 막으므로 // JS 수준에서 이스케이프한다. 폴더/파일 이름(사용자 입력)을 싣는 데만 쓴다. -function escJs(s){return String(s??'').replace(/\\/g,'\\\\').replace(/'/g,"\\'").replace(/<\//g,'<\\/').replace(/\r?\n/g,'\\n');} +function escJs(s){return String(s??'').replace(/\\/g,'\\\\').replace(/'/g,"\\'").replace(/"/g,'"').replace(//g,'>').replace(/\r?\n/g,'\\n');} function genId(prefix){return prefix+'_'+Math.random().toString(36).slice(2,10);} // ── 상태 ────────────────────────────────────────────────────────────────────── @@ -395,6 +395,10 @@ function switchProject(id){ } function createProjectPrompt(){ + // 새 프로젝트로 넘어가기 전 지금 편집 중인 내용을 직전 프로젝트에 반영 — + // 안 그러면 loadLocalFromProject()가 지역변수를 새 빈 프로젝트로 덮어써서 + // 600ms debounce 안 끝난 편집이 유실된다(switchProject와 동일 순서). + syncLocalToProject(); const name=(prompt('새 프로젝트 이름을 입력하세요:')||'').trim(); if(!name)return; const id=genId('proj'); @@ -446,6 +450,19 @@ function scheduleSave(){ saveDebounce=setTimeout(saveProject,600); } +// 탭 닫기/새로고침 시 대기 중인 저장을 즉시 flush — 600ms debounce가 끝나기 전에 +// 닫아버리면 마지막 편집이 그대로 사라진다. unload 중엔 일반 fetch가 버려질 수 있어 +// keepalive:true로 브라우저에 전송을 보장받는다. beforeunload(닫기 직전)와 +// pagehide(실제 내비게이션 확정) 둘 다에 걸고, saveDebounce 널 처리로 이중 실행을 막는다. +function flushPendingSave(){ + if(!saveDebounce)return; + clearTimeout(saveDebounce);saveDebounce=null; + syncLocalToProject(); + fetch('/api/workshop/data',{method:'PUT',headers:authH({'Content-Type':'application/json'}),body:JSON.stringify({projects:workshopData.projects,activeProjectId:workshopData.activeProjectId}),keepalive:true}); +} +window.addEventListener('beforeunload',flushPendingSave); +window.addEventListener('pagehide',flushPendingSave); + // 채팅 응답 후 호출: 서버 데이터를 다시 당겨오되, 내용이 안 바뀌었으면 화면을 건드리지 // 않는다(편집 중 포커스/펼침 카드 보존). 바뀌었으면 살아있는 부품에 한해 펼침 상태를 // 유지한 채 다시 그린다. loadWorkshopData(초기 로딩용, 무조건 전체 새로고침)와 다른 용도. @@ -460,6 +477,7 @@ async function softRefreshWorkshop(){ const newProjects=Array.isArray(d.projects)?d.projects:[]; const newActive=d.activeProjectId||(newProjects[0]?.id||''); if(JSON.stringify(newProjects)===JSON.stringify(workshopData.projects)&&newActive===workshopData.activeProjectId)return; + const activeChanged=newActive!==workshopData.activeProjectId; workshopData.projects=newProjects; workshopData.activeProjectId=newActive; loadLocalFromProject(); @@ -473,6 +491,15 @@ async function softRefreshWorkshop(){ const ota=document.getElementById('overview-textarea'); if(ota&&ota.value!==overviewDesc)ota.value=overviewDesc; updateBudget(); + if(activeChanged){ + // 채팅에서 set_active_project/create_project로 프로젝트가 바뀐 경우 — + // switchProject와 같은 정리를 해줘야 한다. 안 그러면 옛 프로젝트에서 + // 선택해둔 STL이 그대로 남아 슬라이서에 잘못 전송되고, 채팅창도 옛 + // 프로젝트의 세션 기록을 보여준다. 매 응답마다가 아니라 실제 전환 시에만. + clearStlSelection(); + refreshChatForProject(); + if(document.getElementById('pane-files').classList.contains('active'))loadFiles(); + } }catch{} } @@ -671,7 +698,7 @@ function renderParts(){
`).join('')} @@ -1101,6 +1128,11 @@ async function loadHistory(){ function escHtml(s){return String(s??'').replace(/[&<>"']/g,c=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]));} +// 부품 참고링크 href용 — http(s)(프로토콜 상대 포함)만 통과시키고 나머지는 빈 값으로 +// 뭉갠다. javascript:/data: 스킴 링크는 채팅 도구(add_link)나 오타로도 들어올 수 있고 +// href에 그대로 쓰면 클릭 한 번에 스크립트가 돈다. +function safeUrl(u){u=String(u??'').trim();return /^(https?:|\/\/)/i.test(u)?u:'';} + function addMsg(role,html){ const msgs=document.getElementById('chat-msgs'); const div=document.createElement('div');div.className='rb-msg '+role; @@ -1142,7 +1174,9 @@ function renderMd(text){ } function chatGreeting(){ - return '🛠️ 작업실 채팅입니다. 현재 프로젝트: "'+(currentProject()?.name||'')+'". 부품 구매/상태 변경, 작업 완료 체크, 예산 확인, 새 프로젝트 생성 등을 물어보세요.'; + // 프로젝트 이름은 addMsg→innerHTML로 들어가므로 반드시 이스케이프 — 이름에 + //