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:
@@ -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,''').replace(/"/g,'"');}
|
||||
// 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 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,'&').replace(/</g,'<').replace(/>/g,'>')
|
||||
.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,'&').replace(/</g,'<').replace(/>/g,'>');
|
||||
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();
|
||||
|
||||
Reference in New Issue
Block a user