From 54263ae0b4d1fd2c5e6f125edc29af37220fce60 Mon Sep 17 00:00:00 2001 From: kim Date: Tue, 22 Sep 2026 19:28:54 +0900 Subject: [PATCH] =?UTF-8?q?fix:=20=EC=9E=91=EC=97=85=EC=8B=A4=20=EC=95=B1?= =?UTF-8?q?=206=EC=A2=85=20=EB=B2=84=EA=B7=B8=20=EC=88=98=EC=A0=95=20?= =?UTF-8?q?=E2=80=94=20renderMd=20=EC=BD=94=EB=93=9C/=EB=A6=AC=EC=8A=A4?= =?UTF-8?q?=ED=8A=B8,=20softRefresh=20=EB=8D=AE=EC=96=B4=EC=93=B0=EA=B8=B0?= =?UTF-8?q?,=20escJs=20=EC=9D=B8=EC=A0=9D=EC=85=98,=20=ED=85=8C=EB=A7=88?= =?UTF-8?q?=20=EC=95=84=EC=9D=B4=EC=BD=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit renderMd: 코드펜스 Placeholder 추출→연속 목록 ul 그룹화→단락 분할→블록태그 p 래핑 제외→Placeholder 복원 순으로 재작성. softRefreshWorkshop 추가(저장 대기 플러시 후 변경시에만 재렌더, expandedParts/notes 보존). escJs 추가해 삭제/토글 onclick 인젝션 방지. 테마 토글 아이콘(🌙/☀️) 동기화. Co-Authored-By: Claude Code --- web-ui/html/workshop-app.html | 87 ++++++++++++++++++++++++++++------- 1 file changed, 70 insertions(+), 17 deletions(-) diff --git a/web-ui/html/workshop-app.html b/web-ui/html/workshop-app.html index 5d9bf00..da93742 100644 --- a/web-ui/html/workshop-app.html +++ b/web-ui/html/workshop-app.html @@ -201,7 +201,7 @@ body{background:var(--bg);color:var(--text);font-family:system-ui,sans-serif;dis - +
@@ -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?``:''; + const delBtn=key?``:''; const grid=files.length?`
${files.map(renderFileCard).join('')}
` :'
(빈 폴더)
'; return `
@@ -860,13 +892,13 @@ function renderFileCard(file){ ? `${escAttr(file.name)}` : `${icon}`; // 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?``:''; + const selBox=isStl?``:''; return `
${selBox} - +
${thumb}
@@ -1040,16 +1072,34 @@ function addMsg(role,html){ } function renderMd(text){ - return text - .replace(/&/g,'&').replace(//g,'>') - .replace(/```[\w]*\n?([\s\S]*?)```/g,'
$1
') - .replace(/`([^`]+)`/g,'$1') - .replace(/\*\*(.+?)\*\*/g,'$1') - .replace(/^#{1,3}\s+(.+)$/gm,'

$1

') - .replace(/^[-*]\s+(.+)$/gm,'
  • $1
  • ') - .replace(/(
  • [\s\S]+?<\/li>)/g,m=>`
      ${m}
    `) - .replace(/\n\n/g,'

    ').replace(/^/,'

    ').replace(/$/,'

    ') - .replace(/

    <\/p>/g,'').replace(/

    (<[hup])/g,'$1').replace(/(<\/[hup][^>]*>)<\/p>/g,'$1'); + // 코드펜스를 먼저 자리표시자로 빼두고 모든 인라인/단락 처리가 끝난 뒤 복원한다 — + // 그래야 예전처럼 \n\n→

    치환이

     안까지 파고들어 코드 블록의 빈 줄을
    +  // 

    로 끊어먹는 사고가 안 난다. 인라인 `코드`/굵게 처리도 pre 안을 건드리지 못한다. + const blocks=[]; + let s=String(text||'').replace(/&/g,'&').replace(//g,'>'); + s=s.replace(/```[\w]*\n?([\s\S]*?)```/g,(m,code)=>{ + blocks.push('

    '+code.replace(/^\n/,'')+'
    '); + return '\n\n\u0000B'+(blocks.length-1)+'\u0000\n\n'; + }); + s=s.replace(/`([^`]+)`/g,'$1') + .replace(/\*\*(.+?)\*\*/g,'$1') + .replace(/^#{1,3}\s+(.+)$/gm,'

    $1

    '); + // 리스트 — 연속하는 [-*] 줄을 하나의
      로 묶는다(항목별
        이 따로 생기던 버그 수정). + s=s.replace(/(^|\n)([-*]\s+.+(?:\n[-*]\s+.+)*)/g,(m,lead,grp)=>{ + const items=grp.split('\n').map(line=>'
      • '+line.replace(/^[-*]\s+/,'')+'
      • ').join(''); + return lead+'
          '+items+'
        '; + }); + // 단락 — 빈 줄 단위로 split. 이미 블록 태그(

        /
          /코드 자리표시자)로 시작하면 + //

          로 감싸지 않는다. + 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 '

          '+t+'

          '; + }).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='오류: '+escHtml(e.message)+'';} }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();