fix: 작업실 앱 6종 버그 수정 — renderMd 코드/리스트, softRefresh 덮어쓰기, escJs 인젝션, 테마 아이콘

renderMd: 코드펜스 Placeholder 추출→연속 목록 ul 그룹화→단락 분할→블록태그 p
래핑 제외→Placeholder 복원 순으로 재작성. softRefreshWorkshop 추가(저장 대기
플러시 후 변경시에만 재렌더, expandedParts/notes 보존). escJs 추가해 삭제/토글
onclick 인젝션 방지. 테마 토글 아이콘(🌙/☀️) 동기화.

Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
kim
2026-09-22 19:28:54 +09:00
co-authored by Claude Code
parent 7587adb624
commit 54263ae0b4
+70 -17
View File
@@ -201,7 +201,7 @@ body{background:var(--bg);color:var(--text);font-family:system-ui,sans-serif;dis
<button class="hdr-btn" onclick="deleteProjectPrompt()">🗑 삭제</button>
<span class="rb-hdr-sp"></span>
<span class="rb-save-indicator" id="save-indicator"></span>
<button class="hdr-btn" onclick="toggleTheme()">🌙</button>
<button class="hdr-btn" id="theme-btn" onclick="toggleTheme()">🌙</button>
</div>
<div class="rb-budget">
@@ -305,9 +305,13 @@ async function checkAuth(){
}
// ── Theme ─────────────────────────────────────────────────────────────────────
function toggleTheme(){const d=document.documentElement;const n=d.getAttribute('data-theme')==='dark'?'light':'dark';d.setAttribute('data-theme',n);try{localStorage.setItem('homeclaw_theme',n);}catch{}}
function toggleTheme(){const d=document.documentElement;const n=d.getAttribute('data-theme')==='dark'?'light':'dark';d.setAttribute('data-theme',n);try{localStorage.setItem('homeclaw_theme',n);}catch{}const tb=document.getElementById('theme-btn');if(tb)tb.textContent=n==='light'?'☀️':'🌙';}
function escAttr(s){return String(s||'').replace(/'/g,'&#39;').replace(/"/g,'&quot;');}
// onclick="fn('...')" 안의 JS 문자열 컨텍스트용 — escAttr의 HTML 엔티티(&#39;)는
// HTML 파서가 속성값을 JS로 넘기기 전에 다시 ' 로 디코딩해버려 따옴표 주입을 못 막으므로
// JS 수준에서 이스케이프한다. 폴더/파일 이름(사용자 입력)을 싣는 데만 쓴다.
function escJs(s){return String(s??'').replace(/\\/g,'\\\\').replace(/'/g,"\\'").replace(/<\//g,'<\\/').replace(/\r?\n/g,'\\n');}
function genId(prefix){return prefix+'_'+Math.random().toString(36).slice(2,10);}
// ── 상태 ──────────────────────────────────────────────────────────────────────
@@ -413,6 +417,34 @@ function scheduleSave(){
saveDebounce=setTimeout(saveProject,600);
}
// 채팅 응답 후 호출: 서버 데이터를 다시 당겨오되, 내용이 안 바뀌었으면 화면을 건드리지
// 않는다(편집 중 포커스/펼침 카드 보존). 바뀌었으면 살아있는 부품에 한해 펼침 상태를
// 유지한 채 다시 그린다. loadWorkshopData(초기 로딩용, 무조건 전체 새로고침)와 다른 용도.
async function softRefreshWorkshop(){
// 대기 중인 저장(debounce)이 있으면 먼저 flush — 안 그러면 방금 편집한 내용이 아직
// 서버에 안 간 상태에서 서버 데이터를 당겨오면 그 편집이 옛 데이터에 덮어씌워 사라진다.
if(saveDebounce){clearTimeout(saveDebounce);saveDebounce=null;await saveProject();}
try{
const r=await fetch('/api/workshop/data',{headers:authH()});
if(!r.ok)return;
const d=await r.json();
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;
workshopData.projects=newProjects;
workshopData.activeProjectId=newActive;
loadLocalFromProject();
const liveIds=new Set(parts.map(p=>p.id));
for(const id of [...expandedParts])if(!liveIds.has(id))expandedParts.delete(id);
renderProjectSelector();
renderParts();
renderPhases();
const ta=document.getElementById('notes-textarea');
if(ta&&ta.value!==notes)ta.value=notes;
updateBudget();
}catch{}
}
async function saveProject(){
syncLocalToProject();
try{
@@ -834,7 +866,7 @@ function renderFiles(){
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 delBtn=key?`<button class="rb-files-group-del" onclick="deleteFolder('${escJs(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">
@@ -860,13 +892,13 @@ function renderFileCard(file){
? `<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"`;
const openAttrs=isStl?`href="javascript:void(0)" onclick="openCadEditorForFile('${escJs(file.relPath)}')"`:`href="${escAttr(file.url)}" target="_blank" rel="noopener"`;
// STL만 슬라이서로 보낼 다중선택 체크박스(09-22) — 여러 부품 STL을 한 판에 얹어 같이 슬라이싱.
const checked=stlSelection.has(file.relPath);
const selBox=isStl?`<input type="checkbox" class="rb-file-stl-sel" title="슬라이서로 보내기 선택" ${checked?'checked':''} onclick="event.stopPropagation()" onchange="toggleStlSelect('${escAttr(file.relPath)}',this.checked)">`:'';
const selBox=isStl?`<input type="checkbox" class="rb-file-stl-sel" title="슬라이서로 보내기 선택" ${checked?'checked':''} onclick="event.stopPropagation()" onchange="toggleStlSelect('${escJs(file.relPath)}',this.checked)">`:'';
return `<div class="rb-file-card${checked?' stl-selected':''}" data-rel="${escAttr(file.relPath)}">
${selBox}
<button class="rb-file-del" onclick="deleteFile('${escAttr(file.relPath)}')" title="삭제">✕</button>
<button class="rb-file-del" onclick="deleteFile('${escJs(file.relPath)}')" title="삭제">✕</button>
<a ${openAttrs}>
<div class="rb-file-thumb">${thumb}</div>
<div class="rb-file-info">
@@ -1040,16 +1072,34 @@ function addMsg(role,html){
}
function renderMd(text){
return text
.replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;')
.replace(/```[\w]*\n?([\s\S]*?)```/g,'<pre><code>$1</code></pre>')
.replace(/`([^`]+)`/g,'<code>$1</code>')
.replace(/\*\*(.+?)\*\*/g,'<strong>$1</strong>')
.replace(/^#{1,3}\s+(.+)$/gm,'<h3>$1</h3>')
.replace(/^[-*]\s+(.+)$/gm,'<li>$1</li>')
.replace(/(<li>[\s\S]+?<\/li>)/g,m=>`<ul style="padding-left:16px;margin:3px 0">${m}</ul>`)
.replace(/\n\n/g,'</p><p>').replace(/^/,'<p>').replace(/$/,'</p>')
.replace(/<p><\/p>/g,'').replace(/<p>(<[hup])/g,'$1').replace(/(<\/[hup][^>]*>)<\/p>/g,'$1');
// 코드펜스를 먼저 자리표시자로 빼두고 모든 인라인/단락 처리가 끝난 뒤 복원한다 —
// 그래야 예전처럼 \n\n→<p> 치환이 <pre> 안까지 파고들어 코드 블록의 빈 줄을
// </p><p> 로 끊어먹는 사고가 안 난다. 인라인 `코드`/굵게 처리도 pre 안을 건드리지 못한다.
const blocks=[];
let s=String(text||'').replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');
s=s.replace(/```[\w]*\n?([\s\S]*?)```/g,(m,code)=>{
blocks.push('<pre><code>'+code.replace(/^\n/,'')+'</code></pre>');
return '\n\n\u0000B'+(blocks.length-1)+'\u0000\n\n';
});
s=s.replace(/`([^`]+)`/g,'<code>$1</code>')
.replace(/\*\*(.+?)\*\*/g,'<strong>$1</strong>')
.replace(/^#{1,3}\s+(.+)$/gm,'<h3>$1</h3>');
// 리스트 — 연속하는 [-*] 줄을 하나의 <ul>로 묶는다(항목별 <ul>이 따로 생기던 버그 수정).
s=s.replace(/(^|\n)([-*]\s+.+(?:\n[-*]\s+.+)*)/g,(m,lead,grp)=>{
const items=grp.split('\n').map(line=>'<li>'+line.replace(/^[-*]\s+/,'')+'</li>').join('');
return lead+'<ul style="padding-left:16px;margin:3px 0">'+items+'</ul>';
});
// 단락 — 빈 줄 단위로 split. 이미 블록 태그(<h3>/<ul>/코드 자리표시자)로 시작하면
// <p>로 감싸지 않는다.
s=s.split(/\n{2,}/).map(chunk=>{
const t=chunk.trim();
if(!t)return '';
if(/^\u0000B\d+\u0000$/.test(t))return t;
if(/^<(h3|ul|pre)\b/.test(t))return t;
return '<p>'+t+'</p>';
}).join('');
s=s.replace(/\u0000B(\d+)\u0000/g,(m,i)=>blocks[+i]||'');
return s;
}
async function sendMessage(){
@@ -1099,7 +1149,9 @@ async function callChat(message){
if(e.name!=='AbortError'){if(!bubble)bubble=addMsg('assistant','');bubble.innerHTML='<span style="color:#ef4444">오류: '+escHtml(e.message)+'</span>';}
}finally{btn.disabled=false;btn.textContent='전송';}
// 채팅으로 데이터가 바뀌었을 수 있으니(workshop_project 도구 호출) 대시보드를 새로고침한다.
await loadWorkshopData();
// 단, 데이터가 안 바뀌었으면(도구가 get 처럼 읽기만 한 경우) 화면을 건드리지 않는다 —
// 펼쳐둔 부품 카드가 접히거나 편집 중이던 input의 포커스가 날아가는 사고를 막기 위함.
await softRefreshWorkshop();
// 09-22: 채팅으로 scad_to_stl 등 파일도구를 불러 새 파일이 생겨도 파일탭이 이미 열려있으면
// 안 새로고침되던 문제(사용자 신고: "저장되었는데 보이지 않네") — 탭 전환 없이도 갱신.
if(document.getElementById('pane-files').classList.contains('active'))loadFiles();
@@ -1185,6 +1237,7 @@ document.addEventListener('click',(e)=>{
// ── Init ──────────────────────────────────────────────────────────────────────
(async function init(){
if(!(await checkAuth()))return;
{const tb=document.getElementById('theme-btn');if(tb)tb.textContent=document.documentElement.getAttribute('data-theme')==='light'?'☀️':'🌙';}
await loadWorkshopData();
await initSessionId();
await loadHistory();