- Image extraction now immediately followed by per-figure mistral analysis no manual button click needed: extract → analyze runs as one atomic flow - Progress shows "이미지 분석 1/5..." inline with prep step - Captions + 제외 filtering available before outline generation - "🔍 재분석" button retained for manual uploads / re-run on failures Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
3761 lines
199 KiB
HTML
3761 lines
199 KiB
HTML
<!DOCTYPE html>
|
||
<html lang="ko">
|
||
<head>
|
||
<meta charset="UTF-8">
|
||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||
<title>📊 슬라이드 제작</title>
|
||
<style>
|
||
:root{
|
||
--bg:#0f1117;--panel:#1a1d27;--panel2:#22263a;--line:#2e3250;
|
||
--text:#e2e8f0;--muted:#94a3b8;--brand:#6366f1;--brand2:#818cf8;
|
||
--green:#22c55e;--red:#ef4444;--yellow:#f59e0b;--r:10px;
|
||
}
|
||
body.theme-light{
|
||
--bg:#eef1f8;--panel:#ffffff;--panel2:#f0f3fb;--line:#cdd3e8;
|
||
--text:#1e2337;--muted:#5a6480;--brand:#4f52cc;--brand2:#6366f1;
|
||
--green:#15803d;--red:#dc2626;--yellow:#b45309;
|
||
}
|
||
body.theme-warm{
|
||
--bg:#f5f0e8;--panel:#fffdf7;--panel2:#ede8dc;--line:#d6ccbb;
|
||
--text:#2c2318;--muted:#7a6e5e;--brand:#7c5c2e;--brand2:#a07840;
|
||
--green:#15803d;--red:#dc2626;--yellow:#b45309;
|
||
}
|
||
.theme-btn{background:none;border:1.5px solid var(--line);border-radius:6px;padding:4px 10px;font-size:12px;color:var(--muted);cursor:pointer;transition:.15s;}
|
||
.theme-btn:hover,.theme-btn.active{border-color:var(--brand);color:var(--brand2);background:rgba(99,102,241,.08);}
|
||
.src-chk{display:flex;align-items:flex-start;gap:6px;font-size:12px;font-weight:600;color:var(--muted);cursor:pointer;background:var(--panel2);border:1.5px solid var(--line);border-radius:7px;padding:7px 12px;transition:.15s;user-select:none;max-width:220px;}
|
||
.src-chk:has(input:checked){border-color:var(--brand);background:rgba(99,102,241,.1);color:var(--brand2);}
|
||
.src-chk input{accent-color:var(--brand);width:13px;height:13px;cursor:pointer;margin-top:2px;flex-shrink:0;}
|
||
.src-chk-desc{display:block;font-size:10px;font-weight:400;color:var(--muted);margin-top:3px;line-height:1.5;}
|
||
*{margin:0;padding:0;box-sizing:border-box;}
|
||
body{background:var(--bg);color:var(--text);font-family:'Segoe UI',system-ui,sans-serif;height:100vh;display:flex;flex-direction:column;overflow:hidden;font-size:14px;}
|
||
|
||
.header{display:flex;align-items:center;gap:10px;padding:10px 18px;background:var(--panel);border-bottom:1px solid var(--line);flex-shrink:0;}
|
||
.header h1{font-size:16px;font-weight:700;}
|
||
.steps{display:flex;gap:2px;margin-left:auto;align-items:center;}
|
||
.step-hint{font-size:10px;color:var(--muted);margin-right:6px;}
|
||
.sdot{display:flex;align-items:center;gap:6px;font-size:12px;color:var(--muted);padding:5px 12px;border-radius:6px;cursor:pointer;transition:.15s;user-select:none;}
|
||
.sdot.active{color:var(--brand2);background:rgba(99,102,241,.15);font-weight:700;}
|
||
.sdot.done{color:var(--green);}
|
||
.sdot .n{width:20px;height:20px;border-radius:50%;border:1.5px solid currentColor;display:flex;align-items:center;justify-content:center;font-size:10px;font-weight:700;flex-shrink:0;}
|
||
|
||
.body{display:flex;flex:1;min-height:0;}
|
||
.sv{display:none;flex-direction:column;flex:1;min-height:0;}
|
||
.sv.active{display:flex;}
|
||
.ptitle{font-size:11px;font-weight:700;color:var(--muted);text-transform:uppercase;letter-spacing:.06em;padding:11px 16px 8px;border-bottom:1px solid var(--line);flex-shrink:0;}
|
||
.scroll{flex:1;overflow-y:auto;padding:14px 16px;}
|
||
|
||
/* Buttons */
|
||
.btn{padding:8px 16px;border:none;border-radius:var(--r);font-size:13px;font-weight:600;cursor:pointer;transition:.15s;white-space:nowrap;}
|
||
.btn:disabled{opacity:.4;cursor:not-allowed;}
|
||
.bp{background:var(--brand);color:#fff;}.bp:not(:disabled):hover{background:var(--brand2);}
|
||
.bg{background:var(--panel2);color:var(--text);border:1px solid var(--line);}.bg:not(:disabled):hover{background:var(--line);}
|
||
.bgreen{background:#166534;color:#86efac;border:1px solid #166534;}.bgreen:not(:disabled):hover{background:#15803d;}
|
||
.bred{background:#7f1d1d;color:#fca5a5;border:1px solid #7f1d1d;}
|
||
|
||
/* Search */
|
||
.srow{display:flex;gap:8px;margin-bottom:12px;}
|
||
.srow input{flex:1;background:var(--panel2);border:1px solid var(--line);border-radius:var(--r);padding:8px 12px;font-size:13px;color:var(--text);outline:none;}
|
||
.srow input:focus{border-color:var(--brand);}
|
||
.srow .yin{width:80px;flex-shrink:0;}
|
||
|
||
/* Paper cards */
|
||
.pcard{background:var(--panel2);border:1px solid var(--line);border-radius:var(--r);padding:11px 13px;margin-bottom:8px;display:flex;gap:10px;align-items:flex-start;transition:.15s;}
|
||
.pcard.sel{border-color:var(--brand);background:rgba(99,102,241,.07);}
|
||
.pcard input[type=checkbox]{margin-top:3px;flex-shrink:0;accent-color:var(--brand);width:15px;height:15px;cursor:pointer;}
|
||
.pmeta{flex:1;min-width:0;}
|
||
.ptitle2{font-size:13px;font-weight:600;line-height:1.45;margin-bottom:4px;}
|
||
.psub{font-size:11px;color:var(--muted);}
|
||
.badge{display:inline-block;font-size:10px;padding:2px 6px;border-radius:3px;margin-right:3px;font-weight:700;}
|
||
.bpmc{background:rgba(34,197,94,.15);color:var(--green);}
|
||
.byear{background:rgba(99,102,241,.15);color:var(--brand2);}
|
||
.btype{background:rgba(245,158,11,.1);color:var(--yellow);}
|
||
.bnopmc{background:rgba(239,68,68,.1);color:var(--red);}
|
||
.bupload{background:rgba(99,102,241,.15);color:var(--brand2);}
|
||
.dropzone{border:2px dashed var(--line);border-radius:10px;padding:18px 14px;text-align:center;cursor:pointer;transition:.15s;color:var(--muted);font-size:12px;margin-bottom:6px;}
|
||
.dropzone:hover,.dropzone.drag{border-color:var(--brand);color:var(--brand2);background:rgba(99,102,241,.05);}
|
||
.dropzone input[type=file]{display:none;}
|
||
.pbtns{display:flex;gap:5px;margin-top:6px;}
|
||
|
||
/* Prep */
|
||
.prog{padding:8px 10px;border-bottom:1px solid var(--line);font-size:13px;border-radius:6px;transition:background .2s;}
|
||
.prog.active{background:rgba(99,102,241,.08);border-left:3px solid var(--brand2);padding-left:8px;}
|
||
.psp{display:inline-block;padding:2px 9px;border-radius:10px;font-size:11px;font-weight:600;white-space:nowrap;}
|
||
.psp-wait{background:var(--line);color:var(--muted);}
|
||
.psp-run{background:rgba(99,102,241,.2);color:var(--brand2);}
|
||
.psp-done{background:rgba(34,197,94,.15);color:var(--green);}
|
||
.psp-fail{background:rgba(239,68,68,.15);color:var(--red);}
|
||
.psp-skip{background:var(--line);color:var(--muted);}
|
||
@keyframes blink{0%,100%{opacity:1}50%{opacity:.4}}
|
||
.psp-run{animation:blink 1.2s ease-in-out infinite;}
|
||
.prog:last-child{border-bottom:none;}
|
||
.pstat{width:16px;flex-shrink:0;margin-top:1px;}
|
||
|
||
/* Img grid */
|
||
.igrid{display:grid;grid-template-columns:repeat(auto-fill,minmax(110px,1fr));gap:8px;}
|
||
.icard{position:relative;border:1.5px solid var(--line);border-radius:7px;overflow:hidden;cursor:pointer;aspect-ratio:4/3;background:var(--panel2);transition:.15s;}
|
||
.icard:hover{border-color:var(--brand);}
|
||
.icard img{width:100%;height:100%;object-fit:cover;}
|
||
.ilabel{position:absolute;bottom:0;left:0;right:0;background:rgba(0,0,0,.65);color:#fff;font-size:9px;padding:2px 5px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}
|
||
|
||
/* Slide cards */
|
||
/* slide editor */
|
||
.slide-eg{display:grid;grid-template-columns:repeat(auto-fill,minmax(260px,1fr));gap:10px;padding-bottom:8px;}
|
||
.scard{background:var(--panel2);border:1.5px solid var(--line);border-radius:10px;overflow:hidden;transition:.15s;}
|
||
.scard.open{border-color:var(--brand);}
|
||
.scard-hd{display:flex;align-items:center;gap:6px;padding:7px 10px;cursor:pointer;background:var(--panel);user-select:none;}
|
||
.scard-hd:hover{background:var(--line);}
|
||
.slide-mini{aspect-ratio:16/9;padding:8% 9%;position:relative;overflow:hidden;cursor:pointer;box-sizing:border-box;}
|
||
.slide-mini-title{font-weight:700;line-height:1.3;overflow:hidden;display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;word-break:keep-all;overflow-wrap:break-word;}
|
||
.slide-mini-bullets{margin-top:5px;opacity:.75;}
|
||
.slide-mini-img{position:absolute;right:5%;top:15%;width:38%;height:70%;object-fit:cover;border-radius:3px;opacity:.88;}
|
||
.slide-edit-area{padding:12px;border-top:1px solid var(--line);}
|
||
.slide-edit-row{display:flex;gap:8px;flex-wrap:wrap;align-items:flex-end;margin-top:8px;}
|
||
.sbul-ta{width:100%;background:var(--panel);border:1px solid var(--line);border-radius:6px;padding:6px 8px;font-size:12px;color:var(--text);resize:vertical;outline:none;line-height:1.5;min-height:60px;font-family:monospace;}
|
||
.sbul-ta:focus{border-color:var(--brand);}
|
||
.elabel{font-size:10px;font-weight:700;color:var(--muted);text-transform:uppercase;letter-spacing:.05em;display:block;margin-bottom:3px;}
|
||
/* translation modal */
|
||
.txlmodal{width:min(680px,94vw);max-height:86vh;}
|
||
.txlmodal-hd{padding:14px 18px;border-bottom:1px solid var(--line);display:flex;align-items:center;gap:10px;flex-shrink:0;}
|
||
.txlmodal-body{flex:1;overflow-y:auto;padding:18px 20px;font-size:13px;line-height:1.85;white-space:pre-wrap;color:var(--text);}
|
||
.txlmodal-ft{padding:10px 14px;border-top:1px solid var(--line);display:flex;gap:8px;justify-content:flex-end;flex-shrink:0;}
|
||
.snum{width:22px;height:22px;border-radius:50%;background:var(--brand);color:#fff;font-size:10px;font-weight:700;display:flex;align-items:center;justify-content:center;flex-shrink:0;}
|
||
.stype-tag{font-size:10px;color:var(--muted);background:var(--panel);padding:2px 6px;border-radius:3px;}
|
||
.sin{background:var(--panel);border:1px solid var(--line);border-radius:6px;padding:6px 9px;font-size:13px;color:var(--text);width:100%;outline:none;}
|
||
.sin:focus{border-color:var(--brand);}
|
||
.sbul textarea{width:100%;background:var(--panel);border:1px solid var(--line);border-radius:6px;padding:6px 9px;font-size:12px;color:var(--text);font-family:'IBM Plex Mono',monospace;resize:vertical;outline:none;margin-top:5px;line-height:1.5;}
|
||
.sbul textarea:focus{border-color:var(--brand);}
|
||
.simg-row{display:flex;align-items:center;gap:8px;margin-top:7px;}
|
||
.sthumb{width:52px;height:40px;border-radius:5px;object-fit:cover;border:1px solid var(--line);}
|
||
.sno-img{width:52px;height:40px;border-radius:5px;border:1px dashed var(--line);display:flex;align-items:center;justify-content:center;color:var(--muted);font-size:14px;flex-shrink:0;}
|
||
.pickbtn{font-size:11px;padding:3px 9px;border-radius:5px;background:var(--panel);border:1px solid var(--line);color:var(--muted);cursor:pointer;}
|
||
.pickbtn:hover{border-color:var(--brand);color:var(--brand2);}
|
||
|
||
/* Skin picker */
|
||
.skin-grid{display:grid;grid-template-columns:repeat(6,1fr);gap:5px;margin-top:5px;}
|
||
.skin-sw{width:100%;aspect-ratio:1;border-radius:5px;cursor:pointer;border:2px solid transparent;transition:.15s;}
|
||
.skin-sw.active{border-color:var(--brand);box-shadow:0 0 0 2px var(--brand);transform:scale(1.12);}
|
||
.skin-sw:hover{transform:scale(1.08);}
|
||
|
||
/* Preview grid */
|
||
.preview-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(220px,1fr));gap:12px;}
|
||
.preview-card{position:relative;background:var(--panel2);border:1px solid var(--line);border-radius:var(--r);overflow:hidden;transition:.15s;}
|
||
.preview-card:hover{border-color:var(--brand);}
|
||
.preview-card img{width:100%;display:block;}
|
||
.prev-edit-btn{position:absolute;top:6px;right:6px;opacity:0;transition:.15s;z-index:2;background:rgba(30,30,40,.75);color:#fff;border:none;border-radius:5px;padding:3px 9px;font-size:11px;cursor:pointer;backdrop-filter:blur(4px);}
|
||
.preview-card:hover .prev-edit-btn{opacity:1;}
|
||
.prev-edit-btn:hover{background:var(--brand)!important;opacity:1!important;}
|
||
.preview-card[data-edited]::after{content:'● 수정됨';position:absolute;top:6px;left:6px;background:rgba(99,102,241,.85);color:#fff;font-size:10px;padding:2px 7px;border-radius:10px;pointer-events:none;z-index:3;}
|
||
.preview-label{font-size:11px;padding:6px 10px;color:var(--muted);text-align:center;}
|
||
|
||
/* Settings sidebar — LEFT */
|
||
.sidebar{width:230px;flex-shrink:0;border-right:1px solid var(--line);background:var(--panel);display:flex;flex-direction:column;overflow:hidden;order:-1;}
|
||
.sidebar .scroll{padding:12px;}
|
||
.field{margin-bottom:12px;}
|
||
.flabel{display:block;font-size:10px;font-weight:700;color:var(--muted);text-transform:uppercase;letter-spacing:.05em;margin-bottom:5px;}
|
||
.field select,.field input[type=text],.field input[type=number]{width:100%;background:var(--panel2);border:1px solid var(--line);border-radius:7px;padding:6px 8px;font-size:12px;color:var(--text);outline:none;}
|
||
.field select:focus,.field input:focus{border-color:var(--brand);}
|
||
|
||
/* Bottom bar */
|
||
.bbar{padding:9px 14px;border-top:1px solid var(--line);background:var(--panel);flex-shrink:0;display:flex;gap:7px;align-items:center;}
|
||
.bbar .sp{flex:1;}
|
||
.smsg{font-size:12px;color:var(--muted);}
|
||
.smsg.running{animation:blink 1.2s ease-in-out infinite;}
|
||
|
||
/* Modals */
|
||
.mbg{display:none;position:fixed;inset:0;background:rgba(0,0,0,.8);z-index:200;align-items:center;justify-content:center;}
|
||
.mbg.open{display:flex;}
|
||
.modal{background:var(--panel);border:1px solid var(--line);border-radius:14px;display:flex;flex-direction:column;gap:0;}
|
||
|
||
/* Abstract modal */
|
||
.amodal{width:min(740px,94vw);max-height:88vh;}
|
||
.amodal-hd{padding:16px 20px;border-bottom:1px solid var(--line);flex-shrink:0;}
|
||
.amodal-title{font-size:15px;font-weight:700;line-height:1.4;margin-bottom:5px;}
|
||
.amodal-meta{font-size:12px;color:var(--muted);}
|
||
.atabs{display:flex;gap:2px;padding:8px 16px;border-bottom:1px solid var(--line);flex-shrink:0;}
|
||
.atab{padding:5px 14px;border-radius:6px;font-size:12px;font-weight:600;cursor:pointer;color:var(--muted);background:none;border:none;}
|
||
.atab.active{color:var(--brand2);background:rgba(99,102,241,.15);}
|
||
.amodal-body{flex:1;overflow-y:auto;padding:18px 20px;font-size:14px;line-height:1.85;white-space:pre-wrap;color:var(--text);}
|
||
.amodal-ft{padding:12px 18px;border-top:1px solid var(--line);flex-shrink:0;display:flex;gap:8px;justify-content:flex-end;align-items:center;}
|
||
|
||
/* Image picker modal */
|
||
.imodal{width:min(680px,94vw);max-height:82vh;}
|
||
.imodal-hd{padding:14px 18px;border-bottom:1px solid var(--line);font-size:14px;font-weight:700;flex-shrink:0;}
|
||
.imodal-body{flex:1;overflow-y:auto;padding:14px;}
|
||
.imodal-ft{padding:10px 16px;border-top:1px solid var(--line);flex-shrink:0;display:flex;justify-content:flex-end;}
|
||
/* PDF capture modal */
|
||
.pdfmodal{width:min(900px,96vw);max-height:94vh;height:94vh;}
|
||
.pdfmodal-hd{padding:10px 16px;border-bottom:1px solid var(--line);display:flex;align-items:center;gap:10px;flex-shrink:0;}
|
||
.pdfmodal-body{flex:1;overflow:hidden;display:flex;flex-direction:column;align-items:center;background:#111;position:relative;}
|
||
.pdfmodal-ft{padding:8px 14px;border-top:1px solid var(--line);display:flex;gap:8px;align-items:center;flex-shrink:0;}
|
||
#pdf-canvas-wrap{overflow:auto;flex:1;width:100%;display:flex;justify-content:flex-start;align-items:flex-start;background:#111;}
|
||
#pdf-main-canvas{display:block;cursor:crosshair;}
|
||
|
||
/* Slideshow overlay */
|
||
#slideshow{display:none;position:fixed;inset:0;background:#000;z-index:300;flex-direction:column;align-items:center;justify-content:center;}
|
||
#slideshow.open{display:flex;}
|
||
.ss-img-wrap{flex:1;min-height:0;display:flex;align-items:center;justify-content:center;width:100%;padding:16px 80px;}
|
||
#ss-img{max-width:100%;max-height:100%;object-fit:contain;border-radius:4px;}
|
||
.ss-bar{width:100%;padding:14px 20px;display:flex;align-items:center;gap:12px;background:rgba(0,0,0,.6);flex-shrink:0;}
|
||
.ss-btn{background:rgba(255,255,255,.12);border:1px solid rgba(255,255,255,.2);color:#fff;border-radius:8px;padding:8px 18px;font-size:14px;cursor:pointer;transition:.15s;}
|
||
.ss-btn:hover{background:rgba(255,255,255,.22);}
|
||
.ss-counter{color:#fff;font-size:14px;font-weight:600;min-width:60px;text-align:center;}
|
||
.ss-title{color:rgba(255,255,255,.7);font-size:13px;flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}
|
||
.ss-nav{position:absolute;top:50%;transform:translateY(-50%);background:rgba(0,0,0,.4);border:none;color:#fff;font-size:28px;width:52px;height:52px;border-radius:50%;cursor:pointer;display:flex;align-items:center;justify-content:center;transition:.2s;}
|
||
.ss-nav:hover{background:rgba(99,102,241,.6);}
|
||
#ss-prev{left:12px;}
|
||
#ss-nav-next{right:12px;}
|
||
|
||
/* ── Slide Editor Modal ─────────────────────────────────────────────────── */
|
||
.sedmodal{width:1200px;height:90vh;max-width:98vw;max-height:98vh;border-radius:10px;position:fixed;box-shadow:0 8px 40px rgba(0,0,0,.6);cursor:default;overflow:hidden;left:50%;top:50%;transform:translate(-50%,-50%);}
|
||
.sed-hd{cursor:move;user-select:none;}
|
||
.sed-hd{display:flex;align-items:center;gap:8px;padding:10px 18px;border-bottom:1px solid var(--line);flex-shrink:0;}
|
||
.sed-body{display:flex;flex:1;min-height:0;overflow:hidden;}
|
||
.sed-ctrl{width:340px;flex-shrink:0;border-right:1px solid var(--line);overflow-y:auto;padding:16px 18px;display:flex;flex-direction:column;gap:16px;}
|
||
.sed-stage{flex:1;background:#080b12;display:flex;align-items:center;justify-content:center;padding:28px;min-width:0;position:relative;}
|
||
.sed-preview{position:relative;border-radius:6px;box-shadow:0 8px 48px rgba(0,0,0,.7);overflow:hidden;transition:background .2s;}
|
||
.sed-section-lbl{font-size:12px;font-weight:700;color:var(--muted);text-transform:uppercase;letter-spacing:.05em;margin-bottom:8px;display:block;}
|
||
.sed-hint{display:flex;gap:10px;font-size:10px;color:var(--muted);margin-top:3px;margin-bottom:4px;opacity:.7}
|
||
.sed-hint span{background:var(--panel2);padding:1px 6px;border-radius:4px}
|
||
.layout-grid{display:grid;grid-template-columns:repeat(3,1fr);gap:5px;}
|
||
.layout-btn{background:var(--panel2);border:1.5px solid var(--line);border-radius:7px;padding:8px 4px 6px;cursor:pointer;text-align:center;transition:.15s;user-select:none;}
|
||
.layout-btn:hover{border-color:var(--brand);background:rgba(99,102,241,.08);}
|
||
.layout-btn.active{border-color:var(--brand);background:rgba(99,102,241,.15);color:var(--brand2);}
|
||
.layout-btn .li{font-size:13px;display:block;margin-bottom:3px;letter-spacing:.5px;font-family:monospace;}
|
||
.layout-btn .lt{font-size:11px;color:var(--muted);}
|
||
.layout-btn.active .lt{color:var(--brand2);font-weight:700;}
|
||
.sed-inp{width:100%;background:var(--panel);border:1px solid var(--line);border-radius:7px;padding:9px 12px;font-size:16px;color:var(--text);outline:none;font-family:inherit;resize:none;line-height:1.5;min-height:46px;}
|
||
.sed-inp:focus{border-color:var(--brand);}
|
||
.sed-ta{width:100%;background:var(--panel);border:1px solid var(--line);border-radius:7px;padding:9px 12px;font-size:15px;color:var(--text);font-family:'IBM Plex Mono',monospace;resize:vertical;outline:none;line-height:1.65;min-height:110px;}
|
||
.sed-ta:focus{border-color:var(--brand);}
|
||
.sed-row{display:flex;align-items:center;gap:8px;margin-top:6px;}
|
||
.sed-range{flex:1;accent-color:var(--brand);}
|
||
.sed-val{font-size:13px;font-weight:700;color:var(--brand2);min-width:40px;text-align:right;}
|
||
.sed-skin-grid{display:grid;grid-template-columns:repeat(7,1fr);gap:4px;margin-top:5px;}
|
||
.sed-skin-sw{aspect-ratio:1;border-radius:5px;cursor:pointer;border:2px solid transparent;transition:.15s;}
|
||
.sed-skin-sw.active{border-color:var(--brand);box-shadow:0 0 0 2px var(--brand);transform:scale(1.15);}
|
||
.sed-skin-sw:hover{transform:scale(1.1);}
|
||
.fx-btn{font-size:11px;padding:3px 8px;border-radius:5px;background:var(--panel);border:1px solid var(--line);color:var(--muted);cursor:pointer;transition:.12s;}
|
||
.fx-btn:hover{border-color:var(--brand);color:var(--brand2);}
|
||
.fx-btn.active{background:var(--brand);border-color:var(--brand);color:#fff;}
|
||
.clr-row{display:flex;align-items:center;gap:6px;margin-top:5px;}
|
||
.clr-row label{font-size:11px;color:var(--muted);min-width:36px;}
|
||
.clr-row input[type=color]{width:28px;height:22px;border:1px solid var(--line);border-radius:4px;padding:0 2px;background:var(--panel);cursor:pointer;}
|
||
.clr-row .clr-reset{font-size:10px;color:var(--muted);cursor:pointer;padding:2px 5px;border:1px solid var(--line);border-radius:4px;background:var(--panel);}
|
||
.clr-row .clr-reset:hover{color:var(--red);border-color:var(--red);}
|
||
.sed-img-thumb{width:100%;height:100px;border-radius:6px;background:var(--panel) center/cover no-repeat;border:1px solid var(--line);display:flex;align-items:center;justify-content:center;font-size:28px;color:var(--muted);cursor:pointer;transition:.15s;}
|
||
.sed-img-thumb:hover{border-color:var(--brand);}
|
||
.sed-nav-info{font-size:12px;color:var(--muted);text-align:center;flex:1;}
|
||
</style>
|
||
</head>
|
||
<body>
|
||
|
||
<div class="header">
|
||
<span style="font-size:18px">📊</span>
|
||
<h1>슬라이드 제작 마법사</h1>
|
||
<div style="display:flex;gap:4px;margin-left:14px">
|
||
<button class="theme-btn active" id="tbtn-dark" onclick="setTheme('dark')">🌙 다크</button>
|
||
<button class="theme-btn" id="tbtn-light" onclick="setTheme('light')">☀️ 라이트</button>
|
||
<button class="theme-btn" id="tbtn-warm" onclick="setTheme('warm')">🌿 웜</button>
|
||
</div>
|
||
<div class="steps">
|
||
<span class="step-hint">← 번호 클릭으로 이동</span>
|
||
<div class="sdot active" id="sd1" onclick="goStep(1)"><div class="n">1</div>논문 검색</div>
|
||
<div class="sdot" id="sd2" onclick="goStep(2)"><div class="n">2</div>준비</div>
|
||
<div class="sdot" id="sd3" onclick="goStep(3)"><div class="n">3</div>아웃라인</div>
|
||
<div class="sdot" id="sd4" onclick="goStep(4)"><div class="n">4</div>프리뷰</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="body">
|
||
|
||
<!-- ── STEP 1 ── -->
|
||
<div class="sv active" id="step-1">
|
||
<div class="ptitle">PubMed 논문 검색</div>
|
||
<div class="scroll">
|
||
<div style="display:flex;gap:6px;margin-bottom:8px;align-items:flex-start;flex-wrap:wrap">
|
||
<span style="font-size:11px;font-weight:700;color:var(--muted);margin-top:7px">검색 소스:</span>
|
||
<label class="src-chk" id="src-pubmed">
|
||
<input type="checkbox" checked onchange="updSrcState()">
|
||
<span>🔬 PubMed<span class="src-chk-desc">미국 NLM 의학·생명과학 전문 DB. PMC 등재 논문은 전문 PDF 다운로드 가능.</span></span>
|
||
</label>
|
||
<label class="src-chk" id="src-openalex">
|
||
<input type="checkbox" onchange="updSrcState()">
|
||
<span>🌐 OpenAlex<span class="src-chk-desc">전 분야 2억+ 논문. API 키 불필요. 인용수·OA 여부 제공. OA 논문은 PDF 다운로드 가능.</span></span>
|
||
</label>
|
||
<label class="src-chk" id="src-semantic">
|
||
<input type="checkbox" onchange="updSrcState()">
|
||
<span>🧠 Semantic Scholar<span class="src-chk-desc">AI·CS·학제간 연구에 강함. 인용 영향력 지수 및 OA PDF 링크 제공.</span></span>
|
||
</label>
|
||
</div>
|
||
<div class="srow">
|
||
<input id="qi" placeholder="예: sinus lift augmentation" onkeydown="if(event.key==='Enter')doSearch()">
|
||
<input class="yin" id="qy" type="number" placeholder="연도" min="1990" max="2030" value="2017">
|
||
<span style="font-size:11px;color:var(--muted);white-space:nowrap">이후 (선택)</span>
|
||
<button class="btn bp" onclick="doSearch()" id="sbtn">🔍 검색</button>
|
||
</div>
|
||
<div id="search-legend" style="display:none;padding:7px 12px;background:rgba(99,102,241,.07);border-radius:7px;margin-bottom:6px;font-size:11px;color:var(--muted);line-height:1.7">
|
||
<span style="color:var(--green);font-weight:700">■ PMC등재</span> — PubMed Central에 전문(PDF) 있음. 체크박스로 선택해 PPTX에 활용 가능.<br>
|
||
<span style="color:var(--red);font-weight:700">■ PMC미등재</span> — PMC에 전문 없음. 초록 보기 가능, PDF 다운로드 불가.<br>
|
||
<span style="color:var(--brand2);font-weight:700">■ 연도</span> — 출판 연도 | <span style="color:#fcd34d;font-weight:700">■ 유형</span> — 논문 종류 (RCT, Review 등)
|
||
</div>
|
||
<div id="results"></div>
|
||
<div style="padding:8px 0 2px;display:flex;align-items:center;gap:8px;color:var(--muted);font-size:11px">
|
||
<div style="flex:1;height:1px;background:var(--line)"></div>또는 PDF 직접 업로드<div style="flex:1;height:1px;background:var(--line)"></div>
|
||
</div>
|
||
<div class="dropzone" id="pdf-dropzone"
|
||
onclick="document.getElementById('pdf-file-input').click()"
|
||
ondragover="event.preventDefault();this.classList.add('drag')"
|
||
ondragleave="this.classList.remove('drag')"
|
||
ondrop="this.classList.remove('drag');handlePdfDrop(event)">
|
||
<input type="file" id="pdf-file-input" accept=".pdf" multiple onchange="handlePdfFileInput(this)">
|
||
📄 PDF 파일을 드래그하거나 클릭해서 선택<br>
|
||
<span style="font-size:10px">여러 파일 동시 업로드 가능</span>
|
||
</div>
|
||
<div id="upload-list"></div>
|
||
</div>
|
||
<div class="bbar">
|
||
<span class="smsg" id="s1msg"></span><div class="sp"></div>
|
||
<button class="btn bp" id="s1next" onclick="goStep(2)" disabled>▶ PDF 준비 →</button>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- ── STEP 2 ── -->
|
||
<div class="sv" id="step-2">
|
||
<div class="ptitle">PDF 다운로드</div>
|
||
<div class="scroll" style="display:flex;gap:14px">
|
||
<div style="flex:1;min-width:0">
|
||
<div style="display:flex;align-items:center;gap:8px;margin-bottom:8px">
|
||
<span style="font-size:10px;color:var(--muted);font-weight:600;text-transform:uppercase;letter-spacing:.04em">진행 상황</span>
|
||
<span id="s2-overall" style="display:none;font-size:11px;color:var(--brand2);font-weight:700"></span>
|
||
</div>
|
||
<div id="proglist"></div>
|
||
<div id="img-search-section" style="margin-top:14px;border-top:1px solid var(--line);padding-top:10px">
|
||
<div style="font-size:10px;color:var(--muted);font-weight:600;text-transform:uppercase;letter-spacing:.04em;margin-bottom:6px">🔍 이미지 검색</div>
|
||
<div style="display:flex;gap:6px">
|
||
<input id="img-search-input" type="text" placeholder="예: 구강 해부도, TMJ X-ray"
|
||
style="flex:1;font-size:12px;padding:4px 8px;background:var(--panel2);border:1px solid var(--line);border-radius:5px;color:var(--fg);outline:none"
|
||
onkeydown="if(event.key==='Enter')doImgSearch()">
|
||
<button class="btn bg" style="font-size:11px;padding:3px 10px;flex-shrink:0" onclick="doImgSearch()">검색</button>
|
||
</div>
|
||
<div id="img-search-results" style="margin-top:8px;max-height:260px;overflow-y:auto"></div>
|
||
</div>
|
||
</div>
|
||
<div style="flex:2;min-width:0">
|
||
<div style="display:flex;align-items:center;gap:8px;margin-bottom:6px">
|
||
<span style="font-size:10px;color:var(--muted);font-weight:600;text-transform:uppercase;letter-spacing:.04em">캡처 이미지 <span id="imgcnt"></span></span>
|
||
<button id="btn-analyze-imgs" onclick="analyzeImages()" style="font-size:11px;color:#10b981;background:rgba(16,185,129,.1);border:1px solid #10b981;border-radius:5px;padding:2px 8px;cursor:pointer;flex-shrink:0" title="미분석 이미지를 mistral vision으로 재분석 (준비 실행 시 자동 처리됨)">🔍 재분석</button>
|
||
<label style="display:flex;align-items:center;gap:4px;font-size:11px;color:var(--brand2);background:rgba(99,102,241,.1);border:1px solid var(--brand);border-radius:5px;padding:2px 8px;cursor:pointer;flex-shrink:0" title="이미지 파일 업로드">
|
||
<input type="file" id="img-upload-input" accept="image/*" multiple style="display:none" onchange="handleImgUpload(this.files)">
|
||
+ 이미지 업로드
|
||
</label>
|
||
</div>
|
||
<div id="img-dropzone"
|
||
style="border:2px dashed var(--line);border-radius:7px;padding:6px 10px;text-align:center;font-size:11px;color:var(--muted);margin-bottom:7px;cursor:pointer;transition:.15s"
|
||
onclick="document.getElementById('img-upload-input').click()"
|
||
ondragover="event.preventDefault();this.style.borderColor='var(--brand)';this.style.color='var(--brand2)'"
|
||
ondragleave="this.style.borderColor='';this.style.color=''"
|
||
ondrop="event.preventDefault();this.style.borderColor='';this.style.color='';handleImgUpload(event.dataTransfer.files)">
|
||
이미지 파일을 여기에 드래그하거나 클릭
|
||
</div>
|
||
<div class="igrid" id="imgrid"></div>
|
||
</div>
|
||
</div>
|
||
<div class="bbar">
|
||
<span class="smsg" id="s2msg"></span><div class="sp"></div>
|
||
<button class="btn bp" id="s2start" onclick="startPrep()">▶ 시작</button>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- ── STEP 3 ── -->
|
||
<div class="sv" id="step-3">
|
||
<div class="ptitle">슬라이드 아웃라인 편집</div>
|
||
<div class="scroll" id="outline-scroll">
|
||
<div id="outline-cards" style="padding-bottom:8px">
|
||
<div style="color:var(--muted);font-size:12px;text-align:center;padding:24px 0">준비 단계 완료 후 아웃라인이 자동 생성됩니다</div>
|
||
</div>
|
||
</div>
|
||
<div class="bbar">
|
||
<span class="smsg" id="s3msg"></span><div class="sp"></div>
|
||
<button class="btn bgreen" id="s3create" onclick="createPptx()" disabled>✅ PPTX 생성 →</button>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- ── STEP 4 ── -->
|
||
<div class="sv" id="step-4">
|
||
<div class="ptitle">슬라이드 프리뷰</div>
|
||
<div class="scroll">
|
||
<div id="preview-hint" style="display:none;padding:7px 14px;background:rgba(99,102,241,.07);border-radius:7px;margin-bottom:8px;font-size:12px;color:var(--muted);line-height:1.6">
|
||
카드에 마우스를 올리면 <strong>✏ 편집</strong> 버튼이 나타납니다. 편집 후 <strong>🔄 PPTX 재생성</strong>으로 업데이트하세요. | 카드 이미지를 클릭하면 슬라이드 쇼를 시작합니다.
|
||
</div>
|
||
<div id="preview-grid" class="preview-grid">
|
||
<div style="color:var(--muted);font-size:12px;text-align:center;padding:24px 0;grid-column:1/-1">PPTX 생성 후 프리뷰가 표시됩니다</div>
|
||
</div>
|
||
<div style="margin-top:14px;padding:8px 12px;border-top:1px solid var(--line);font-size:11px;color:var(--muted);line-height:1.7">
|
||
↑ 상단 <strong>1 · 2 · 3</strong> 단계 버튼을 클릭하면 이전 단계로 돌아갈 수 있습니다. 
|
||
논문 추가·변경 → <strong>1단계</strong>, PDF 처리 재실행 → <strong>2단계</strong>, 아웃라인 수정 → <strong>3단계</strong>.
|
||
</div>
|
||
</div>
|
||
<div class="bbar">
|
||
<span class="smsg" id="s4msg"></span><div class="sp"></div>
|
||
<button class="btn bg" onclick="reloadPreview()">↺ 새로고침</button>
|
||
<button class="btn bg" id="ss-launch" onclick="ssStart()" disabled>▶ 슬라이드 쇼</button>
|
||
<button class="btn bgreen" id="s4-regen" onclick="s4Regen()" style="display:none">🔄 PPTX 재생성</button>
|
||
<a id="dl-btn" class="btn bgreen" style="text-decoration:none;display:none" download>⬇ 다운로드</a>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- ── SETTINGS SIDEBAR ── -->
|
||
<div class="sidebar">
|
||
<div class="ptitle">설정</div>
|
||
<div class="scroll">
|
||
<div class="field">
|
||
<button class="btn bgreen" style="width:100%;font-size:12px;padding:7px 0" onclick="newProject()">+ 새 프로젝트</button>
|
||
</div>
|
||
<div class="field">
|
||
<label class="flabel">기존 프로젝트 열기</label>
|
||
<div style="display:flex;gap:5px">
|
||
<select id="cfg-open-proj" style="flex:1;background:var(--panel2);border:1px solid var(--line);border-radius:7px;padding:4px 6px;font-size:12px;color:var(--text);outline:none" onchange="onOpenProjChange(this.value)">
|
||
<option value="">— 선택 —</option>
|
||
</select>
|
||
<button class="btn bg" style="font-size:11px;padding:4px 8px;flex-shrink:0" onclick="loadProjList()" title="목록 새로고침">↺</button>
|
||
<button class="btn bred" style="font-size:11px;padding:4px 8px;flex-shrink:0" onclick="deleteSelectedProject()" title="선택한 프로젝트 폴더 삭제">🗑</button>
|
||
</div>
|
||
</div>
|
||
<div class="field">
|
||
<label class="flabel">모델 요청</label>
|
||
<textarea id="cfg-topic" placeholder="예: 상악동 거상술 최신 동향에 대해 치과의사 대상 학회 발표 자료를 만들어주세요. 임상 적용 위주로 구성하고 핵심 수치를 강조해주세요." style="width:100%;background:var(--panel2);border:1px solid var(--line);border-radius:7px;padding:7px 9px;font-size:12px;color:var(--text);resize:vertical;outline:none;line-height:1.6;min-height:72px;font-family:inherit" onfocus="this.style.borderColor='var(--brand)'" onblur="this.style.borderColor='var(--line)'"></textarea>
|
||
</div>
|
||
<div class="field">
|
||
<label class="flabel">프로젝트 폴더명</label>
|
||
<input type="text" id="cfg-project" placeholder="자동 (아웃라인 생성 후 채워짐)" style="width:100%;background:var(--panel2);border:1px solid var(--line);border-radius:7px;padding:5px 8px;font-size:12px;color:var(--text);outline:none;font-family:inherit" onfocus="this.style.borderColor='var(--brand)'" onblur="this.style.borderColor='var(--line)'">
|
||
</div>
|
||
<div class="field">
|
||
<label class="flabel">슬라이드 수</label>
|
||
<input type="number" id="cfg-slides" min="5" max="30" value="12">
|
||
</div>
|
||
<div class="field">
|
||
<label class="flabel">템플릿 <span id="tmpl-lbl" style="font-weight:400;text-transform:none;font-size:11px;margin-left:4px;color:var(--text)">creative</span></label>
|
||
<input type="hidden" id="cfg-tmpl" value="creative">
|
||
<div class="skin-grid" id="tmpl-grid" style="grid-template-columns:repeat(6,1fr)"></div>
|
||
</div>
|
||
<div class="field">
|
||
<label class="flabel">배경 스킨 <span id="skin-name-lbl" style="font-weight:400;text-transform:none;margin-left:4px"></span></label>
|
||
<div class="skin-grid" id="skin-grid"></div>
|
||
</div>
|
||
<div class="field">
|
||
<label class="flabel">정렬</label>
|
||
<select id="cfg-sort">
|
||
<option value="relevance">관련도순</option>
|
||
<option value="date">최신순</option>
|
||
</select>
|
||
</div>
|
||
<div class="field">
|
||
<label class="flabel">PDF 이미지 자동 추출</label>
|
||
<label style="display:flex;align-items:flex-start;gap:8px;font-size:12px;color:var(--text);cursor:pointer;line-height:1.5">
|
||
<input type="checkbox" id="cfg-auto-img" checked onchange="onAutoImgToggle()" style="accent-color:var(--brand);width:15px;height:15px;cursor:pointer;margin-top:1px;flex-shrink:0">
|
||
<span id="cfg-auto-img-lbl"></span>
|
||
</label>
|
||
</div>
|
||
<div class="field">
|
||
<label class="flabel">PDF 본문 텍스트 추출</label>
|
||
<label style="display:flex;align-items:flex-start;gap:8px;font-size:12px;color:var(--text);cursor:pointer;line-height:1.5">
|
||
<input type="checkbox" id="cfg-extract-txt" checked onchange="onExtractTxtToggle()" style="accent-color:var(--brand);width:15px;height:15px;cursor:pointer;margin-top:1px;flex-shrink:0">
|
||
<span id="cfg-extract-txt-lbl"></span>
|
||
</label>
|
||
</div>
|
||
<div class="field">
|
||
<label class="flabel">본문 자동 번역</label>
|
||
<label style="display:flex;align-items:flex-start;gap:8px;font-size:12px;color:var(--text);cursor:pointer;line-height:1.5">
|
||
<input type="checkbox" id="cfg-auto-txl" checked onchange="onAutoTxlToggle()" style="accent-color:var(--brand);width:15px;height:15px;cursor:pointer;margin-top:1px;flex-shrink:0">
|
||
<span id="cfg-auto-txl-lbl"></span>
|
||
</label>
|
||
</div>
|
||
<div class="field" style="margin-top:14px;border-top:1px solid var(--line);padding-top:12px">
|
||
<label class="flabel">선택된 논문</label>
|
||
<div id="sel-summary" style="font-size:10px;color:var(--muted)">없음</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Abstract modal -->
|
||
<div class="mbg" id="abg">
|
||
<div class="modal amodal">
|
||
<div class="amodal-hd">
|
||
<div class="amodal-title" id="am-title"></div>
|
||
<div class="amodal-meta" id="am-meta"></div>
|
||
</div>
|
||
<div class="atabs">
|
||
<button class="atab active" id="at-en" onclick="switchAbTab('en')">영문</button>
|
||
<button class="atab" id="at-ko" onclick="switchAbTab('ko')">한국어 번역</button>
|
||
</div>
|
||
<div class="amodal-body" id="am-body">로딩 중...</div>
|
||
<div class="amodal-ft">
|
||
<button class="btn bg" id="am-translate-btn" onclick="translateAbstract()">🌐 번역</button>
|
||
<button class="btn bg" onclick="closeAbModal()">닫기</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Slideshow overlay -->
|
||
<div id="slideshow">
|
||
<button class="ss-nav" id="ss-prev" onclick="ssPrev()">‹</button>
|
||
<button class="ss-nav" id="ss-nav-next" onclick="ssNext()">›</button>
|
||
<div class="ss-img-wrap"><img id="ss-img" src="" alt=""></div>
|
||
<div class="ss-bar">
|
||
<button class="ss-btn" onclick="ssPrev()">← 이전</button>
|
||
<span class="ss-counter" id="ss-counter">1 / 1</span>
|
||
<button class="ss-btn" onclick="ssNext()">다음 →</button>
|
||
<span class="ss-title" id="ss-title"></span>
|
||
<button class="ss-btn" onclick="ssToggleAuto()" id="ss-auto-btn">⏵ 자동</button>
|
||
<button class="ss-btn" onclick="ssClose()" style="margin-left:auto">✕ 닫기</button>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Image picker modal -->
|
||
<div class="mbg" id="ibg" style="z-index:600">
|
||
<div class="modal imodal">
|
||
<div class="imodal-hd">이미지 선택</div>
|
||
<div class="imodal-body"><div class="igrid" id="imodal-grid"></div></div>
|
||
<div class="imodal-ft">
|
||
<button class="btn bg" onclick="closeIModal()">취소</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- PDF Translation modal -->
|
||
<div class="mbg" id="txlbg" onclick="if(event.target===this)closeTxlModal()">
|
||
<div class="modal txlmodal" onclick="event.stopPropagation()">
|
||
<div class="txlmodal-hd">
|
||
<span style="font-size:14px">📄</span>
|
||
<span id="txl-title" style="font-size:13px;font-weight:700;flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">전문</span>
|
||
<button class="btn bg" style="padding:3px 10px;font-size:11px" id="txl-tab-en" onclick="switchTxlTab('en')">원문</button>
|
||
<button class="btn bg" style="padding:3px 10px;font-size:11px" id="txl-tab-ko" onclick="switchTxlTab('ko')">번역</button>
|
||
<button class="btn bg" style="padding:3px 10px;font-size:11px" onclick="copyTxlText()">복사</button>
|
||
<button class="btn bg" style="padding:3px 10px;font-size:11px" onclick="closeTxlModal()">✕</button>
|
||
</div>
|
||
<div class="txlmodal-body" id="txl-body"></div>
|
||
<div class="txlmodal-ft">
|
||
<span id="txl-status" style="font-size:11px;color:var(--muted);flex:1"></span>
|
||
<button class="btn bg" onclick="closeTxlModal()">닫기</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Image lightbox -->
|
||
<div class="mbg" id="lbg" onclick="closeImgPreview()" style="z-index:400">
|
||
<div style="position:relative;max-width:90vw;max-height:90vh;display:flex;flex-direction:column;gap:8px" onclick="event.stopPropagation()">
|
||
<div style="display:flex;justify-content:space-between;align-items:center">
|
||
<span id="lb-label" style="color:rgba(255,255,255,.7);font-size:12px;max-width:80vw;overflow:hidden;text-overflow:ellipsis;white-space:nowrap"></span>
|
||
<button onclick="closeImgPreview()" style="background:rgba(255,255,255,.15);border:none;color:#fff;border-radius:6px;padding:4px 10px;cursor:pointer;font-size:13px;margin-left:12px">✕</button>
|
||
</div>
|
||
<img id="lb-img" src="" style="max-width:88vw;max-height:82vh;border-radius:8px;display:block;box-shadow:0 8px 32px rgba(0,0,0,.6)">
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Slide Editor Modal -->
|
||
<div class="mbg" id="sedbg" onclick="if(event.target===this)closeSed()" style="padding:0;align-items:center;justify-content:center">
|
||
<div class="modal sedmodal" onclick="event.stopPropagation()">
|
||
<div class="sed-hd">
|
||
<div class="snum" id="sed-num">1</div>
|
||
<span class="stype-tag" id="sed-type"></span>
|
||
<span id="sed-title-lbl" style="flex:1;font-size:14px;font-weight:600;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;margin:0 10px;color:var(--text)"></span>
|
||
<span class="sed-nav-info"><span id="sed-cur">1</span> / <span id="sed-total">1</span></span>
|
||
<button class="btn bg" style="padding:5px 12px" onclick="sedNav(-1)">← 이전</button>
|
||
<button class="btn bg" style="padding:5px 12px" onclick="sedNav(1)">다음 →</button>
|
||
<button class="btn bg" style="padding:5px 12px" onclick="closeSed()">✕ 닫기</button>
|
||
</div>
|
||
<div class="sed-body">
|
||
<!-- Controls -->
|
||
<div class="sed-ctrl">
|
||
<!-- Layout -->
|
||
<div id="sed-layout-sec">
|
||
<span class="sed-section-lbl">레이아웃</span>
|
||
<div class="layout-grid">
|
||
<div class="layout-btn" data-layout="split-right" onclick="sedSetLayout('split-right')"><span class="li">T|I</span><span class="lt">텍스트+이미지</span></div>
|
||
<div class="layout-btn" data-layout="split-left" onclick="sedSetLayout('split-left')"><span class="li">I|T</span><span class="lt">이미지+텍스트</span></div>
|
||
<div class="layout-btn" data-layout="text" onclick="sedSetLayout('text')"><span class="li">T</span><span class="lt">텍스트만</span></div>
|
||
<div class="layout-btn" data-layout="fullscreen" onclick="sedSetLayout('fullscreen')"><span class="li">[I]</span><span class="lt">전체이미지</span></div>
|
||
<div class="layout-btn" data-layout="compare" onclick="sedSetLayout('compare')"><span class="li">||</span><span class="lt">2단비교</span></div>
|
||
</div>
|
||
</div>
|
||
<!-- Title -->
|
||
<div>
|
||
<span class="sed-section-lbl">제목</span>
|
||
<textarea class="sed-inp" id="sed-title-inp" oninput="sedChange()" onkeydown="setTimeout(sedRenderPreview,0)" placeholder="슬라이드 제목" rows="2"></textarea>
|
||
<div class="sed-row">
|
||
<span style="font-size:11px;color:var(--muted)">제목 크기</span>
|
||
<input type="range" class="sed-range" id="sed-title-size" min="14" max="52" step="1" oninput="sedChange()">
|
||
<span class="sed-val" id="sed-title-size-val">28pt</span>
|
||
</div>
|
||
</div>
|
||
<!-- Subtitle (title-type only) -->
|
||
<div id="sed-subtitle-sec">
|
||
<span class="sed-section-lbl">부제목</span>
|
||
<textarea class="sed-inp" id="sed-subtitle-inp" oninput="sedChange()" onkeydown="setTimeout(sedRenderPreview,0)" placeholder="부제목" rows="2"></textarea>
|
||
</div>
|
||
<!-- Bullets -->
|
||
<div id="sed-bullets-sec">
|
||
<span class="sed-section-lbl">내용</span>
|
||
<textarea class="sed-ta" id="sed-bullets-ta" oninput="sedChange()" onkeydown="setTimeout(sedRenderPreview,0)"></textarea>
|
||
<div class="sed-hint">
|
||
<span>Enter = 같은 항목 줄바꿈</span>
|
||
<span>빈 줄 = 새 항목 ●</span>
|
||
<span>- 시작 = 하위 항목 –</span>
|
||
</div>
|
||
<div class="sed-row">
|
||
<span style="font-size:11px;color:var(--muted)">본문 크기</span>
|
||
<input type="range" class="sed-range" id="sed-body-size" min="10" max="36" step="1" oninput="sedChange()">
|
||
<span class="sed-val" id="sed-body-size-val">18pt</span>
|
||
</div>
|
||
</div>
|
||
<!-- Image -->
|
||
<div id="sed-img-sec">
|
||
<span class="sed-section-lbl">이미지</span>
|
||
<div class="sed-img-thumb" id="sed-img-thumb" onclick="sedPickImg()">🖼</div>
|
||
<input type="text" class="sed-inp" id="sed-img-url-inp" placeholder="URL 직접 붙여넣기" style="margin-top:5px;font-size:11px" oninput="sedImgUrlInput()">
|
||
<div style="display:flex;gap:6px;margin-top:5px">
|
||
<button class="btn bg" style="flex:1;font-size:12px" onclick="sedPickImg()">🖼 선택</button>
|
||
<button class="btn bp" style="flex:1;font-size:12px" onclick="sedAutoExtract()">📷 자동추출</button>
|
||
<button class="btn bg" style="font-size:12px;color:var(--red)" onclick="sedClearImg()">✕</button>
|
||
</div>
|
||
<button class="btn bg" style="width:100%;font-size:11px;margin-top:4px;color:var(--muted)" onclick="sedOpenPdfPicker()">✂ 수동 캡처 (PDF 드래그)</button>
|
||
<div class="sed-row" style="margin-top:8px">
|
||
<span style="font-size:11px;color:var(--muted)">이미지 크기</span>
|
||
<input type="range" class="sed-range" id="sed-img-scale" min="20" max="80" step="5" oninput="sedChange()">
|
||
<span class="sed-val" id="sed-img-scale-val">50%</span>
|
||
</div>
|
||
</div>
|
||
<!-- Font -->
|
||
<div>
|
||
<span class="sed-section-lbl">폰트</span>
|
||
<div class="sed-row" style="margin-top:2px">
|
||
<span style="font-size:11px;color:var(--muted);min-width:36px">패밀리</span>
|
||
<select id="sed-font-family" style="flex:1;background:var(--panel);border:1px solid var(--line);border-radius:6px;padding:3px 6px;font-size:12px;color:var(--text)" onchange="sedChange()">
|
||
<option value="">기본 (Calibri/맑은고딕)</option>
|
||
<option value="Malgun Gothic">맑은 고딕</option>
|
||
<option value="NanumGothic">나눔고딕</option>
|
||
<option value="Arial">Arial</option>
|
||
<option value="Calibri">Calibri</option>
|
||
<option value="Georgia">Georgia</option>
|
||
<option value="Times New Roman">Times New Roman</option>
|
||
</select>
|
||
</div>
|
||
<div class="clr-row">
|
||
<label>제목색</label>
|
||
<input type="color" id="sed-title-color" value="#1a1a2e" oninput="sedChange()">
|
||
<button class="clr-reset" onclick="sedResetColor('title')">초기화</button>
|
||
</div>
|
||
<div class="clr-row">
|
||
<label>본문색</label>
|
||
<input type="color" id="sed-body-color" value="#2d3748" oninput="sedChange()">
|
||
<button class="clr-reset" onclick="sedResetColor('body')">초기화</button>
|
||
</div>
|
||
</div>
|
||
<!-- Image FX -->
|
||
<div id="sed-img-fx-sec">
|
||
<span class="sed-section-lbl">이미지 효과</span>
|
||
<div style="display:flex;gap:4px;flex-wrap:wrap;margin-top:4px">
|
||
<button class="fx-btn active" data-fx="" onclick="sedSetFx('')">원본</button>
|
||
<button class="fx-btn" data-fx="blur" onclick="sedSetFx('blur')">흐리게</button>
|
||
<button class="fx-btn" data-fx="grayscale" onclick="sedSetFx('grayscale')">흑백</button>
|
||
<button class="fx-btn" data-fx="contrast" onclick="sedSetFx('contrast')">대비↑</button>
|
||
<button class="fx-btn" data-fx="bright" onclick="sedSetFx('bright')">밝기↑</button>
|
||
<button class="fx-btn" data-fx="round" onclick="sedSetFx('round')">모서리</button>
|
||
</div>
|
||
<div id="sed-fx-blur-row" class="sed-row" style="display:none;margin-top:6px">
|
||
<span style="font-size:11px;color:var(--muted)">강도</span>
|
||
<input type="range" class="sed-range" id="sed-fx-blur" min="1" max="20" value="5" oninput="sedFxSlider()">
|
||
<span class="sed-val" id="sed-fx-blur-val">5</span>
|
||
</div>
|
||
<div id="sed-fx-round-row" class="sed-row" style="display:none;margin-top:6px">
|
||
<span style="font-size:11px;color:var(--muted)">반경</span>
|
||
<input type="range" class="sed-range" id="sed-fx-radius" min="5" max="50" value="15" oninput="sedFxSlider()">
|
||
<span class="sed-val" id="sed-fx-radius-val">15%</span>
|
||
</div>
|
||
</div>
|
||
<!-- Background -->
|
||
<div>
|
||
<span class="sed-section-lbl">배경 <span id="sed-skin-lbl" style="font-weight:400;text-transform:none;font-size:11px;margin-left:4px;color:var(--text)"></span></span>
|
||
<div class="sed-skin-grid" id="sed-skin-grid"></div>
|
||
<div style="display:flex;gap:5px;align-items:center;margin-top:6px">
|
||
<div id="sed-bg-img-thumb" style="width:38px;height:26px;border:1px solid var(--line);border-radius:3px;background-size:cover;background-position:center;flex-shrink:0;font-size:10px;display:flex;align-items:center;justify-content:center;color:var(--muted)">🖼</div>
|
||
<button class="pickbtn" style="font-size:11px;padding:3px 8px" onclick="sedPickBgImg()">이미지 배경</button>
|
||
<button class="pickbtn" id="sed-bg-img-clear" style="font-size:11px;padding:3px 8px;color:var(--red);display:none" onclick="sedClearBgImg()">✕</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<!-- Preview Stage -->
|
||
<div class="sed-stage" id="sed-stage">
|
||
<div class="sed-preview" id="sed-preview"></div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- PDF Capture modal -->
|
||
<div class="mbg" id="pdfbg" onclick="if(event.target===this)closePdfModal()">
|
||
<div class="modal pdfmodal" onclick="event.stopPropagation()">
|
||
<div class="pdfmodal-hd">
|
||
<span style="font-size:13px;font-weight:700">📄 PDF 캡처</span>
|
||
<span id="pdf-modal-name" style="font-size:11px;color:var(--muted);flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap"></span>
|
||
<button class="btn bg" style="padding:4px 10px;font-size:11px" onclick="pdfPrevPage()">◀</button>
|
||
<span id="pdf-page-lbl" style="font-size:12px;color:var(--muted);min-width:60px;text-align:center">1 / 1</span>
|
||
<button class="btn bg" style="padding:4px 10px;font-size:11px" onclick="pdfNextPage()">▶</button>
|
||
<button class="btn bg" style="padding:4px 10px;font-size:11px" onclick="pdfZoomStep(-1)">-</button>
|
||
<span id="pdf-zoom-lbl" style="font-size:12px;color:var(--muted);min-width:42px;text-align:center;cursor:pointer" title="클릭하여 초기화" onclick="pdfZoomReset()">100%</span>
|
||
<button class="btn bg" style="padding:4px 10px;font-size:11px" onclick="pdfZoomStep(1)">+</button>
|
||
<button class="btn bg" style="padding:4px 10px;font-size:11px" onclick="closePdfModal()">✕</button>
|
||
</div>
|
||
<div class="pdfmodal-body" id="pdfmodal-body">
|
||
<div id="pdf-canvas-wrap">
|
||
<canvas id="pdf-main-canvas"></canvas>
|
||
</div>
|
||
<div id="pdf-load-msg" style="position:absolute;top:50%;left:50%;transform:translate(-50%,-50%);color:#aaa;font-size:14px">페이지 로딩 중...</div>
|
||
</div>
|
||
<div class="pdfmodal-ft">
|
||
<span style="font-size:11px;color:var(--muted)">드래그로 영역 선택 → 캡처</span>
|
||
<div style="flex:1"></div>
|
||
<span id="pdf-sel-info" style="font-size:11px;color:var(--brand2);margin-right:8px"></span>
|
||
<button class="btn bg" id="pdf-capture-btn" onclick="pdfCapture()" disabled>📷 캡처</button>
|
||
<button class="btn bg" onclick="pdfClearSel()">✕ 선택 해제</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<script>
|
||
'use strict';
|
||
|
||
// ── Theme ────────────────────────────────────────────────────────────────────
|
||
function setTheme(name) {
|
||
document.body.classList.remove('theme-light','theme-warm');
|
||
if(name==='light') document.body.classList.add('theme-light');
|
||
if(name==='warm') document.body.classList.add('theme-warm');
|
||
document.querySelectorAll('.theme-btn').forEach(b=>b.classList.toggle('active',b.id==='tbtn-'+name));
|
||
localStorage.setItem('pptx-wizard-theme',name);
|
||
}
|
||
(function(){ setTheme(localStorage.getItem('pptx-wizard-theme')||'dark'); })();
|
||
|
||
// 브라우저 기본 파일 드롭(새 탭 열기) 전역 차단
|
||
window.addEventListener('dragover', e => e.preventDefault(), false);
|
||
window.addEventListener('drop', e => e.preventDefault(), false);
|
||
|
||
// ── skin definitions ────────────────────────────────────────────────────────
|
||
const SKINS = [
|
||
{v:'navy', label:'Navy', c:'#1d4ed8'},
|
||
{v:'midnight', label:'Midnight', c:'#4c1d95'},
|
||
{v:'charcoal', label:'Charcoal', c:'#4b5563'},
|
||
{v:'slate', label:'Slate', c:'#2d6a8f'},
|
||
{v:'cream', label:'Cream', c:'#fffbeb'},
|
||
{v:'sand', label:'Sand', c:'#fef3c7'},
|
||
{v:'sky', label:'Sky', c:'#e0f2fe'},
|
||
{v:'peach', label:'Peach', c:'#fff1f0'},
|
||
{v:'coral', label:'Coral', c:'#ff6b6b'},
|
||
{v:'teal', label:'Teal', c:'#0d9488'},
|
||
{v:'emerald', label:'Emerald', c:'#059669'},
|
||
{v:'plum', label:'Plum', c:'#7c3aed'},
|
||
];
|
||
|
||
// ── state ───────────────────────────────────────────────────────────────────
|
||
let papers = [], selected = new Set(), paperMeta = {};
|
||
function updSrcState(){
|
||
// 최소 하나는 선택 유지
|
||
const checks=[...document.querySelectorAll('.src-chk input')];
|
||
if(checks.every(c=>!c.checked)) checks[0].checked=true;
|
||
}
|
||
let extractedImages = [], paperTexts = {}, imageCaptions = {};
|
||
let prepLog = {}, outline = null, outlineEdits = {}, currentProcessingKey = null;
|
||
let pptxRelPath = null, previewImages = [];
|
||
let currentStep = 1, abTabMode = 'en', modalSlide = -1;
|
||
let selectedSkin = 'cream';
|
||
let selectedSlide = -1;
|
||
let paperTranslations = {}, txlStatus = {};
|
||
|
||
function paperKey(p) {
|
||
if(p._isUpload) return 'UPL_'+(p._localPdf||'').split('/').pop();
|
||
if(p.pmcid) return 'PMC'+String(p.pmcid).replace(/^PMC/i,'');
|
||
if(p.pmid) return 'PMID'+String(p.pmid);
|
||
if(p.doi) return 'DOI_'+p.doi.toLowerCase().trim();
|
||
return null;
|
||
}
|
||
function paperLabel(key) {
|
||
if(key.startsWith('UPL_')) return key.slice(4);
|
||
const p=papers.find(x=>paperKey(x)===key);
|
||
if(p&&p.title) return p.title.slice(0,60)+(p.title.length>60?'…':'');
|
||
return key;
|
||
}
|
||
|
||
// ── Save outline to project folder ──────────────────────────────────────────
|
||
async function saveOutlineToProject() {
|
||
if (!outline) return;
|
||
const project = resolveProjectSlug();
|
||
if (!project) return;
|
||
try {
|
||
await fetch('/api/pptx/save-outline', {
|
||
...jh(), method: 'POST',
|
||
body: JSON.stringify({ project, outline })
|
||
});
|
||
} catch (e) { console.warn('[wizard] save-outline failed:', e.message); }
|
||
}
|
||
|
||
// ── Save papers to project (PDF paths, extracted texts, translations) ──────
|
||
async function savePapersToProject() {
|
||
const project = resolveProjectSlug();
|
||
if (!project) return;
|
||
const paperEntries = [];
|
||
const translations = {};
|
||
for (const key of selected) {
|
||
const p = papers.find(x => paperKey(x) === key);
|
||
if (!p) continue;
|
||
const isUpl = key.startsWith('UPL_');
|
||
const entry = {
|
||
key,
|
||
title: p.title || (isUpl ? key.slice(4) : ''),
|
||
authors: p.authors || '',
|
||
year: p.year || '',
|
||
journal: p.journal || '',
|
||
doi: p.doi || '',
|
||
pmcid: p.pmcid || null,
|
||
pmid: p.pmid || null,
|
||
_source: isUpl ? 'upload' : (p._source || 'pubmed'),
|
||
abstract: (paperMeta[key]?.abstract) || p.abstract || '',
|
||
pdfPath: prepLog[key]?.pdfPath || null,
|
||
txtPath: null,
|
||
koPath: null,
|
||
};
|
||
// Determine txt/ko paths from pdfPath or key
|
||
if (entry.pdfPath) {
|
||
const baseName = entry.pdfPath.replace(/\.pdf$/i, '');
|
||
if (prepLog[key]?.txt === 'done') entry.txtPath = baseName + '.txt';
|
||
if (paperTranslations[key] && entry.txtPath) {
|
||
entry.koPath = entry.txtPath.replace(/\.txt$/, '.ko.txt');
|
||
translations[key] = paperTranslations[key];
|
||
}
|
||
} else if (prepLog[key]?.txt === 'done') {
|
||
// No PDF but text exists (e.g. abstract-only fallback)
|
||
entry.txtPath = project + '/' + key.replace(/[^a-zA-Z0-9_\-]/g, '_') + '.txt';
|
||
if (paperTranslations[key]) {
|
||
entry.koPath = entry.txtPath.replace(/\.txt$/, '.ko.txt');
|
||
translations[key] = paperTranslations[key];
|
||
}
|
||
}
|
||
paperEntries.push(entry);
|
||
}
|
||
if (!paperEntries.length) return;
|
||
try {
|
||
await fetch('/api/pptx/save-papers', {
|
||
...jh(), method: 'POST',
|
||
body: JSON.stringify({ project, papers: paperEntries, translations })
|
||
});
|
||
loadProjList();
|
||
} catch (e) { console.warn('[wizard] save-papers failed:', e.message); }
|
||
}
|
||
|
||
// ── New / Open existing project ──────────────────────────────────────────────
|
||
function newProject(){
|
||
papers=[]; selected=new Set(); paperMeta={};
|
||
paperTexts={}; paperTranslations={}; txlStatus={};
|
||
prepLog={}; extractedImages={}; imageCaptions={}; abPaper=null;
|
||
outline=null; outlineEdits={}; pptxRelPath=null; previewImages=[];
|
||
document.querySelectorAll('.preview-card[data-edited]').forEach(c=>delete c.dataset.edited);
|
||
const sel=document.getElementById('cfg-open-proj'); if(sel) sel.value='';
|
||
const dlBtn=document.getElementById('dl-btn'); if(dlBtn) dlBtn.style.display='none';
|
||
const regenBtn=document.getElementById('s4-regen'); if(regenBtn) regenBtn.style.display='none';
|
||
document.getElementById('cfg-project').value='';
|
||
document.getElementById('cfg-topic').value='';
|
||
localStorage.removeItem('pptx-session');
|
||
renderPapers(); renderProgList(); renderOutline();
|
||
goStep(1);
|
||
}
|
||
async function deleteSelectedProject(){
|
||
const sel=document.getElementById('cfg-open-proj');
|
||
const relPath=sel?.value||'';
|
||
if(!relPath){ alert('삭제할 프로젝트를 먼저 선택하세요.'); return; }
|
||
const _dp=relPath.replace(/\\/g,'/').split('/');
|
||
const project=relPath.endsWith('.pptx')&&_dp.length>=3 ? _dp[1] : _dp[0];
|
||
if(!confirm(`프로젝트 폴더 "${project}" 를 완전히 삭제하시겠습니까?\n이 작업은 되돌릴 수 없습니다.`)) return;
|
||
try{
|
||
const r=await fetch('/api/pptx/delete-project',{...jh(),method:'DELETE',body:JSON.stringify({project})});
|
||
const d=await r.json();
|
||
if(!d.success) throw new Error(d.error||'삭제 실패');
|
||
await loadProjList();
|
||
sel.value='';
|
||
alert(`"${project}" 삭제 완료.`);
|
||
}catch(e){ alert('삭제 오류: '+e.message); }
|
||
}
|
||
|
||
async function loadProjList(){
|
||
const sel=document.getElementById('cfg-open-proj'); if(!sel)return;
|
||
sel.innerHTML='<option value="">로딩 중...</option>';
|
||
try{
|
||
const r=await fetch('/api/pptx/list',cr()); const d=await r.json();
|
||
const items=d.items||[];
|
||
sel.innerHTML='<option value="">— 선택 —</option>'
|
||
+items.map(it=>`<option value="${esc(it.relPath)}">${esc(it.project)}${it.hasPptx?' / '+esc(it.filename):' 📂'}</option>`).join('');
|
||
}catch(e){ sel.innerHTML='<option value="">오류: '+esc(e.message)+'</option>'; }
|
||
}
|
||
async function onOpenProjChange(relPath){
|
||
if(!relPath)return;
|
||
const parts=relPath.replace(/\\/g,'/').split('/');
|
||
const hasPptx=relPath.endsWith('.pptx');
|
||
// PPTX relPath = "pptx/{slug}/{file}.pptx" → parts[1], no-PPTX relPath = "{slug}" → parts[0]
|
||
const project=(hasPptx && parts.length>=3) ? parts[1] : parts[0];
|
||
const projInp=document.getElementById('cfg-project'); if(projInp) projInp.value=project;
|
||
const dlBtn=document.getElementById('dl-btn');
|
||
const regenBtn=document.getElementById('s4-regen');
|
||
// 이전 편집/논문 상태 초기화
|
||
outlineEdits={};
|
||
document.querySelectorAll('.preview-card[data-edited]').forEach(c=>delete c.dataset.edited);
|
||
papers=[]; selected=new Set(); paperMeta={};
|
||
paperTexts={}; paperTranslations={}; txlStatus={};
|
||
prepLog={}; extractedImages=[]; imageCaptions={}; abPaper=null;
|
||
|
||
// PPTX 없는 프로젝트 — step 1로 이동해서 논문 목록 확인
|
||
if(!hasPptx){
|
||
pptxRelPath=null;
|
||
dlBtn.style.display='none';
|
||
if(regenBtn) regenBtn.style.display='none';
|
||
await loadExistingProjectPdfs();
|
||
goStep(1);
|
||
return;
|
||
}
|
||
|
||
pptxRelPath=relPath;
|
||
const fileUrl='/api/files/'+relPath.split('/').map(s=>encodeURIComponent(s)).join('/');
|
||
dlBtn.href=fileUrl; dlBtn.style.display='inline-flex'; dlBtn.download=parts[parts.length-1];
|
||
if(regenBtn) regenBtn.style.display='';
|
||
|
||
// Reload saved papers for this project
|
||
let hasLoadedPapers = false;
|
||
try {
|
||
const pr = await fetch('/api/pptx/project-papers?project=' + encodeURIComponent(project), cr());
|
||
const pd = await pr.json();
|
||
if (pd.success && pd.papers && pd.papers.length) {
|
||
// Restore papers array (excluding uploads which are session-only)
|
||
papers = pd.papers.map(pe => ({
|
||
title: pe.title || '',
|
||
authors: pe.authors || '',
|
||
year: pe.year || '',
|
||
journal: pe.journal || '',
|
||
doi: pe.doi || '',
|
||
pmcid: pe.pmcid || null,
|
||
pmid: pe.pmid || null,
|
||
_source: pe._source || 'pubmed',
|
||
abstract: pe.abstract || '',
|
||
_fromSaved: true,
|
||
}));
|
||
// Restore selection
|
||
selected = new Set(pd.papers.map(pe => pe.key));
|
||
// Restore paperTexts and paperTranslations from saved files
|
||
for (const pe of pd.papers) {
|
||
if (pe.txtPath && pe.hasText) {
|
||
try {
|
||
const tr = await fetch('/api/files/' + pe.txtPath.split('/').map(s => encodeURIComponent(s)).join('/'), cr());
|
||
if (tr.ok) paperTexts[pe.key] = await tr.text();
|
||
} catch {}
|
||
}
|
||
if (pe.koPath && pe.hasTranslation) {
|
||
try {
|
||
const kr = await fetch('/api/files/' + pe.koPath.split('/').map(s => encodeURIComponent(s)).join('/'), cr());
|
||
if (kr.ok) paperTranslations[pe.key] = await kr.text();
|
||
} catch {}
|
||
}
|
||
if (pe.abstract) {
|
||
paperMeta[pe.key] = { ...(paperMeta[pe.key] || {}), abstract: pe.abstract };
|
||
}
|
||
// Set prepLog so the prep UI shows completed state
|
||
prepLog[pe.key] = {
|
||
pdf: pe.pdfPath ? 'done' : 'skip',
|
||
img: 'skip',
|
||
txt: pe.txtPath && pe.hasText ? 'done' : (pe.abstract ? 'done' : 'skip'),
|
||
pdfPath: pe.pdfPath || null,
|
||
};
|
||
if (pe.koPath && pe.hasTranslation) txlStatus[pe.key] = 'done';
|
||
}
|
||
renderPapers();
|
||
updSelUI();
|
||
renderProgList();
|
||
hasLoadedPapers = true;
|
||
}
|
||
} catch (e) { console.warn('[wizard] project-papers load failed:', e.message); }
|
||
|
||
// 아웃라인 역추출 — PPTX → outline.json 순서로 시도
|
||
try{
|
||
const r=await fetch('/api/pptx/read-outline?path='+encodeURIComponent(relPath),cr());
|
||
const d=await r.json();
|
||
if(d.success&&d.outline){
|
||
outline=d.outline;
|
||
if(outline.slides) outline.slides=cleanSlideTexts(outline.slides);
|
||
const skin=d.outline.background||'cream';
|
||
selectedSkin=skin;
|
||
document.querySelectorAll('.sed-skin-sw').forEach(sw=>sw.classList.toggle('active',sw.dataset.v===skin));
|
||
renderOutline();
|
||
document.getElementById('s3create').disabled=false;
|
||
}
|
||
}catch(e){ console.warn('[wizard] outline read failed:',e.message); }
|
||
// PPTX에서 못 읽었으면 outline.json 시도
|
||
if(!outline){
|
||
try{
|
||
const proj=resolveProjectSlug();
|
||
const ojPath='pptx/'+proj+'/outline.json';
|
||
const r=await fetch('/api/files/'+ojPath.split('/').map(encodeURIComponent).join('/'),cr());
|
||
if(r.ok){
|
||
const d=await r.json();
|
||
if(d && d.slides){
|
||
outline=d;
|
||
if(outline.slides) outline.slides=cleanSlideTexts(outline.slides);
|
||
const skin=outline.background||'cream';
|
||
selectedSkin=skin;
|
||
document.querySelectorAll('.sed-skin-sw').forEach(sw=>sw.classList.toggle('active',sw.dataset.v===skin));
|
||
renderOutline();
|
||
document.getElementById('s3create').disabled=false;
|
||
}
|
||
}
|
||
}catch{}
|
||
}
|
||
|
||
// 디스크 PDF도 로드 시도 (papers.json 없는 프로젝트 포함)
|
||
await loadExistingProjectPdfs();
|
||
|
||
if (hasLoadedPapers || papers.length > 0) {
|
||
goStep(1);
|
||
loadPreview(relPath); // 백그라운드로 프리뷰도 미리 로드
|
||
} else {
|
||
goStep(4);
|
||
await loadPreview(relPath);
|
||
}
|
||
}
|
||
(function(){
|
||
loadProjList();
|
||
// 이전 세션 복원 (새로고침 후에도 outline/편집 내용 유지)
|
||
try{
|
||
const saved=JSON.parse(localStorage.getItem('pptx-session')||'null');
|
||
// 24시간 이내 세션만 복원
|
||
if(saved && saved.outline && saved.pptxRelPath && (Date.now()-saved.ts)<86400000){
|
||
outline=saved.outline;
|
||
pptxRelPath=saved.pptxRelPath;
|
||
if(saved.skin){ selectedSkin=saved.skin; }
|
||
const fileUrl='/api/files/'+saved.pptxRelPath.split('/').map(encodeURIComponent).join('/');
|
||
const dlBtn=document.getElementById('dl-btn');
|
||
if(dlBtn){ dlBtn.href=fileUrl; dlBtn.style.display='inline-flex'; dlBtn.download=saved.pptxRelPath.split('/').pop()||'slides.pptx'; }
|
||
const regenBtn=document.getElementById('s4-regen'); if(regenBtn) regenBtn.style.display='';
|
||
document.getElementById('s3create').disabled=false;
|
||
renderOutline();
|
||
goStep(4);
|
||
loadPreview(saved.pptxRelPath);
|
||
}
|
||
}catch(_){}
|
||
})();
|
||
|
||
// ── init ────────────────────────────────────────────────────────────────────
|
||
const TMPLS = [
|
||
{v:'dark', label:'Dark', bg:'#1F242D', accent:'#4C8DFF'},
|
||
{v:'business', label:'Business', bg:'#FFFFFF', accent:'#1668E3'},
|
||
{v:'minimal', label:'Minimal', bg:'#F8F8F8', accent:'#888888'},
|
||
{v:'creative', label:'Creative', bg:'#FFFFFF', accent:'#E53E3E'},
|
||
{v:'pastel', label:'Pastel', bg:'#FAF5FF', accent:'#9F7AEA'},
|
||
{v:'warm', label:'Warm', bg:'#FFFBF0', accent:'#D97706'},
|
||
];
|
||
let selectedTmpl = 'creative';
|
||
(function initTmplGrid() {
|
||
const g = document.getElementById('tmpl-grid'); if(!g) return;
|
||
TMPLS.forEach(t => {
|
||
const d = document.createElement('div');
|
||
d.className = 'skin-sw' + (t.v === selectedTmpl ? ' active' : '');
|
||
d.title = t.label;
|
||
d.dataset.v = t.v;
|
||
// 위: 배경색, 아래 1/4: 포인트색 바
|
||
d.style.cssText = `background:${t.bg};border:2px solid ${t.v===selectedTmpl?'var(--brand)':'transparent'};position:relative;overflow:hidden;`;
|
||
const bar = document.createElement('div');
|
||
bar.style.cssText = `position:absolute;bottom:0;left:0;right:0;height:30%;background:${t.accent};`;
|
||
d.appendChild(bar);
|
||
// 라벨
|
||
const lbl = document.createElement('div');
|
||
lbl.style.cssText = `position:absolute;top:2px;left:0;right:0;text-align:center;font-size:8px;font-weight:700;color:${t.bg==='#FFFFFF'||t.bg==='#F8F8F8'||t.bg==='#FAF5FF'||t.bg==='#FFFBF0'?'#333':'#eee'};`;
|
||
lbl.textContent = t.label;
|
||
d.appendChild(lbl);
|
||
d.onclick = () => selectTmpl(t.v);
|
||
g.appendChild(d);
|
||
});
|
||
})();
|
||
function selectTmpl(v) {
|
||
selectedTmpl = v;
|
||
document.getElementById('cfg-tmpl').value = v;
|
||
document.querySelectorAll('#tmpl-grid .skin-sw').forEach(el => {
|
||
const t = TMPLS.find(x => x.v === el.dataset.v);
|
||
el.classList.toggle('active', el.dataset.v === v);
|
||
el.style.borderColor = el.dataset.v === v ? 'var(--brand)' : 'transparent';
|
||
});
|
||
const t = TMPLS.find(x => x.v === v);
|
||
if(t) document.getElementById('tmpl-lbl').textContent = t.label;
|
||
}
|
||
|
||
(function initSkinGrid() {
|
||
const g = document.getElementById('skin-grid');
|
||
SKINS.forEach(s => {
|
||
const d = document.createElement('div');
|
||
d.className = 'skin-sw' + (s.v === selectedSkin ? ' active' : '');
|
||
d.style.background = s.c;
|
||
d.title = s.label;
|
||
d.dataset.v = s.v;
|
||
d.onclick = () => selectSkin(s.v);
|
||
g.appendChild(d);
|
||
});
|
||
const initSk = SKINS.find(s => s.v === selectedSkin);
|
||
if(initSk) document.getElementById('skin-name-lbl').textContent = initSk.label;
|
||
})();
|
||
|
||
function selectSkin(v) {
|
||
selectedSkin = v;
|
||
document.querySelectorAll('.skin-sw').forEach(el => el.classList.toggle('active', el.dataset.v === v));
|
||
const sk = SKINS.find(s => s.v === v);
|
||
document.getElementById('skin-name-lbl').textContent = sk ? sk.label : '';
|
||
}
|
||
|
||
// ── PDF 이미지 자동 추출 토글 ──────────────────────────────────────────────────
|
||
let autoExtractImages = localStorage.getItem('pptx-auto-img') !== 'off';
|
||
function updAutoImgLbl() {
|
||
const l = document.getElementById('cfg-auto-img-lbl'); if(!l) return;
|
||
l.textContent = autoExtractImages ? '켜짐 — 준비 단계에서 그림·도표 자동 추출' : '꺼짐 — 수동 캡처/추출만 사용';
|
||
}
|
||
function onAutoImgToggle() {
|
||
autoExtractImages = document.getElementById('cfg-auto-img').checked;
|
||
localStorage.setItem('pptx-auto-img', autoExtractImages ? 'on' : 'off');
|
||
updAutoImgLbl();
|
||
}
|
||
(function initAutoImg(){
|
||
const cb = document.getElementById('cfg-auto-img');
|
||
if(cb) cb.checked = autoExtractImages;
|
||
updAutoImgLbl();
|
||
})();
|
||
|
||
// ── PDF 텍스트 추출 토글 ───────────────────────────────────────────────────────
|
||
let extractText = localStorage.getItem('pptx-extract-txt') !== 'off';
|
||
function updExtractTxtLbl() {
|
||
const l = document.getElementById('cfg-extract-txt-lbl'); if(!l) return;
|
||
l.textContent = extractText ? '켜짐 — 본문을 추출해 아웃라인 근거로 사용' : '꺼짐 — 본문 추출 생략';
|
||
}
|
||
function onExtractTxtToggle() {
|
||
extractText = document.getElementById('cfg-extract-txt').checked;
|
||
localStorage.setItem('pptx-extract-txt', extractText ? 'on' : 'off');
|
||
updExtractTxtLbl();
|
||
}
|
||
(function initExtractTxt(){
|
||
const cb = document.getElementById('cfg-extract-txt');
|
||
if(cb) cb.checked = extractText;
|
||
updExtractTxtLbl();
|
||
})();
|
||
|
||
// ── 본문 자동 번역 토글 ────────────────────────────────────────────────────────
|
||
let autoTranslate = localStorage.getItem('pptx-auto-txl') !== 'off';
|
||
function updAutoTxlLbl() {
|
||
const l = document.getElementById('cfg-auto-txl-lbl'); if(!l) return;
|
||
l.textContent = autoTranslate ? '켜짐 — 추출한 본문을 한국어로 자동 번역' : '꺼짐 — 번역 안 함';
|
||
}
|
||
function onAutoTxlToggle() {
|
||
autoTranslate = document.getElementById('cfg-auto-txl').checked;
|
||
localStorage.setItem('pptx-auto-txl', autoTranslate ? 'on' : 'off');
|
||
updAutoTxlLbl();
|
||
}
|
||
(function initAutoTxl(){
|
||
const cb = document.getElementById('cfg-auto-txl');
|
||
if(cb) cb.checked = autoTranslate;
|
||
updAutoTxlLbl();
|
||
})();
|
||
|
||
// ── credentials ─────────────────────────────────────────────────────────────
|
||
function cr() { return {credentials:'include'}; }
|
||
function jh() { return {credentials:'include',headers:{'Content-Type':'application/json'}}; }
|
||
|
||
// ── utils ───────────────────────────────────────────────────────────────────
|
||
function esc(s){ return String(s||'').replace(/&/g,'&').replace(/\\/g,'\').replace(/`/g,'`').replace(/\$/g,'$').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"'); }
|
||
|
||
function cleanOoxmlEscapes(s){
|
||
if(typeof s!=='string') return s;
|
||
return s.replace(/_?x[0-9A-Fa-f]{4}_/g,' ').replace(/ +/g,' ').trim();
|
||
}
|
||
function cleanSlideTexts(slides){
|
||
const TEXT_FIELDS=['title','subtitle','body','content','notes'];
|
||
return (slides||[]).map(s=>{
|
||
const out=Object.assign({},s);
|
||
TEXT_FIELDS.forEach(f=>{ if(typeof out[f]==='string') out[f]=cleanOoxmlEscapes(out[f]); });
|
||
if(Array.isArray(out.bullets)) out.bullets=out.bullets.map(b=>typeof b==='string'?cleanOoxmlEscapes(b):b);
|
||
if(Array.isArray(out.bullet_points)) out.bullet_points=out.bullet_points.map(b=>typeof b==='string'?cleanOoxmlEscapes(b):b);
|
||
return out;
|
||
});
|
||
}
|
||
function errHtml(s){ return esc(s).replace(/(https?:\/\/[^\s&]+)/g,'<a href="$1" target="_blank" style="color:#f87171;word-break:break-all">$1</a>').replace(/\n/g,'<br>'); }
|
||
function setMsg(n, msg, c){ const e=document.getElementById('s'+n+'msg'); if(e){e.textContent=msg;e.style.color=c||'var(--muted)';} }
|
||
|
||
// 경과 시간 타이머: startTimer('s3msg', '생성 중', color) → stopFn
|
||
function startTimer(msgId, baseText, color) {
|
||
const t0=Date.now();
|
||
const el=document.getElementById(msgId); if(!el)return()=>{};
|
||
el.textContent=baseText+' (0s)'; el.style.color=color||'var(--brand2)';
|
||
el.classList.add('running');
|
||
const iv=setInterval(()=>{
|
||
if(!el.isConnected){clearInterval(iv);return;}
|
||
el.textContent=baseText+' ('+Math.floor((Date.now()-t0)/1000)+'s)';
|
||
},1000);
|
||
return ()=>{ clearInterval(iv); el.classList.remove('running'); };
|
||
}
|
||
function ic(state){ return state==='done'?'<span style="color:var(--green)">✓</span>':state==='running'?'<span style="color:var(--brand2)">⟳</span>':state==='fail'?'<span style="color:var(--red)">✗</span>':state==='skip'?'<span style="color:var(--muted)">—</span>':'<span style="color:var(--muted)">○</span>'; }
|
||
|
||
// ── steps ───────────────────────────────────────────────────────────────────
|
||
function goStep(n) {
|
||
for(let i=1;i<=4;i++){
|
||
document.getElementById('step-'+i).classList.toggle('active',i===n);
|
||
const d=document.getElementById('sd'+i);
|
||
d.classList.toggle('active',i===n); d.classList.toggle('done',i<n);
|
||
}
|
||
currentStep=n;
|
||
if(n===1){ renderPapers(); renderUploadList(); updSelUI(); loadExistingProjectPdfs(); }
|
||
if(n===2){ renderProgList(); loadExistingProjectPdfs(); }
|
||
}
|
||
|
||
// ── STEP 1: Search ──────────────────────────────────────────────────────────
|
||
async function doSearch() {
|
||
const q=document.getElementById('qi').value.trim();
|
||
const yr=document.getElementById('qy').value;
|
||
const sort=document.getElementById('cfg-sort').value;
|
||
if(!q)return;
|
||
const usePubmed=document.querySelector('#src-pubmed input')?.checked;
|
||
const useOpenalex=document.querySelector('#src-openalex input')?.checked;
|
||
const useSemantic=document.querySelector('#src-semantic input')?.checked;
|
||
const btn=document.getElementById('sbtn');
|
||
btn.disabled=true; btn.textContent='검색 중...'; setMsg(1,'검색 중...','var(--brand2)');
|
||
document.getElementById('results').innerHTML='<div style="color:var(--muted);padding:14px;text-align:center;font-size:12px">검색 중...</div>';
|
||
try {
|
||
const tasks=[];
|
||
if(usePubmed) tasks.push(
|
||
fetch('/api/pubmed/search?q='+encodeURIComponent(q)+'&max=15&sort='+sort+(yr?'&min_year='+yr:''),cr())
|
||
.then(r=>r.json()).then(d=>(d.data?.results||[]).map(p=>({...p,_source:'pubmed'})))
|
||
.catch(()=>[])
|
||
);
|
||
if(useOpenalex) tasks.push(
|
||
fetch('/api/openalex/search?q='+encodeURIComponent(q)+'&max=15&sort='+sort+(yr?'&min_year='+yr:''),cr())
|
||
.then(r=>r.json()).then(d=>(d.data?.results||[]).map(p=>({
|
||
_source:'openalex', title:p.title, authors:(p.authors||[]).join(', '),
|
||
year:p.year, journal:'', doi:p.doi, pmcid:null, pmid:null, pub_types:'',
|
||
citations:p.citations, is_oa:p.is_oa, oa_url:p.oa_url, abstract:p.abstract||'',
|
||
}))).catch(()=>[])
|
||
);
|
||
if(useSemantic) tasks.push(
|
||
fetch('/api/semantic/search?q='+encodeURIComponent(q)+'&max=15'+(yr?'&min_year='+yr:''),cr())
|
||
.then(r=>r.json()).then(d=>(d.data?.papers||[]).map(p=>({
|
||
_source:'semantic', title:p.title, authors:(p.authors||[]).join(', '),
|
||
year:p.year, journal:'', doi:p.doi, pmcid:null, pmid:p.pmid||null, pub_types:'',
|
||
citations:p.citations, inf_citations:p.inf_citations, oa_pdf:p.oa_pdf, abstract:p.abstract||'',
|
||
}))).catch(()=>[])
|
||
);
|
||
|
||
const results=await Promise.all(tasks);
|
||
// 병합 + DOI 기준 중복 제거 (pubmed 우선)
|
||
const merged=[], seenDoi=new Set(), seenTitle=new Set();
|
||
for(const list of results) for(const p of list){
|
||
const dkey=p.doi?p.doi.toLowerCase().trim():'';
|
||
const tkey=p.title?p.title.toLowerCase().slice(0,60):'';
|
||
if(dkey&&seenDoi.has(dkey)) continue;
|
||
if(!dkey&&tkey&&seenTitle.has(tkey)) continue;
|
||
if(dkey) seenDoi.add(dkey);
|
||
if(tkey) seenTitle.add(tkey);
|
||
merged.push(p);
|
||
}
|
||
papers=[...papers.filter(p=>p._isUpload), ...merged];
|
||
renderPapers();
|
||
setMsg(1,merged.length+'건 검색됨 ('+[usePubmed&&'PubMed',useOpenalex&&'OpenAlex',useSemantic&&'Semantic'].filter(Boolean).join('+')+')','var(--green)');
|
||
document.getElementById('search-legend').style.display='';
|
||
} catch(e){ setMsg(1,'검색 실패: '+e.message,'var(--red)'); document.getElementById('results').innerHTML='<div style="color:var(--red);padding:12px;font-size:12px">'+esc(e.message)+'</div>'; }
|
||
finally{ btn.disabled=false; btn.textContent='🔍 검색'; }
|
||
}
|
||
|
||
function renderPapers() {
|
||
const el=document.getElementById('results');
|
||
const searchPapers=papers.filter(p=>!p._isUpload);
|
||
if(!searchPapers.length){el.innerHTML='<div style="color:var(--muted);padding:12px;font-size:12px">결과 없음</div>';return;}
|
||
el.innerHTML=searchPapers.map((p)=>{
|
||
const idx=papers.indexOf(p);
|
||
const src=p._source||'pubmed';
|
||
const hp=!!p.pmcid;
|
||
const hasOaPdf=!!(p.oa_url||p.oa_pdf);
|
||
const hasPdf=src==='pubmed'?hp:hasOaPdf;
|
||
const pmcid=hp?'PMC'+String(p.pmcid||'').replace(/^PMC/i,''):'';
|
||
const key=paperKey(p); const isSel=key?selected.has(key):false;
|
||
const canSelect=!!key;
|
||
const srcBadge=src==='openalex'?`<span class="badge" style="background:rgba(34,197,94,.15);color:var(--green)">OpenAlex</span>`:
|
||
src==='semantic'?`<span class="badge" style="background:rgba(251,191,36,.15);color:var(--yellow)">Semantic Scholar</span>`:'';
|
||
const citeBadge=p.citations?`<span class="badge" style="background:rgba(148,163,184,.1);color:var(--muted)">인용 ${p.citations}</span>`:'';
|
||
const oaBadge=hasOaPdf?`<span class="badge" style="background:rgba(34,197,94,.15);color:var(--green)" title="Open Access PDF 있음">OA</span>`
|
||
:(!hasPdf?`<span class="badge" style="background:rgba(148,163,184,.1);color:var(--muted)" title="PDF 없음 — 초록으로만 사용">초록만</span>`:'');
|
||
return `<div class="pcard ${isSel?'sel':''}" id="pc${idx}">
|
||
<input type="checkbox" ${isSel?'checked':''} ${!canSelect?'disabled':''} onclick="event.stopPropagation();toggleP(${idx})">
|
||
<div class="pmeta">
|
||
<div class="ptitle2">${esc(p.title)}</div>
|
||
<div class="psub">${esc(p.authors||'')} · ${esc(p.journal||'')} ${esc(p.year||'')}</div>
|
||
<div style="margin-top:3px;display:flex;flex-wrap:wrap;gap:3px">
|
||
${srcBadge}
|
||
${src==='pubmed'?(hp?`<span class="badge bpmc" title="PMC 전문 PDF 가능">${pmcid}</span>`:'<span class="badge bnopmc">PMC미등재</span>'):''}
|
||
${p.year?`<span class="badge byear">${p.year}</span>`:''}
|
||
${p.pub_types?`<span class="badge btype">${p.pub_types.split(',')[0].trim()}</span>`:''}
|
||
${oaBadge}${citeBadge}
|
||
</div>
|
||
<div class="pbtns">
|
||
${src==='pubmed'?`<button class="btn bg" style="font-size:12px;padding:4px 12px" onclick="event.stopPropagation();showAbstract(${idx})">📋 초록 보기</button>`:''}
|
||
${p.pmid?`<a class="btn bg" style="font-size:12px;padding:4px 10px;text-decoration:none" href="https://pubmed.ncbi.nlm.nih.gov/${p.pmid}/" target="_blank" onclick="event.stopPropagation()">PubMed ↗</a>`:''}
|
||
${hp?`<a class="btn bg" style="font-size:12px;padding:4px 10px;text-decoration:none;color:var(--green)" href="https://www.ncbi.nlm.nih.gov/pmc/articles/${pmcid}/" target="_blank" onclick="event.stopPropagation()">전문(PMC) ↗</a>`:''}
|
||
${(p.oa_url||p.oa_pdf)?`<a class="btn bg" style="font-size:12px;padding:4px 10px;text-decoration:none;color:var(--green)" href="${p.oa_url||p.oa_pdf}" target="_blank" onclick="event.stopPropagation()">OA PDF ↗</a>`:''}
|
||
${p.doi?`<a class="btn bg" style="font-size:12px;padding:4px 10px;text-decoration:none" href="https://doi.org/${p.doi}" target="_blank" onclick="event.stopPropagation()">DOI ↗</a>`:''}
|
||
</div>
|
||
</div>
|
||
</div>`;
|
||
}).join('');
|
||
updSelUI();
|
||
}
|
||
|
||
function renderUploadList() {
|
||
const el=document.getElementById('upload-list'); if(!el)return;
|
||
const ups=papers.filter(p=>p._isUpload);
|
||
if(!ups.length){el.innerHTML='';return;}
|
||
el.innerHTML=ups.map((p,_)=>{
|
||
const idx=papers.indexOf(p);
|
||
const key=paperKey(p); const isSel=key?selected.has(key):false;
|
||
const fname=(p._localPdf||'').split('/').pop();
|
||
return `<div class="pcard ${isSel?'sel':''}" id="pc${idx}" style="cursor:pointer" onclick="toggleP(${idx})">
|
||
<input type="checkbox" ${isSel?'checked':''} onclick="event.stopPropagation();toggleP(${idx})">
|
||
<div class="pmeta">
|
||
<div class="ptitle2">${esc(p.title||fname)}</div>
|
||
<div class="psub" style="font-size:10px">${esc(p._localPdf||'')} ${p._size?'· '+Math.round(p._size/1024)+'KB':''}</div>
|
||
<div style="margin-top:3px">
|
||
<span class="badge bupload" title="직접 업로드한 PDF">📄 직접 업로드</span>
|
||
</div>
|
||
<div class="pbtns">
|
||
<button class="btn bred" style="font-size:11px;padding:3px 10px" onclick="event.stopPropagation();removeUpload(${idx})">✕ 제거</button>
|
||
</div>
|
||
</div>
|
||
</div>`;
|
||
}).join('');
|
||
}
|
||
|
||
function toggleP(idx) {
|
||
const p=papers[idx]; if(!p)return;
|
||
const key=paperKey(p); if(!key)return;
|
||
selected.has(key)?selected.delete(key):selected.add(key);
|
||
const card=document.getElementById('pc'+idx);
|
||
if(card){ card.classList.toggle('sel',selected.has(key)); const cb=card.querySelector('input');if(cb)cb.checked=selected.has(key); }
|
||
updSelUI();
|
||
}
|
||
|
||
function updSelUI() {
|
||
document.getElementById('s1next').disabled=selected.size===0;
|
||
const el=document.getElementById('sel-summary');
|
||
if(!selected.size){el.textContent='없음';return;}
|
||
el.innerHTML=Array.from(selected).map(id=>`<div>${esc(paperLabel(id))}</div>`).join('');
|
||
}
|
||
|
||
// ── Abstract modal ───────────────────────────────────────────────────────────
|
||
let abPaper=null, abEn='', abKo='';
|
||
|
||
async function showAbstract(idx) {
|
||
abPaper=papers[idx]; abEn=''; abKo=''; abTabMode='en';
|
||
document.getElementById('am-title').textContent=abPaper.title||'';
|
||
document.getElementById('am-meta').textContent=(abPaper.authors||'')+' · '+(abPaper.journal||'')+' ('+abPaper.year+')';
|
||
document.getElementById('am-body').textContent='초록 로딩 중...';
|
||
document.getElementById('at-en').classList.add('active'); document.getElementById('at-ko').classList.remove('active');
|
||
document.getElementById('am-translate-btn').disabled=false;
|
||
document.getElementById('am-translate-btn').textContent='🌐 번역';
|
||
document.getElementById('abg').classList.add('open');
|
||
|
||
const pmcid='PMC'+String(abPaper.pmcid||abPaper.pmid||'').replace(/^PMC/i,'');
|
||
const pmid=String(abPaper.pmid||'');
|
||
try {
|
||
const r=await fetch('/api/pubmed/fetch?pmids='+pmid,cr()); const d=await r.json();
|
||
if(!r.ok) throw new Error(d.error||'fetch failed');
|
||
// pubmedFetchTool returns data.articles[], fallback to stdout text
|
||
const art=d.data?.articles?.[0]||d.data?.results?.[0];
|
||
abEn=(art?.abstract) || (() => { const m=(d.stdout||'').match(/Abstract:\n([\s\S]*?)(?:\n\n|\n═|$)/); return m?m[1].trim():''; })() || d.stdout?.slice(0,2000) || '초록 없음';
|
||
// Store in state for outline generation
|
||
paperMeta[pmcid]={...( paperMeta[pmcid]||{}), abstract:abEn };
|
||
document.getElementById('am-body').textContent=abEn;
|
||
} catch(e){ document.getElementById('am-body').textContent='초록 로드 실패: '+e.message; }
|
||
}
|
||
|
||
function switchAbTab(t) {
|
||
abTabMode=t;
|
||
document.getElementById('at-en').classList.toggle('active',t==='en');
|
||
document.getElementById('at-ko').classList.toggle('active',t==='ko');
|
||
if(t==='en') document.getElementById('am-body').textContent=abEn||'';
|
||
else document.getElementById('am-body').textContent=abKo||'(번역 버튼을 눌러주세요)';
|
||
}
|
||
|
||
async function translateAbstract() {
|
||
if(!abEn){return;}
|
||
const btn=document.getElementById('am-translate-btn');
|
||
btn.disabled=true; btn.textContent='번역 중...';
|
||
if(abKo){ switchAbTab('ko'); btn.disabled=false; btn.textContent='🌐 번역'; return; }
|
||
// switch to ko tab immediately and show streaming progress
|
||
abTabMode='ko';
|
||
document.getElementById('at-en').classList.remove('active');
|
||
document.getElementById('at-ko').classList.add('active');
|
||
const bodyEl=document.getElementById('am-body');
|
||
bodyEl.textContent='번역 중...';
|
||
try {
|
||
const msg='다음 영문 의학 논문 초록을 자연스러운 한국어로 번역해주세요:\n\n'+abEn;
|
||
const r=await fetch('/api/chat',{method:'POST',credentials:'include',headers:{'Content-Type':'application/json','Accept':'text/event-stream'},body:JSON.stringify({message:msg,sessionId:'translate-abstract',model:'mistral-large-3:675b-cloud'})});
|
||
if(!r.ok) throw new Error('API '+r.status);
|
||
const reader=r.body.getReader(),dec=new TextDecoder();
|
||
let buf='',reply='';
|
||
while(true){
|
||
const {done,value}=await reader.read(); if(done)break;
|
||
buf+=dec.decode(value,{stream:true});
|
||
const lines=buf.split('\n'); buf=lines.pop();
|
||
for(const line of lines){
|
||
if(!line.startsWith('data: '))continue;
|
||
try{ const ev=JSON.parse(line.slice(6));
|
||
if((ev.type==='token'||ev.type==='text_delta')&&ev.text){
|
||
reply+=ev.text;
|
||
if(abTabMode==='ko') bodyEl.textContent=reply;
|
||
} else if(ev.type==='final'||ev.type==='reply'||ev.type==='final_reply'){
|
||
const t=ev.text||ev.reply||ev.content; if(t) reply=t;
|
||
} else if(ev.type==='done'&&ev.reply){ reply=ev.reply; }
|
||
}catch{}
|
||
}
|
||
}
|
||
if(buf.trim()){ try{ const ev=JSON.parse(buf.trim().replace(/^data: /,'')); if(ev.type==='final'&&ev.text) reply=ev.text; }catch{} }
|
||
abKo=reply.trim()||'번역 실패';
|
||
const pmcid=abPaper?'PMC'+String(abPaper.pmcid||'').replace(/^PMC/i,''):'';
|
||
if(pmcid) paperMeta[pmcid]={...(paperMeta[pmcid]||{}),translatedAbstract:abKo};
|
||
if(abTabMode==='ko') bodyEl.textContent=abKo;
|
||
} catch(e){ abKo='번역 오류: '+e.message; if(abTabMode==='ko') bodyEl.textContent=abKo; }
|
||
finally{ btn.disabled=false; btn.textContent='🌐 번역'; }
|
||
}
|
||
|
||
function closeAbModal(){ document.getElementById('abg').classList.remove('open'); }
|
||
|
||
// ── PDF 텍스트 번역 ──────────────────────────────────────────────────────────
|
||
const txlStartTime = {};
|
||
const CHUNK_SIZE = 15000;
|
||
|
||
function splitChunks(text, size) {
|
||
const chunks = [];
|
||
let pos = 0;
|
||
while (pos < text.length) {
|
||
let end = pos + size;
|
||
if (end < text.length) {
|
||
const nl = text.lastIndexOf('\n\n', end);
|
||
if (nl > pos) end = nl + 2;
|
||
else { const n = text.lastIndexOf('\n', end); if (n > pos) end = n + 1; }
|
||
}
|
||
chunks.push(text.slice(pos, end));
|
||
pos = end;
|
||
}
|
||
return chunks;
|
||
}
|
||
|
||
async function chatStream(msg, sessionId) {
|
||
const r = await fetch('/api/chat', {method:'POST', credentials:'include',
|
||
headers:{'Content-Type':'application/json','Accept':'text/event-stream'},
|
||
body: JSON.stringify({message: msg, sessionId, direct: true, model: 'mistral-large-3:675b-cloud'})});
|
||
if (!r.ok) throw new Error('API ' + r.status);
|
||
const reader = r.body.getReader(), dec = new TextDecoder();
|
||
let buf = '', reply = '';
|
||
while (true) {
|
||
const {done, value} = await reader.read(); if (done) break;
|
||
buf += dec.decode(value, {stream: true});
|
||
const lines = buf.split('\n'); buf = lines.pop();
|
||
for (const line of lines) {
|
||
if (!line.startsWith('data: ')) continue;
|
||
try { const ev = JSON.parse(line.slice(6));
|
||
if ((ev.type==='token'||ev.type==='text_delta') && ev.text) reply += ev.text;
|
||
else if (ev.type==='final' && ev.text) reply = ev.text;
|
||
else if (ev.type==='done' && ev.reply) reply = ev.reply;
|
||
} catch {}
|
||
}
|
||
}
|
||
return reply.trim();
|
||
}
|
||
|
||
async function translatePdfText(key, text) {
|
||
txlStatus[key]='running'; txlStartTime[key]=Date.now(); updProg(key);
|
||
const timer=setInterval(()=>{ if(txlStatus[key]==='running') updProg(key); else clearInterval(timer); },1000);
|
||
const sid = 'translate-pdf-' + key.replace(/[^a-z0-9]/gi,'_').slice(0,30);
|
||
const chunks = splitChunks(text, CHUNK_SIZE);
|
||
const parts = new Array(chunks.length).fill(null);
|
||
let failed = false;
|
||
try {
|
||
// 모든 청크를 병렬로 번역
|
||
await Promise.all(chunks.map(async (chunk, i) => {
|
||
const prompt = '다음 의학 논문 본문을 자연스러운 한국어로 번역해주세요. 원문의 단락 구조를 유지하세요:\n\n' + chunk;
|
||
parts[i] = await chatStream(prompt, sid + '_c' + i);
|
||
// 완료된 청크 수 표시
|
||
const done = parts.filter(Boolean).length;
|
||
paperTranslations[key] = parts.map((p,j)=>p||(j<done?'':'[번역 중...]')).filter(Boolean).join('\n\n');
|
||
updProg(key);
|
||
}));
|
||
paperTranslations[key] = parts.join('\n\n').trim() || '번역 실패';
|
||
txlStatus[key] = 'done';
|
||
} catch(e) {
|
||
paperTranslations[key] = parts.filter(Boolean).join('\n\n') + (parts.filter(Boolean).length ? '\n\n' : '') + '번역 오류: ' + e.message;
|
||
txlStatus[key] = 'fail';
|
||
}
|
||
clearInterval(timer);
|
||
updProg(key);
|
||
updOutlineBtn();
|
||
savePapersToProject();
|
||
}
|
||
|
||
function updOutlineBtn() {
|
||
// 준비 완료 기준: 각 논문의 텍스트 단계가 종료(done/fail/skip)되고,
|
||
// 번역이 진행 중이 아닐 때. (번역/추출 토글 OFF여도 게이트가 막히지 않음)
|
||
const allDone=Array.from(selected).every(k=>{
|
||
const s=prepLog[k]||{};
|
||
const txtTerminal=s.txt==='done'||s.txt==='fail'||s.txt==='skip';
|
||
if(!txtTerminal) return false;
|
||
if(txlStatus[k]==='running') return false;
|
||
return true;
|
||
});
|
||
let btn=document.getElementById('s2-outline-btn');
|
||
if(allDone){
|
||
if(!btn){
|
||
btn=document.createElement('button');
|
||
btn.id='s2-outline-btn'; btn.className='btn bp';
|
||
btn.textContent='✨ 아웃라인 생성 →';
|
||
btn.onclick=()=>{ goStep(3); generateOutline(); };
|
||
document.querySelector('#step-2 .bbar').appendChild(btn);
|
||
}
|
||
} else {
|
||
if(btn) btn.remove();
|
||
}
|
||
}
|
||
|
||
let txlViewKey=null, txlTabMode='en';
|
||
function showPdfTranslation(key){
|
||
txlViewKey=key; txlTabMode='en';
|
||
document.getElementById('txl-title').textContent=paperLabel(key)+' — 전문/번역';
|
||
const body=document.getElementById('txl-body');
|
||
body.textContent=paperTexts[key]||'전문 없음';
|
||
document.getElementById('txl-status').textContent=txlStatus[key]==='done'?'번역 완료':txlStatus[key]==='fail'?'번역 실패':'';
|
||
document.getElementById('txl-tab-en').style.background='var(--brand)';
|
||
document.getElementById('txl-tab-en').style.color='#fff';
|
||
document.getElementById('txl-tab-ko').style.background='';
|
||
document.getElementById('txl-tab-ko').style.color='';
|
||
document.getElementById('txlbg').classList.add('open');
|
||
}
|
||
function switchTxlTab(t){
|
||
txlTabMode=t;
|
||
const body=document.getElementById('txl-body');
|
||
if(t==='en'){
|
||
body.textContent=paperTexts[txlViewKey]||'전문 없음';
|
||
document.getElementById('txl-tab-en').style.background='var(--brand)';
|
||
document.getElementById('txl-tab-en').style.color='#fff';
|
||
document.getElementById('txl-tab-ko').style.background='';
|
||
document.getElementById('txl-tab-ko').style.color='';
|
||
} else {
|
||
body.textContent=paperTranslations[txlViewKey]||'번역 없음';
|
||
document.getElementById('txl-tab-ko').style.background='var(--brand)';
|
||
document.getElementById('txl-tab-ko').style.color='#fff';
|
||
document.getElementById('txl-tab-en').style.background='';
|
||
document.getElementById('txl-tab-en').style.color='';
|
||
}
|
||
}
|
||
function closeTxlModal(){ document.getElementById('txlbg').classList.remove('open'); }
|
||
function copyTxlText(){
|
||
const t=document.getElementById('txl-body').textContent;
|
||
navigator.clipboard.writeText(t).then(()=>{
|
||
const s=document.getElementById('txl-status'); s.textContent='클립보드에 복사됨'; setTimeout(()=>s.textContent='',2000);
|
||
});
|
||
}
|
||
|
||
// ── PDF 직접 업로드 ──────────────────────────────────────────────────────────
|
||
function handlePdfDrop(e) {
|
||
e.preventDefault();
|
||
const files=[...e.dataTransfer.files].filter(f=>f.name.toLowerCase().endsWith('.pdf'));
|
||
if(files.length) uploadPdfs(files);
|
||
}
|
||
function handlePdfFileInput(input) {
|
||
const files=[...input.files];
|
||
if(files.length) uploadPdfs(files);
|
||
input.value='';
|
||
}
|
||
async function uploadPdfs(files) {
|
||
const dz=document.getElementById('pdf-dropzone');
|
||
dz.textContent='업로드 중...';
|
||
for(const file of files){
|
||
try{
|
||
const fd=new FormData(); fd.append('file',file);
|
||
const r=await fetch('/api/upload/file',{method:'POST',credentials:'include',body:fd});
|
||
const d=await r.json();
|
||
if(!d.success) throw new Error(d.error||'업로드 실패');
|
||
const existing=papers.findIndex(p=>p._isUpload&&p._localPdf===d.path);
|
||
if(existing<0){
|
||
const fname=file.name.replace(/\.pdf$/i,'');
|
||
papers.push({_isUpload:true,_localPdf:d.path,_size:file.size,title:fname,pmcid:null,pmid:null,authors:'',journal:'',year:'',doi:'',pub_types:''});
|
||
const key=paperKey(papers[papers.length-1]);
|
||
selected.add(key);
|
||
}
|
||
setMsg(1,file.name+' 업로드 완료','var(--green)');
|
||
}catch(e){ setMsg(1,file.name+' 실패: '+e.message,'var(--red)'); }
|
||
}
|
||
dz.innerHTML='📄 PDF 파일을 드래그하거나 클릭해서 선택<br><span style="font-size:10px">여러 파일 동시 업로드 가능</span>';
|
||
renderUploadList(); updSelUI();
|
||
}
|
||
function removeUpload(idx){
|
||
const p=papers[idx]; if(!p||!p._isUpload)return;
|
||
const key=paperKey(p); selected.delete(key);
|
||
papers.splice(idx,1);
|
||
renderUploadList(); updSelUI();
|
||
}
|
||
|
||
// ── STEP 2: Prep ─────────────────────────────────────────────────────────────
|
||
|
||
// Load existing PDFs from the project folder and papers.json manifest.
|
||
// If the project already has PDFs on disk, add them as selectable papers
|
||
// so the user doesn't have to re-download or re-search.
|
||
async function loadExistingProjectPdfs() {
|
||
const project = resolveProjectSlug();
|
||
if (!project) return;
|
||
|
||
// 1) Try to load papers.json manifest first (has full metadata)
|
||
let manifestPapers = [];
|
||
try {
|
||
const pr = await fetch('/api/pptx/project-papers?project=' + encodeURIComponent(project), cr());
|
||
const pd = await pr.json();
|
||
if (pd.success && pd.papers && pd.papers.length) {
|
||
manifestPapers = pd.papers;
|
||
}
|
||
} catch {}
|
||
|
||
// 2) If we have manifest data and papers aren't already loaded, restore them
|
||
if (manifestPapers.length && !papers.filter(p => !p._isUpload).length) {
|
||
for (const pe of manifestPapers) {
|
||
const existingKey = pe.key;
|
||
if (selected.has(existingKey)) continue; // already in selection
|
||
const isUplEntry = existingKey.startsWith('UPL_') || pe._source === 'upload';
|
||
papers.push({
|
||
title: pe.title || '',
|
||
authors: pe.authors || '',
|
||
year: pe.year || '',
|
||
journal: pe.journal || '',
|
||
doi: pe.doi || '',
|
||
pmcid: pe.pmcid || null,
|
||
pmid: pe.pmid || null,
|
||
_source: pe._source || 'pubmed',
|
||
abstract: pe.abstract || '',
|
||
_fromSaved: true,
|
||
...(isUplEntry ? { _isUpload: true, _localPdf: pe.pdfPath || '' } : {}),
|
||
});
|
||
selected.add(existingKey);
|
||
|
||
// Restore prepLog for already-processed papers
|
||
if (!prepLog[existingKey]) {
|
||
prepLog[existingKey] = {
|
||
pdf: pe.pdfPath ? 'done' : 'skip',
|
||
img: 'skip',
|
||
txt: pe.txtPath && pe.hasText ? 'done' : 'skip',
|
||
pdfPath: pe.pdfPath || null,
|
||
};
|
||
}
|
||
// Restore paperTexts from .txt files
|
||
if (pe.txtPath && pe.hasText && !paperTexts[existingKey]) {
|
||
try {
|
||
const tr = await fetch('/api/files/' + pe.txtPath.split('/').map(s => encodeURIComponent(s)).join('/'), cr());
|
||
if (tr.ok) paperTexts[existingKey] = await tr.text();
|
||
} catch {}
|
||
}
|
||
// Restore translations
|
||
if (pe.koPath && pe.hasTranslation && !paperTranslations[existingKey]) {
|
||
try {
|
||
const kr = await fetch('/api/files/' + pe.koPath.split('/').map(s => encodeURIComponent(s)).join('/'), cr());
|
||
if (kr.ok) { paperTranslations[existingKey] = await kr.text(); txlStatus[existingKey] = 'done'; }
|
||
} catch {}
|
||
}
|
||
if (pe.abstract && !paperMeta[existingKey]) {
|
||
paperMeta[existingKey] = { abstract: pe.abstract };
|
||
}
|
||
}
|
||
renderPapers();
|
||
updSelUI();
|
||
// 프로젝트 폴더 이미지 로드 (UPL_ 전용 프로젝트도 포함)
|
||
try {
|
||
const ir = await fetch('/api/pptx/project-images?project=' + encodeURIComponent(project), cr());
|
||
const id = await ir.json();
|
||
if (id.images && id.images.length) {
|
||
for (const f of id.images) {
|
||
const url = '/api/files/' + f.path.split('/').map(s => encodeURIComponent(s)).join('/');
|
||
if (!extractedImages.find(img => img.url === url))
|
||
extractedImages.push({ url, label: f.name, pdf: null });
|
||
}
|
||
renderImgGrid();
|
||
document.getElementById('imgcnt').textContent = '(' + extractedImages.length + ')';
|
||
}
|
||
} catch {}
|
||
}
|
||
|
||
// 3) Also scan for PDFs on disk that aren't in the manifest
|
||
// (e.g. manually added, or from before papers.json was introduced)
|
||
try {
|
||
const r = await fetch('/api/pptx/project-pdfs?project=' + encodeURIComponent(project), cr());
|
||
const d = await r.json();
|
||
const pdfs = d.pdfs || [];
|
||
for (const pdf of pdfs) {
|
||
// pdf.path = "sinus/PMC8273047.pdf", pdf.name = "PMC8273047" (no extension)
|
||
const pathFile = (pdf.path || '').split('/').pop() || '';
|
||
const baseName = pdf.name || pathFile.replace(/\.pdf$/i, '') || '';
|
||
if (!baseName) continue;
|
||
const key = baseName.startsWith('PMC') ? baseName : 'UPL_' + baseName;
|
||
if (selected.has(key) || papers.some(p => paperKey(p) === key)) continue; // already known
|
||
|
||
// Add as a selectable paper from existing disk file
|
||
papers.push({
|
||
_isUpload: true,
|
||
_localPdf: pdf.path,
|
||
_fromSaved: true,
|
||
title: baseName,
|
||
pmcid: baseName.startsWith('PMC') ? baseName.replace(/^PMC/i, '') : null,
|
||
pmid: null, authors: '', journal: '', year: '', doi: '', pub_types: '',
|
||
});
|
||
selected.add(key);
|
||
|
||
// Set prepLog as done (PDF already on disk)
|
||
if (!prepLog[key]) {
|
||
prepLog[key] = { pdf: 'done', img: 'skip', txt: 'skip', pdfPath: pdf.path };
|
||
}
|
||
|
||
// Load .txt text from companion file (server tells us if it exists)
|
||
if (pdf.hasText && pdf.txtPath && !paperTexts[key]) {
|
||
try {
|
||
const tr = await fetch('/api/files/' + pdf.txtPath.split('/').map(s => encodeURIComponent(s)).join('/'), cr());
|
||
if (tr.ok) {
|
||
paperTexts[key] = await tr.text();
|
||
prepLog[key].txt = 'done';
|
||
}
|
||
} catch {}
|
||
}
|
||
// Load .ko.txt translation from companion file
|
||
if (pdf.hasTranslation && pdf.koPath && !paperTranslations[key]) {
|
||
try {
|
||
const kr = await fetch('/api/files/' + pdf.koPath.split('/').map(s => encodeURIComponent(s)).join('/'), cr());
|
||
if (kr.ok) {
|
||
paperTranslations[key] = await kr.text();
|
||
txlStatus[key] = 'done';
|
||
}
|
||
} catch {}
|
||
}
|
||
// Try to load extracted images from {project}/{baseName}_images/ directory
|
||
// Images are at the project directory root, scanned via project-images endpoint
|
||
const imgDir = project + '/' + baseName + '_images';
|
||
if (!extractedImages.some(img => img.url.includes(baseName + '_images'))) {
|
||
try {
|
||
const ir = await fetch('/api/pptx/project-images?project=' + encodeURIComponent(imgDir), cr());
|
||
const id = await ir.json();
|
||
if (id.images && id.images.length) {
|
||
for (const f of id.images) {
|
||
const url = '/api/files/' + f.path.split('/').map(s => encodeURIComponent(s)).join('/');
|
||
if (!extractedImages.find(img => img.url === url)) {
|
||
extractedImages.push({ url, label: baseName + ': ' + f.name, pdf: pdf.path });
|
||
}
|
||
}
|
||
prepLog[key].img = 'done';
|
||
prepLog[key].imgCount = id.images.length;
|
||
}
|
||
} catch {}
|
||
}
|
||
}
|
||
// Also load images from the project directory root (extracted figures at root level)
|
||
try {
|
||
const ir = await fetch('/api/pptx/project-images?project=' + encodeURIComponent(project), cr());
|
||
const id = await ir.json();
|
||
if (id.images && id.images.length) {
|
||
for (const f of id.images) {
|
||
const url = '/api/files/' + f.path.split('/').map(s => encodeURIComponent(s)).join('/');
|
||
if (!extractedImages.find(img => img.url === url)) {
|
||
extractedImages.push({ url, label: f.name, pdf: null });
|
||
}
|
||
}
|
||
}
|
||
} catch {}
|
||
if (pdfs.length) {
|
||
renderPapers();
|
||
renderUploadList();
|
||
updSelUI();
|
||
}
|
||
renderImgGrid();
|
||
document.getElementById('imgcnt').textContent = '(' + extractedImages.length + ')';
|
||
} catch {}
|
||
|
||
// 5) Update UI — if all selected papers are already prepped, show completed state
|
||
const allDone = Array.from(selected).every(k => {
|
||
const s = prepLog[k] || {};
|
||
return s.pdf === 'done' || s.pdf === 'skip';
|
||
});
|
||
renderProgList();
|
||
if (allDone && selected.size > 0) {
|
||
setMsg(2, '✓ 기존 PDF 복원됨 — 바로 아웃라인 생성 가능', 'var(--green)');
|
||
// Show the outline button
|
||
updOutlineBtn();
|
||
// Change start button to "다시 실행"
|
||
const btn = document.getElementById('s2start');
|
||
if (btn) { btn.disabled = false; btn.textContent = '▶ 다시 실행'; }
|
||
}
|
||
}
|
||
|
||
function pspHtml(label, state, extra='') {
|
||
const cls = state==='done'?'psp-done':state==='running'?'psp-run':state==='fail'?'psp-fail':state==='skip'?'psp-skip':'psp-wait';
|
||
const icon = state==='done'?'✓ ':state==='running'?'⟳ ':state==='fail'?'✗ ':state==='skip'?'— ':'○ ';
|
||
return `<span class="psp ${cls}">${icon}${label}${extra?' '+extra:''}</span>`;
|
||
}
|
||
|
||
function progRowHtml(key, s) {
|
||
const lbl=paperLabel(key);
|
||
const isUp=key.startsWith('UPL_');
|
||
const pdfPath=s.pdfPath||'';
|
||
const txSt=txlStatus[key];
|
||
const txElapsed=txlStartTime[key]?Math.round((Date.now()-txlStartTime[key])/1000)+'s':'';
|
||
|
||
// Step pills: PDF → (이미지) → 텍스트 → 번역
|
||
const pdfPill = isUp ? pspHtml('업로드','done') : pspHtml('PDF', s.pdf||'waiting');
|
||
// 이미지 추출 pill — 자동 추출이 꺼져있거나 시도하지 않은 경우(skip) 숨김
|
||
const imgPill = (s.img && s.img!=='skip')
|
||
? pspHtml('이미지', s.img, s.img==='done'&&s.imgCount?String(s.imgCount):'')
|
||
: '';
|
||
// 텍스트 pill — 추출이 꺼져있으면(skip) 숨김
|
||
const txtPill = (s.txt && s.txt!=='skip') ? pspHtml('텍스트', s.txt) : '';
|
||
// 번역 pill — 번역을 시도하지 않았으면(자동 번역 OFF 등) 숨김
|
||
const txPill = txSt==='done' ? pspHtml('번역','done') :
|
||
txSt==='running' ? pspHtml('번역','running',txElapsed) :
|
||
txSt==='fail' ? pspHtml('번역','fail') : '';
|
||
|
||
const sep='<span style="color:var(--muted);font-size:10px;margin:0 3px">→</span>';
|
||
const pills=[pdfPill,imgPill,txtPill,txPill].filter(Boolean).join(sep);
|
||
const subMsg=s.sub?`<div style="font-size:10px;color:var(--brand2);margin-top:3px;opacity:.85">⟳ ${esc(s.sub)}</div>`:'';
|
||
|
||
const capBtn=pdfPath?`<button class="btn bg" style="font-size:10px;padding:2px 8px;margin-top:5px" onclick="openPdfCapture('${esc(pdfPath)}','${esc(lbl)}')">📷 이미지 캡처</button>`:'';
|
||
const txtViewBtn=paperTexts[key]?`<button class="btn bg" style="font-size:10px;padding:2px 8px;margin-top:5px;color:var(--brand2)" onclick="showPdfTranslation('${esc(key)}')">📄 전문 보기</button>`:'';
|
||
const txViewBtn=txSt==='done'?`<button class="btn bg" style="font-size:10px;padding:2px 8px;margin-top:5px;color:var(--green)" onclick="switchTxlTab('ko');showPdfTranslation('${esc(key)}');switchTxlTab('ko')">📝 번역 보기</button>`:'';
|
||
const txStartBtn=(paperTexts[key]&&txSt!=='done'&&txSt!=='running')?`<button class="btn bg" style="font-size:10px;padding:2px 8px;margin-top:5px" onclick="translatePdfText('${esc(key)}',paperTexts['${esc(key)}'])">🌐 번역</button>`:'';
|
||
|
||
// 이미 추출된 번역/텍스트 미리보기 (첫 120자)
|
||
const previewText = paperTranslations[key]
|
||
? paperTranslations[key].slice(0, 120).replace(/\s+/g,' ').trim() + (paperTranslations[key].length > 120 ? '…' : '')
|
||
: (paperTexts[key]
|
||
? paperTexts[key].slice(0, 120).replace(/\s+/g,' ').trim() + (paperTexts[key].length > 120 ? '…' : '')
|
||
: '');
|
||
const previewTab = paperTranslations[key] ? 'ko' : 'en';
|
||
const previewHtml = previewText
|
||
? `<div style="font-size:10px;color:var(--muted);margin-top:4px;line-height:1.5;font-style:italic;cursor:pointer" onclick="switchTxlTab('${previewTab}');showPdfTranslation('${esc(key)}')" title="클릭하면 전문 보기">${esc(previewText)} <span style="color:var(--brand2);font-style:normal">전문 ↗</span></div>`
|
||
: '';
|
||
|
||
return `<div style="font-size:11px;font-weight:600;margin-bottom:5px;word-break:break-all">${esc(lbl)}</div>
|
||
<div style="display:flex;align-items:center;flex-wrap:wrap;gap:2px">${pills}</div>
|
||
${previewHtml}
|
||
${subMsg}
|
||
${s.error?(s.error.includes('초록으로 대체')
|
||
?`<div style="font-size:10px;color:#f59e0b;margin-top:4px">⚠ PDF 다운로드 불가 — 초록으로 대체됨</div>`
|
||
:(s.error.includes('전문 텍스트로 대체')
|
||
?`<div style="font-size:10px;color:#f59e0b;margin-top:4px">⚠ PDF 없음 — 전문 텍스트로 대체됨</div>`
|
||
:`<div style="font-size:10px;color:var(--red);margin-top:4px">${errHtml(s.error)}</div>`))
|
||
:''}
|
||
${(capBtn||txtViewBtn||txViewBtn||txStartBtn)?`<div style="display:flex;gap:6px;flex-wrap:wrap">${capBtn}${txtViewBtn}${txViewBtn}${txStartBtn}</div>`:''}`;
|
||
}
|
||
|
||
function renderProgList() {
|
||
const el=document.getElementById('proglist'); if(!el)return;
|
||
const ids=Array.from(selected);
|
||
if(!ids.length){el.innerHTML='<div style="color:var(--muted);font-size:12px">선택된 논문이 없습니다</div>';return;}
|
||
el.innerHTML=ids.map(key=>{
|
||
const s=prepLog[key]||{};
|
||
const act=key===currentProcessingKey?' active':'';
|
||
return `<div class="prog${act}" id="pr-${CSS.escape(key)}">${progRowHtml(key,s)}</div>`;
|
||
}).join('');
|
||
}
|
||
|
||
function updProg(key) {
|
||
const row=document.getElementById('pr-'+CSS.escape(key)); if(!row)return;
|
||
const s=prepLog[key]||{};
|
||
row.innerHTML=progRowHtml(key,s);
|
||
if(key===currentProcessingKey) row.classList.add('active'); else row.classList.remove('active');
|
||
}
|
||
|
||
function updOverall() {
|
||
const keys=Array.from(selected);
|
||
const done=keys.filter(k=>{
|
||
const s=prepLog[k]||{};
|
||
return (s.pdf==='done'||s.pdf==='fail'||k.startsWith('UPL_'))&&(s.txt==='done'||s.txt==='fail'||s.txt==='skip');
|
||
}).length;
|
||
const el=document.getElementById('s2-overall');
|
||
if(!el) return;
|
||
if(keys.length>0){ el.style.display=''; el.textContent=`${done} / ${keys.length} 완료`; }
|
||
else { el.style.display='none'; }
|
||
}
|
||
|
||
// project slug 결정: cfg-project → cfg-topic → 첫 논문 키 → 타임스탬프
|
||
function resolveProjectSlug(){
|
||
const inp=(document.getElementById('cfg-project')?.value||'').trim();
|
||
if(inp) return inp.replace(/[^a-zA-Z0-9가-힣_\-]/g,'_').replace(/_+/g,'_').replace(/^_+|_+$/g,'').toLowerCase().slice(0,60);
|
||
// 검색 주제어로 추론
|
||
const topic=(document.getElementById('cfg-topic')?.value||'').trim();
|
||
if(topic) return topic.replace(/[^a-zA-Z0-9가-힣]/g,'_').replace(/_+/g,'_').replace(/^_+|_+$/g,'').toLowerCase().slice(0,40);
|
||
// 첫 번째 선택 항목으로 추론
|
||
const firstKey=Array.from(selected)[0]||'';
|
||
if(firstKey.startsWith('PMC')) return firstKey.toLowerCase();
|
||
const firstPaper=papers.find(p=>paperKey(p)===firstKey);
|
||
if(firstPaper?.title) return firstPaper.title.replace(/[^a-zA-Z0-9가-힣]/g,'_').replace(/_+/g,'_').toLowerCase().slice(0,40);
|
||
// 최후 폴백: 날짜 기반
|
||
return 'project_'+new Date().toISOString().slice(0,10).replace(/-/g,'');
|
||
}
|
||
|
||
async function startPrep() {
|
||
const btn=document.getElementById('s2start');
|
||
if(btn.disabled) return; // 이미 실행 중
|
||
btn.disabled=true; btn.textContent='진행 중...';
|
||
setMsg(2,'준비 중...','var(--brand2)');
|
||
const keys=Array.from(selected); prepLog={}; renderProgList(); extractedImages=[];
|
||
document.getElementById('imgrid').innerHTML='<div style="color:var(--muted);font-size:11px">📷 이미지 캡처 버튼으로 추가하세요</div>';
|
||
|
||
// 프로젝트 slug 결정 — PDF/이미지를 프로젝트 폴더에 직접 저장 (항상 결정)
|
||
const prepProject=resolveProjectSlug();
|
||
const inp=document.getElementById('cfg-project');
|
||
if(inp&&!inp.value) inp.value=prepProject;
|
||
|
||
for(const key of keys){
|
||
const isUpload=key.startsWith('UPL_');
|
||
const paper=papers.find(p=>paperKey(p)===key);
|
||
currentProcessingKey=key;
|
||
prepLog[key]={pdf:isUpload?'done':'running',img:autoExtractImages?'waiting':'skip',txt:extractText?'waiting':'skip'};
|
||
updProg(key); updOverall();
|
||
|
||
try{
|
||
let pdfPath=null;
|
||
|
||
if(isUpload){
|
||
// 직접 업로드 — 다운로드 스킵, 업로드 경로 바로 사용
|
||
pdfPath=paper?._localPdf||null;
|
||
prepLog[key].pdfPath=pdfPath;
|
||
if(!pdfPath) throw new Error('업로드 경로 없음');
|
||
setMsg(2,paperLabel(key)+' 이미지 추출...','var(--brand2)');
|
||
} else if(key.startsWith('DOI_')||key.startsWith('PMID')){
|
||
// OpenAlex/Semantic — OA URL로 직접 다운로드
|
||
const oaUrl=paper?.oa_url||paper?.oa_pdf||null;
|
||
if(!oaUrl) throw new Error('OA PDF URL 없음 — 이 논문은 오픈액세스 PDF가 없습니다');
|
||
setMsg(2,paperLabel(key)+' PDF 다운로드...','var(--brand2)');
|
||
const dlr=await fetch('/api/pptx/download-pdf',{...jh(),method:'POST',body:JSON.stringify({url:oaUrl,doi:paper?.doi,project:prepProject})});
|
||
const dld=await dlr.json();
|
||
if(!dld.success&&!dld.stdout) throw new Error(dld.error||'다운로드 실패');
|
||
const pm=(dld.stdout||dld.path||'').match(/((?:pubmed|uploads|[a-z0-9_-]+)\/[^\s'"]+\.pdf)/i);
|
||
pdfPath=pm?pm[1]:dld.path||null;
|
||
prepLog[key].pdf='done'; prepLog[key].pdfPath=pdfPath; updProg(key);
|
||
if(!pdfPath){ prepLog[key].img='fail'; prepLog[key].txt='fail'; prepLog[key].error='PDF 경로 확인 불가'; updProg(key); continue; }
|
||
setMsg(2,paperLabel(key)+' 이미지 추출...','var(--brand2)');
|
||
} else {
|
||
// PMC 다운로드 (SSE 스트리밍)
|
||
setMsg(2,key+' PDF 다운로드...','var(--brand2)');
|
||
const dld = await new Promise(async (resolve)=>{
|
||
const r=await fetch('/api/pptx/download-pdf-stream',{...jh(),method:'POST',body:JSON.stringify({pmcid:key,project:prepProject})});
|
||
const reader=r.body.getReader(); const dec=new TextDecoder();
|
||
let buf='', result=null;
|
||
while(true){
|
||
const {done,value}=await reader.read(); if(done) break;
|
||
buf+=dec.decode(value,{stream:true});
|
||
const lines=buf.split('\n'); buf=lines.pop();
|
||
for(const line of lines){
|
||
if(!line.startsWith('data: ')) continue;
|
||
try{
|
||
const ev=JSON.parse(line.slice(6));
|
||
if(ev.type==='progress'){
|
||
prepLog[key].sub=ev.msg; updProg(key);
|
||
} else if(ev.type==='done'){
|
||
result=ev;
|
||
}
|
||
}catch{}
|
||
}
|
||
}
|
||
prepLog[key].sub=''; updProg(key);
|
||
resolve(result||{success:false,error:'응답 없음'});
|
||
});
|
||
if(!dld.success&&!dld.stdout) throw new Error(dld.error||'다운로드 실패');
|
||
|
||
// 텍스트 폴백: PDF 대신 efetch 전문 텍스트를 받은 경우
|
||
if(dld._textFallback && dld._txtPath){
|
||
prepLog[key].pdf='fail'; prepLog[key].pdfPath=null;
|
||
prepLog[key].img='skip'; prepLog[key].txt='done';
|
||
prepLog[key].error='PDF 없음 → 전문 텍스트로 대체';
|
||
const txtContent=dld.stdout||'';
|
||
if(txtContent) paperTexts[key]=txtContent.slice(0,200000);
|
||
updProg(key); updOverall();
|
||
if(autoTranslate && paperTexts[key]) translatePdfText(key, paperTexts[key]);
|
||
continue;
|
||
}
|
||
|
||
const pm=(dld.stdout||dld.path||'').match(/((?:pubmed|[a-z0-9_-]+)\/[^\s'"]+\.pdf)/i);
|
||
pdfPath=pm?pm[1]:null;
|
||
prepLog[key].pdf='done'; prepLog[key].pdfPath=pdfPath; updProg(key);
|
||
if(!pdfPath){ prepLog[key].img='fail'; prepLog[key].txt='fail'; prepLog[key].error='PDF 경로 확인 불가'; updProg(key); continue; }
|
||
setMsg(2,key+' 이미지 추출...','var(--brand2)');
|
||
}
|
||
|
||
// 이미지 자동 추출 + mistral vision 분석 (토글 ON일 때만)
|
||
if(autoExtractImages && pdfPath){
|
||
prepLog[key].img='running'; updProg(key);
|
||
setMsg(2,paperLabel(key)+' 이미지 추출...','var(--brand2)');
|
||
try{
|
||
const exr=await fetch('/api/pptx/extract-images',{...jh(),method:'POST',body:JSON.stringify({pdf_path:pdfPath,project:prepProject})});
|
||
const exd=await exr.json();
|
||
let added=0;
|
||
const newImgs=[];
|
||
if(exd.success&&exd.data?.files){
|
||
const pname=isUpload?(pdfPath.split('/').pop()||'').replace(/\.pdf$/i,''):key;
|
||
for(const f of exd.data.files){
|
||
const url='/api/files/'+f;
|
||
if(!extractedImages.find(img=>img.url===url)){
|
||
const entry={url,label:pname+': '+f.split('/').pop(),pdf:pdfPath};
|
||
extractedImages.push(entry);
|
||
newImgs.push(entry);
|
||
added++;
|
||
}
|
||
}
|
||
}
|
||
renderImgGrid();
|
||
// mistral vision으로 새로 추출된 이미지 즉시 분석
|
||
for(let ni=0;ni<newImgs.length;ni++){
|
||
const nim=newImgs[ni];
|
||
if(imageCaptions[nim.url]) continue;
|
||
setMsg(2,`${paperLabel(key)} 이미지 분석 ${ni+1}/${newImgs.length}...`,'var(--brand2)');
|
||
try{
|
||
const ar=await fetch('/api/pptx/analyze-image',{...jh(),method:'POST',body:JSON.stringify({url:nim.url,label:nim.label,project:prepProject})});
|
||
const ad=await ar.json();
|
||
if(ad.caption) imageCaptions[nim.url]=ad.caption;
|
||
}catch{}
|
||
renderImgGrid();
|
||
}
|
||
prepLog[key].img='done'; prepLog[key].imgCount=added;
|
||
}catch(e2){ prepLog[key].img='fail'; }
|
||
updProg(key);
|
||
} else {
|
||
prepLog[key].img='skip'; updProg(key);
|
||
}
|
||
|
||
// Text extract (토글 ON일 때만)
|
||
if(extractText && pdfPath){
|
||
prepLog[key].txt='running'; updProg(key);
|
||
setMsg(2,paperLabel(key)+' 텍스트 추출...','var(--brand2)');
|
||
try{
|
||
const txtSavePath=pdfPath.replace(/\.pdf$/i,'.txt');
|
||
const txr=await fetch('/api/pptx/read-pdf',{...jh(),method:'POST',body:JSON.stringify({pdf_path:pdfPath,save_path:txtSavePath,max_chars:200000})});
|
||
const txd=await txr.json();
|
||
if(txd.success||txd.stdout){ paperTexts[key]=(txd.stdout||txd.data?.text||'').slice(0,200000); prepLog[key].txt='done'; }
|
||
else{ prepLog[key].txt='fail'; }
|
||
}catch(e2){ prepLog[key].txt='fail'; }
|
||
} else {
|
||
prepLog[key].txt='skip';
|
||
}
|
||
updProg(key); updOverall();
|
||
// 텍스트 추출 성공 + 자동 번역 ON이면 백그라운드 번역 시작
|
||
if(autoTranslate && prepLog[key].txt==='done' && paperTexts[key]) translatePdfText(key,paperTexts[key]);
|
||
|
||
}catch(e){
|
||
['pdf','img','txt'].forEach(k=>{ if(prepLog[key][k]==='running'||prepLog[key][k]==='waiting') prepLog[key][k]='fail'; });
|
||
// PDF 실패시 초록으로 대체
|
||
const ab=paper?.abstract||'';
|
||
if(ab&&!paperTexts[key]){
|
||
paperTexts[key]='[초록만 사용 — PDF 다운로드 불가]\n\n'+ab;
|
||
prepLog[key].txt='done';
|
||
prepLog[key].error=(e.message||'다운로드 실패')+' (초록으로 대체)';
|
||
// 초록 대체분도 (자동 번역 ON이면) 번역
|
||
if(autoTranslate) translatePdfText(key, paperTexts[key]);
|
||
} else {
|
||
prepLog[key].error=e.message;
|
||
// 본문도 초록도 없으면 번역 단계 건너뜀 — 게이트가 막히지 않도록 표시
|
||
txlStatus[key]='fail';
|
||
}
|
||
updProg(key); updOverall();
|
||
}
|
||
}
|
||
currentProcessingKey=null;
|
||
// remove active highlight from all rows
|
||
document.querySelectorAll('#proglist .prog.active').forEach(r=>r.classList.remove('active'));
|
||
updOverall();
|
||
const allDone=keys.every(k=>['done','fail','skip'].includes(prepLog[k]?.txt));
|
||
setMsg(2, allDone ? '✓ 준비 완료' : '⚠ 일부 실패', allDone?'var(--green)':'var(--red)');
|
||
btn.disabled=false; btn.textContent='▶ 다시 실행';
|
||
document.getElementById('imgcnt').textContent='('+extractedImages.length+')';
|
||
// 모든 논문이 PDF 번역 없이 끝났거나 일부가 실패한 경우에도 아웃라인 버튼을 노출
|
||
updOutlineBtn();
|
||
// Save paper metadata, texts, and translations to project folder
|
||
savePapersToProject();
|
||
}
|
||
|
||
function renderImgGrid(){
|
||
const g=document.getElementById('imgrid'); if(!g)return;
|
||
document.getElementById('imgcnt').textContent='('+extractedImages.length+')';
|
||
if(!extractedImages.length){g.innerHTML='<div style="color:var(--muted);font-size:11px">없음</div>';return;}
|
||
g.innerHTML=extractedImages.map((img,i)=>{
|
||
const cap=imageCaptions[img.url]||'';
|
||
const excluded=cap==='제외';
|
||
const capHtml=cap?`<div style="font-size:8px;padding:1px 4px;line-height:1.3;color:${excluded?'#f87171':'#34d399'};overflow:hidden;text-overflow:ellipsis;white-space:nowrap" title="${esc(cap)}">${excluded?'⛔ 제외':cap}</div>`:'';
|
||
return `<div class="icard" title="${esc(img.label)}" style="position:relative;opacity:${excluded?.5:1}" onmouseenter="this.querySelector('.icard-del').style.opacity=1" onmouseleave="this.querySelector('.icard-del').style.opacity=0">
|
||
<img src="${esc(img.url)}" loading="lazy" onerror="this.style.display='none'" onclick="showImgPreview('${esc(img.url)}','${esc(img.label)}')" style="cursor:zoom-in;width:100%;height:100%;object-fit:cover">
|
||
<button class="icard-del" onclick="event.stopPropagation();deleteImg(${i})" style="position:absolute;top:4px;right:4px;opacity:0;transition:.15s;background:rgba(220,38,38,.85);border:none;color:#fff;border-radius:4px;width:22px;height:22px;font-size:13px;cursor:pointer;display:flex;align-items:center;justify-content:center;line-height:1" title="삭제">✕</button>
|
||
<div class="ilabel" style="overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-size:9px;padding:2px 5px">${esc(img.label)}</div>
|
||
${capHtml}
|
||
</div>`;
|
||
}).join('');
|
||
}
|
||
|
||
async function analyzeImages(){
|
||
if(!extractedImages.length){setMsg(2,'분석할 이미지가 없습니다.','var(--yellow)');return;}
|
||
const btn=document.getElementById('btn-analyze-imgs');
|
||
btn.disabled=true;
|
||
const project=resolveProjectSlug();
|
||
const total=extractedImages.length;
|
||
let done=0;
|
||
for(const img of extractedImages){
|
||
btn.textContent=`🔍 ${done}/${total}`;
|
||
if(!imageCaptions[img.url]){
|
||
try{
|
||
const r=await fetch('/api/pptx/analyze-image',{...jh(),method:'POST',body:JSON.stringify({url:img.url,label:img.label,project})});
|
||
const d=await r.json();
|
||
if(d.caption) imageCaptions[img.url]=d.caption;
|
||
}catch(e){console.warn('[analyzeImages]',e);}
|
||
}
|
||
done++;
|
||
renderImgGrid();
|
||
}
|
||
btn.disabled=false;
|
||
btn.textContent='🔍 이미지 분석';
|
||
const excluded=Object.values(imageCaptions).filter(c=>c==='제외').length;
|
||
setMsg(2,`이미지 분석 완료 (${total}장${excluded?', 제외 '+excluded+'장':''})`, 'var(--green)');
|
||
}
|
||
|
||
async function deleteImg(i){
|
||
const img = extractedImages[i];
|
||
if (img) {
|
||
// /api/files/<relpath> 형태에서 relpath 추출
|
||
const url = img.url || '';
|
||
const prefix = '/api/files/';
|
||
if (url.startsWith(prefix)) {
|
||
const relPath = url.slice(prefix.length).split('/').map(decodeURIComponent).join('/');
|
||
try {
|
||
await fetch('/api/pptx/delete-image', { ...jh(), method: 'DELETE', body: JSON.stringify({ path: relPath }) });
|
||
} catch (e) { console.warn('[deleteImg]', e); }
|
||
}
|
||
}
|
||
extractedImages.splice(i, 1);
|
||
renderImgGrid();
|
||
}
|
||
|
||
async function doImgSearch() {
|
||
const q = document.getElementById('img-search-input').value.trim();
|
||
const res = document.getElementById('img-search-results');
|
||
if (!q) return;
|
||
res.innerHTML = '<div style="font-size:11px;color:var(--muted)">검색 중…</div>';
|
||
try {
|
||
const r = await fetch('/api/pptx/search-images', { ...jh(), method:'POST', body:JSON.stringify({query:q,count:6}) });
|
||
const d = await r.json();
|
||
if (!d.images?.length) { res.innerHTML = '<div style="font-size:11px;color:var(--muted)">결과 없음</div>'; return; }
|
||
res.innerHTML = `<div class="igrid" style="grid-template-columns:repeat(auto-fill,minmax(85px,1fr))">${d.images.map(img=>`<div class="icard" style="aspect-ratio:4/3"><img src="${esc(img.url)}" loading="lazy" onerror="this.parentNode.style.display='none'" onclick="showImgPreview('${esc(img.url)}','${esc(img.label)}')" style="width:100%;height:100%;object-fit:cover;cursor:zoom-in"><div class="ilabel">${esc(img.label)}</div><button style="position:absolute;top:2px;right:2px;background:rgba(99,102,241,.85);border:none;color:#fff;border-radius:4px;width:20px;height:20px;font-size:13px;cursor:pointer;line-height:1" onclick="addSearchImg('${esc(img.url)}','${esc(img.label)}',this)" title="갤러리에 추가">+</button></div>`).join('')}</div>`;
|
||
} catch(e) { res.innerHTML = `<div style="font-size:11px;color:var(--danger)">${esc(e.message)}</div>`; }
|
||
}
|
||
|
||
async function addSearchImg(url, label, btn) {
|
||
const project = resolveProjectSlug();
|
||
if (!project) { alert('프로젝트를 먼저 선택하세요'); return; }
|
||
btn.textContent = '…'; btn.disabled = true;
|
||
try {
|
||
const r = await fetch('/api/pptx/save-image-url', { ...jh(), method:'POST', body:JSON.stringify({url,project}) });
|
||
const d = await r.json();
|
||
if (d.success) {
|
||
if (!extractedImages.find(img => img.url === d.url)) extractedImages.push({ url: d.url, label, pdf: null });
|
||
renderImgGrid();
|
||
btn.textContent = '✓'; btn.style.background = 'rgba(34,197,94,.85)';
|
||
} else { btn.textContent = '✗'; btn.disabled = false; }
|
||
} catch { btn.textContent = '✗'; btn.disabled = false; }
|
||
}
|
||
|
||
async function handleImgUpload(files) {
|
||
if (!files || !files.length) return;
|
||
const project = resolveProjectSlug();
|
||
if (!project) { alert('먼저 프로젝트 폴더명을 입력하거나 PDF 준비를 시작해주세요.'); return; }
|
||
const dz = document.getElementById('img-dropzone');
|
||
const origTxt = dz ? dz.textContent : '';
|
||
const total = files.length;
|
||
let done = 0;
|
||
for (const file of files) {
|
||
if (dz) dz.textContent = `업로드 중... ${done+1}/${total}`;
|
||
const fd = new FormData();
|
||
fd.append('image', file, file.name);
|
||
fd.append('project', project);
|
||
try {
|
||
const r = await fetch('/api/pptx/upload-image', { method: 'POST', credentials: 'include', body: fd });
|
||
const d = await r.json();
|
||
if (d.success) {
|
||
const label = file.name.replace(/\.[^.]+$/, '');
|
||
if (!extractedImages.find(img => img.url === d.url)) {
|
||
extractedImages.push({ url: d.url, label, pdf: null });
|
||
}
|
||
} else {
|
||
console.warn('[img upload]', d.error);
|
||
}
|
||
} catch (e) { console.warn('[img upload]', e); }
|
||
done++;
|
||
}
|
||
if (dz) dz.textContent = origTxt;
|
||
document.getElementById('img-upload-input').value = '';
|
||
renderImgGrid();
|
||
}
|
||
|
||
async function deleteImgRefreshPicker(i){
|
||
extractedImages.splice(i,1);
|
||
renderImgGrid();
|
||
const g=document.getElementById('imodal-grid');
|
||
if(!g)return;
|
||
await _loadPickerImages(g);
|
||
}
|
||
|
||
// ── STEP 3: Outline ──────────────────────────────────────────────────────────
|
||
async function generateOutline() {
|
||
const stopTimer=startTimer('s3msg','✨ 아웃라인 생성 중');
|
||
const ph=document.querySelector('#outline-cards div');
|
||
const genStart=Date.now();
|
||
const phTimer=setInterval(()=>{
|
||
const el=document.querySelector('#outline-cards > div');
|
||
if(el) el.textContent='아웃라인 생성 중 — '+Math.round((Date.now()-genStart)/1000)+'s';
|
||
},1000);
|
||
if(ph) ph.textContent='아웃라인 생성 중 — 0s';
|
||
|
||
const topic=(document.getElementById('cfg-topic').value||'').trim()||'발표 자료를 만들어주세요';
|
||
const tmpl=document.getElementById('cfg-tmpl').value;
|
||
const slides=parseInt(document.getElementById('cfg-slides').value)||12;
|
||
const skin=selectedSkin;
|
||
const pmcids=Array.from(selected).join(', ');
|
||
|
||
// Collect abstracts and PDF text for each paper
|
||
const contentBlocks=[];
|
||
for(const pmcid of selected){
|
||
const meta=paperMeta[pmcid]||{};
|
||
const txt=paperTexts[pmcid]||'';
|
||
const p=papers.find(x=>'PMC'+String(x.pmcid||'').replace(/^PMC/i,'')===pmcid);
|
||
const title=p?.title||pmcid;
|
||
let block=`[${pmcid}] ${title}`;
|
||
if(meta.abstract) block+=`\n초록: ${meta.abstract.slice(0,800)}`;
|
||
if(txt) block+=`\n본문 발췌: ${txt.slice(0,2000)}`;
|
||
contentBlocks.push(block);
|
||
}
|
||
|
||
const imgList=extractedImages
|
||
.filter(img=>(imageCaptions[img.url]||'')!=='제외')
|
||
.slice(0,30)
|
||
.map((img,i)=>{const cap=imageCaptions[img.url]?` — ${imageCaptions[img.url]}`:'';return `${i+1}. ${img.url} (${img.label})${cap}`;})
|
||
.join('\n');
|
||
|
||
const prompt=`[PPTX 워크플로 시작]\n요청: ${topic}\nPMCIDs: ${pmcids}\n템플릿: ${tmpl}${skin?'\n배경 스킨: '+skin:''}\n슬라이드 수: ${slides}장\n\n=== 논문 내용 ===\n${contentBlocks.join('\n\n---\n')}\n\n=== 추출된 이미지 ===\n${imgList||'없음'}\n\n위 논문 내용과 이미지를 활용해 슬라이드 아웃라인을 \`\`\`pptx-outline 블록으로 출력해주세요. create_presentation은 아직 호출하지 마세요.`;
|
||
|
||
try{
|
||
const r=await fetch('/api/chat',{method:'POST',credentials:'include',headers:{'Content-Type':'application/json','Accept':'text/event-stream'},body:JSON.stringify({message:prompt,history:[],useTools:true,model:'mistral-large-3:675b-cloud'})});
|
||
if(!r.ok) throw new Error('API '+r.status);
|
||
const reader=r.body.getReader(),dec=new TextDecoder();
|
||
let buf='',reply='';
|
||
while(true){
|
||
const {done,value}=await reader.read(); if(done)break;
|
||
buf+=dec.decode(value,{stream:true});
|
||
const lines=buf.split('\n'); buf=lines.pop();
|
||
for(const line of lines){
|
||
if(!line.startsWith('data: '))continue;
|
||
try{ const ev=JSON.parse(line.slice(6));
|
||
if(ev.type==='reply'||ev.type==='final_reply') reply=ev.reply||ev.content||reply;
|
||
if((ev.type==='token'||ev.type==='text_delta')&&ev.text) reply+=ev.text;
|
||
}catch{}
|
||
}
|
||
}
|
||
const m=reply.match(/```pptx-outline\s*\n([\s\S]*?)```/);
|
||
stopTimer();
|
||
if(m){ outline=JSON.parse(m[1]); if(outline.slides) outline.slides=cleanSlideTexts(outline.slides); renderOutline(); document.getElementById('s3create').disabled=false; setMsg(3,'아웃라인 준비 ('+(outline.slides||[]).length+'장)','var(--green)');
|
||
const projInp=document.getElementById('cfg-project'); if(projInp&&!projInp.value&&outline.project) projInp.value=outline.project;
|
||
saveOutlineToProject(); }
|
||
else setMsg(3,'pptx-outline 블록 없음. 다시 시도하세요.','var(--yellow)');
|
||
}catch(e){ stopTimer(); setMsg(3,'오류: '+e.message,'var(--red)'); }
|
||
finally{ clearInterval(phTimer); }
|
||
}
|
||
|
||
const TICON={title:'🏷',section:'📂',content:'📝',image:'🖼',table:'📊',chart:'📈',timeline:'⏳',compare:'⚖'};
|
||
|
||
// dark skin names (navy/charcoal계열 — pptx_gen.py DARK_SKIN_NAMES 와 동기화)
|
||
const DARK_SKIN_NAMES=new Set(['navy','charcoal','midnight','ocean','sunset','forest_green',
|
||
'mint','slate','burgundy','moss','plum','deep_red','teal','emerald',
|
||
'Skin_film','Skin_theater',
|
||
'Skin_slate','Skin_navy','Skin_burgundy','Skin_moss','Skin_plum','Skin_deep_red']);
|
||
|
||
function skinColors(v){
|
||
const sk=SKINS.find(s=>s.v===v);
|
||
const bg=sk?sk.c:'#334155';
|
||
const hex=bg.replace('#','');
|
||
let dark=DARK_SKIN_NAMES.has(v||'');
|
||
if(!dark && /^[0-9a-f]{6}$/i.test(hex)){
|
||
const r=parseInt(hex.slice(0,2),16),g=parseInt(hex.slice(2,4),16),b=parseInt(hex.slice(4,6),16);
|
||
dark=(0.299*r+0.587*g+0.114*b)<140;
|
||
}
|
||
// 스킨 이미지 URL — /api/ppt/skins/<name> 엔드포인트
|
||
const bgImg=v?`/api/ppt/skins/${encodeURIComponent(v)}`:'';
|
||
return {bg, bgImg, txt:dark?'#ffffff':'#1a1a2e', muted:dark?'rgba(255,255,255,.6)':'rgba(0,0,0,.45)', dark};
|
||
}
|
||
|
||
function renderOutline(){
|
||
const c=document.getElementById('outline-cards'); if(!c||!outline)return;
|
||
const slides=outline.slides||[];
|
||
c.innerHTML='<div class="slide-eg">'+slides.map((s,i)=>slideCardHtml(s,i)).join('')
|
||
+`<div style="padding:8px 0;text-align:center"><button class="btn bg" style="font-size:12px;padding:5px 18px" onclick="slideInsert(${slides.length})">+ 슬라이드 추가</button></div>`
|
||
+'</div>';
|
||
}
|
||
|
||
function slideCardHtml(s,i){
|
||
const ed=outlineEdits[i]||{};
|
||
const title=ed.title!==undefined?ed.title:(s.title||'');
|
||
const bullets=ed.bullets!==undefined?ed.bullets:(s.bullets||[]);
|
||
const imgUrl=ed.image_url!==undefined?ed.image_url:(s.image_url||'');
|
||
const bg=ed.background!==undefined?ed.background:(s.background||selectedSkin||'');
|
||
const fs=ed.font_size||s.font_size||18;
|
||
const icon=TICON[s.type]||'📄';
|
||
const open=selectedSlide===i;
|
||
const {bg:bgc,bgImg:bgImg2,txt,muted}=skinColors(bg);
|
||
const miniStyle=bgImg2?`background:${bgc};background-image:url(${bgImg2});background-size:cover;background-position:center`:`background:${bgc}`;
|
||
const sk=SKINS.find(x=>x.v===bg);
|
||
const skLabel=sk?sk.label:'기본';
|
||
const skinOpts=SKINS.map(sk=>`<option value="${esc(sk.v)}" ${bg===sk.v?'selected':''}>${sk.label}</option>`).join('');
|
||
const curType=ed.type||s.type||'content';
|
||
const curLayout=ed.layout||s.layout||'split-right';
|
||
const SLIDE_TYPES=[
|
||
{v:'title', l:'🏷 표지'},
|
||
{v:'content', l:'📝 내용'},
|
||
{v:'section', l:'📂 섹션'},
|
||
{v:'image', l:'🖼 이미지'},
|
||
{v:'table', l:'📊 표'},
|
||
{v:'chart', l:'📈 차트'},
|
||
{v:'timeline', l:'⏳ 타임라인'},
|
||
{v:'compare', l:'⚖ 비교'},
|
||
{v:'blank', l:'⬜ 빈 슬라이드'},
|
||
];
|
||
const LAYOUTS=[
|
||
{v:'split-right', l:'이미지 오른쪽'},
|
||
{v:'split-left', l:'이미지 왼쪽'},
|
||
{v:'text', l:'텍스트만'},
|
||
{v:'fullscreen', l:'이미지 전체'},
|
||
];
|
||
const typeOpts=SLIDE_TYPES.map(t=>`<option value="${t.v}" ${curType===t.v?'selected':''}>${t.l}</option>`).join('');
|
||
const layoutOpts=LAYOUTS.map(t=>`<option value="${t.v}" ${curLayout===t.v?'selected':''}>${t.l}</option>`).join('');
|
||
const hasBul=curType==='content'||curType==='compare'||curType==='image';
|
||
const bulPreview=bullets.slice(0,4).map(b=>{
|
||
const subM=String(b||'').match(/^[-–—]\s+(.*)/); const isSub=!!subM; const txt=isSub?esc(subM[1]):esc(String(b||''));
|
||
const dot=isSub?'–':'•'; const pad=isSub?'padding-left:8px;':'';
|
||
return `<div style="font-size:${Math.min(fs*.52,10)}px;padding:1px 0;${pad}">${dot} ${txt}</div>`;
|
||
}).join('');
|
||
const imgPreview=imgUrl?`<img class="slide-mini-img" src="${esc(imgUrl)}" onerror="this.style.display='none'">`:'';
|
||
return `<div class="scard${open?' open':''}" id="sc${i}">
|
||
<div class="scard-hd" onclick="selectSlide(${i})">
|
||
<div class="snum">${i+1}</div>
|
||
<span style="font-size:12px">${icon}</span>
|
||
<span class="stype-tag">${s.type}</span>
|
||
<span style="flex:1"></span>
|
||
<span style="font-size:10px;color:var(--muted)">${esc(skLabel)}</span>
|
||
<span style="font-size:10px;color:var(--muted);margin-left:4px">${fs}pt</span>
|
||
<button class="btn bg" style="font-size:10px;padding:2px 7px;margin-left:6px" title="위로" onclick="event.stopPropagation();slideMove(${i},-1)">↑</button>
|
||
<button class="btn bg" style="font-size:10px;padding:2px 7px" title="아래로" onclick="event.stopPropagation();slideMove(${i},1)">↓</button>
|
||
<button class="btn bg" style="font-size:10px;padding:2px 7px" title="앞에 삽입" onclick="event.stopPropagation();slideInsert(${i})">+</button>
|
||
<button class="btn bg" style="font-size:10px;padding:2px 7px;color:var(--red)" title="삭제" onclick="event.stopPropagation();slideDelete(${i})">✕</button>
|
||
<button class="btn bp" style="font-size:10px;padding:2px 9px;margin-left:4px" onclick="event.stopPropagation();openSed(${i})">✏ 편집</button>
|
||
</div>
|
||
<div class="slide-mini" id="sm${i}" style="${miniStyle}" onclick="selectSlide(${i})">
|
||
${imgPreview}
|
||
<div style="position:relative;z-index:1;max-width:${imgUrl?'58%':'100%'}">
|
||
<div class="slide-mini-title" id="spt${i}" style="font-size:${Math.min(fs*.72,15)}px;color:${txt}">${esc(title)||`<span style="opacity:.3">제목 없음</span>`}</div>
|
||
${hasBul&&bulPreview?`<div class="slide-mini-bullets" id="spb${i}" style="color:${muted}">${bulPreview}</div>`:''}
|
||
</div>
|
||
</div>
|
||
${open?`<div class="slide-edit-area" onclick="event.stopPropagation()">
|
||
<div class="slide-edit-row" style="margin-bottom:8px">
|
||
<div style="flex:1;min-width:100px">
|
||
<label class="elabel">슬라이드 유형</label>
|
||
<select class="sin" style="padding:4px 6px;font-size:12px" onchange="eType(${i},this.value)">${typeOpts}</select>
|
||
</div>
|
||
<div style="flex:1;min-width:100px">
|
||
<label class="elabel">레이아웃</label>
|
||
<select class="sin" style="padding:4px 6px;font-size:12px" onchange="eLayout(${i},this.value)">${layoutOpts}</select>
|
||
</div>
|
||
</div>
|
||
<div>
|
||
<label class="elabel">제목</label>
|
||
<input class="sin" id="sei${i}" value="${esc(title)}" oninput="eTitle(${i},this.value)" placeholder="슬라이드 제목">
|
||
</div>
|
||
${hasBul?`<div style="margin-top:8px">
|
||
<label class="elabel">내용</label>
|
||
<textarea class="sbul-ta" oninput="eBul(${i},this.value)">${esc(bullets.join('\n\n'))}</textarea>
|
||
<div style="font-size:10px;color:var(--muted);margin-top:2px;line-height:1.5">Enter=같은 항목 · 빈 줄=새 항목 ● · -시작=하위 –</div>
|
||
</div>`:''}
|
||
<div class="slide-edit-row">
|
||
<div style="flex:1;min-width:100px">
|
||
<label class="elabel">배경 스킨</label>
|
||
<select class="sin" style="padding:4px 6px;font-size:12px" onchange="eBg(${i},this.value)">${skinOpts}</select>
|
||
</div>
|
||
<div style="width:80px">
|
||
<label class="elabel">폰트 크기</label>
|
||
<div style="display:flex;align-items:center;gap:3px">
|
||
<input type="number" min="10" max="40" class="sin" style="padding:4px 6px;font-size:12px;text-align:center" value="${fs}" oninput="eFontSize(${i},this.value)">
|
||
<span style="font-size:10px;color:var(--muted)">pt</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div style="display:flex;gap:6px;align-items:center;margin-top:10px">
|
||
${imgUrl?`<img src="${esc(imgUrl)}" style="width:52px;height:38px;object-fit:cover;border-radius:4px;border:1px solid var(--line);cursor:pointer" onclick="showImgPreview('${esc(imgUrl)}','')" onerror="this.style.display='none'">`:`<div style="width:52px;height:38px;border:1px dashed var(--line);border-radius:4px;display:flex;align-items:center;justify-content:center;color:var(--muted)">🖼</div>`}
|
||
<button class="pickbtn" onclick="openIPicker(${i})">이미지 선택</button>
|
||
${imgUrl?`<button class="pickbtn" style="color:var(--red)" onclick="clearImg(${i})">✕ 제거</button>`:''}
|
||
</div>
|
||
</div>`:''}
|
||
</div>`;
|
||
}
|
||
|
||
function slideApplyEdits() {
|
||
// outlineEdits를 outline.slides에 병합한 뒤 edits 초기화
|
||
outline.slides = outline.slides.map((s,i) => {
|
||
const ed = outlineEdits[i]||{};
|
||
return Object.assign({}, s,
|
||
ed.title!==undefined?{title:ed.title}:{},
|
||
ed.subtitle!==undefined?{subtitle:ed.subtitle}:{},
|
||
ed.bullets!==undefined?{bullets:ed.bullets}:{},
|
||
ed.image_url!==undefined?{image_url:ed.image_url}:{},
|
||
ed.image_search!==undefined?{image_search:ed.image_search}:{},
|
||
ed.background!==undefined&&ed.background?{background:ed.background}:{},
|
||
ed.bg_image!==undefined?{bg_image:ed.bg_image}:{},
|
||
ed.bg_img_url!==undefined?{bg_img_url:ed.bg_img_url}:{},
|
||
ed.font_family!==undefined?{font_family:ed.font_family}:{},
|
||
ed.title_color!==undefined?{title_color:ed.title_color}:{},
|
||
ed.body_color!==undefined?{body_color:ed.body_color}:{},
|
||
ed.img_filter!==undefined?{img_filter:ed.img_filter}:{},
|
||
ed.img_blur_radius!==undefined?{img_blur_radius:ed.img_blur_radius}:{},
|
||
ed.img_corner_radius!==undefined?{img_corner_radius:ed.img_corner_radius}:{},
|
||
ed.font_size!==undefined?{font_size:ed.font_size}:{},
|
||
ed.title_size!==undefined?{title_size:ed.title_size}:{},
|
||
ed.img_scale!==undefined?{img_scale:ed.img_scale}:{},
|
||
ed.img_x!=null?{img_x:ed.img_x}:{},
|
||
ed.img_y!=null?{img_y:ed.img_y}:{},
|
||
ed.img_w!=null?{img_w:ed.img_w}:{},
|
||
ed.img_h!=null?{img_h:ed.img_h}:{},
|
||
ed.type!==undefined?{type:ed.type}:{},
|
||
ed.layout!==undefined?{layout:ed.layout}:{},
|
||
ed.text_y!=null?{text_y:ed.text_y}:{}
|
||
);
|
||
});
|
||
outlineEdits = {};
|
||
}
|
||
function slideMove(i, dir) {
|
||
const slides = outline.slides||[]; const j = i+dir;
|
||
if(j<0||j>=slides.length) return;
|
||
slideApplyEdits();
|
||
[slides[i],slides[j]] = [slides[j],slides[i]];
|
||
selectedSlide = j;
|
||
renderOutline();
|
||
setTimeout(()=>{ const el=document.getElementById('sc'+j); if(el) el.scrollIntoView({behavior:'smooth',block:'nearest'}); },50);
|
||
}
|
||
function slideDelete(i) {
|
||
const slides = outline.slides||[]; if(slides.length<=1) return;
|
||
slideApplyEdits();
|
||
slides.splice(i,1);
|
||
selectedSlide = -1;
|
||
renderOutline();
|
||
setMsg(3, '슬라이드 '+slides.length+'장', 'var(--muted)');
|
||
}
|
||
function slideInsert(i) {
|
||
slideApplyEdits();
|
||
const slides = outline.slides||[];
|
||
const newSlide = {type:'content', title:'새 슬라이드', bullets:[], background: slides[i]?.background||''};
|
||
slides.splice(i, 0, newSlide);
|
||
selectedSlide = i;
|
||
renderOutline();
|
||
setTimeout(()=>{ const el=document.getElementById('sc'+i); if(el) el.scrollIntoView({behavior:'smooth',block:'nearest'}); },50);
|
||
}
|
||
|
||
function selectSlide(i){
|
||
selectedSlide=selectedSlide===i?-1:i;
|
||
renderOutline();
|
||
if(selectedSlide>=0){ const el=document.getElementById('sc'+i); if(el)setTimeout(()=>el.scrollIntoView({behavior:'smooth',block:'nearest'}),50); }
|
||
}
|
||
|
||
function eType(i,v){
|
||
if(!outlineEdits[i])outlineEdits[i]={};
|
||
outlineEdits[i].type=v;
|
||
if(outline.slides[i]) outline.slides[i].type=v;
|
||
renderOutline(); selectSlide(i);
|
||
}
|
||
function eLayout(i,v){
|
||
if(!outlineEdits[i])outlineEdits[i]={};
|
||
outlineEdits[i].layout=v;
|
||
if(outline.slides[i]) outline.slides[i].layout=v;
|
||
}
|
||
function eTitle(i,v){
|
||
if(!outlineEdits[i])outlineEdits[i]={}; outlineEdits[i].title=v;
|
||
const el=document.getElementById('spt'+i); if(el) el.textContent=v||'';
|
||
}
|
||
function eBul(i,v){
|
||
if(!outlineEdits[i])outlineEdits[i]={};
|
||
const _groups=v.split(/\n\n+/);
|
||
const arr=_groups.map(g=>g.replace(/^\n+|\n+$/g,'')).filter(g=>g.trim()!=='');
|
||
outlineEdits[i].bullets=arr;
|
||
const el=document.getElementById('spb'+i);
|
||
if(el){ const fs=outlineEdits[i]?.font_size||(outline?.slides?.[i]?.font_size)||18; el.innerHTML=arr.slice(0,4).map(b=>{
|
||
const subM=String(b).match(/^[-–—]\s+(.*)/); const isSub=!!subM; const txt=isSub?esc(subM[1]):esc(String(b));
|
||
const dot=isSub?'–':'•'; const pad=isSub?'padding-left:8px;':'';
|
||
return `<div style="font-size:${Math.min(fs*.52,10)}px;padding:1px 0;${pad}">${dot} ${txt}</div>`;
|
||
}).join(''); }
|
||
}
|
||
function eBg(i,v){
|
||
if(!outlineEdits[i])outlineEdits[i]={}; outlineEdits[i].background=v;
|
||
const sk=SKINS.find(s=>s.v===v)||SKINS[0];
|
||
const mini=document.getElementById('sm'+i); if(mini) mini.style.background=sk.c;
|
||
const {txt,muted}=skinColors(v);
|
||
const pt=document.getElementById('spt'+i); if(pt) pt.style.color=txt;
|
||
const pb=document.getElementById('spb'+i); if(pb) pb.style.color=muted;
|
||
}
|
||
function eFontSize(i,v){
|
||
const n=parseInt(v); if(!outlineEdits[i])outlineEdits[i]={};
|
||
outlineEdits[i].font_size=isNaN(n)?18:Math.min(40,Math.max(10,n));
|
||
const el=document.getElementById('spt'+i); if(el) el.style.fontSize=Math.min(n*.72,15)+'px';
|
||
}
|
||
function clearImg(i){ if(!outlineEdits[i])outlineEdits[i]={}; outlineEdits[i].image_url=''; outlineEdits[i].image_search=''; renderOutline(); }
|
||
|
||
// ── Image picker ─────────────────────────────────────────────────────────────
|
||
function _pickerGridHtml(imgs, allowDelete){
|
||
if(!imgs.length) return '<div style="color:var(--muted);font-size:12px;padding:12px;text-align:center">이미지가 없습니다</div>';
|
||
return imgs.map((img,i)=>`<div class="icard" title="${esc(img.label)}" style="cursor:pointer">
|
||
<img src="${esc(img.url)}" loading="lazy" onerror="this.style.display='none'" onclick="pickImg('${esc(img.url)}')" style="cursor:pointer">
|
||
<div class="ilabel" style="display:flex;gap:4px;align-items:center;padding:3px 5px">
|
||
<span style="flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-size:9px">${esc(img.label)}</span>
|
||
<button style="font-size:9px;padding:1px 4px;background:rgba(255,255,255,.15);border:none;color:#fff;border-radius:3px;cursor:pointer;flex-shrink:0" onclick="event.stopPropagation();showImgPreview('${esc(img.url)}','${esc(img.label)}')">🔍</button>
|
||
${allowDelete?`<button style="font-size:9px;padding:1px 4px;background:rgba(239,68,68,.75);border:none;color:#fff;border-radius:3px;cursor:pointer;flex-shrink:0" onclick="event.stopPropagation();deleteImgRefreshPicker(${i})" title="삭제">✕</button>`:''}
|
||
</div>
|
||
</div>`).join('');
|
||
}
|
||
|
||
async function _loadPickerImages(g){
|
||
if(extractedImages.length){
|
||
g.innerHTML=_pickerGridHtml(extractedImages, true);
|
||
return;
|
||
}
|
||
// 추출 이미지 없으면 프로젝트 폴더 이미지 자동 로드
|
||
const proj=(document.getElementById('cfg-project')?.value||'').trim()||outline?.project||existingProject||'';
|
||
if(!proj){ g.innerHTML='<div style="color:var(--muted);font-size:12px;padding:12px;text-align:center">추출된 이미지가 없습니다.<br>📷 자동추출을 먼저 실행하세요.</div>'; return; }
|
||
g.innerHTML='<div style="color:var(--muted);font-size:12px;padding:12px;text-align:center">⏳ 폴더 이미지 로딩...</div>';
|
||
try{
|
||
const r=await fetch(`/api/pptx/project-images?project=${encodeURIComponent(proj)}`,{...jh()});
|
||
const d=await r.json();
|
||
const imgs=(d.images||[]).map(f=>({url:'/api/files/'+f.path,label:f.name}));
|
||
if(imgs.length){
|
||
g.innerHTML=_pickerGridHtml(imgs, false);
|
||
} else {
|
||
g.innerHTML='<div style="color:var(--muted);font-size:12px;padding:12px;text-align:center">이미지 파일 없음<br>📷 자동추출을 먼저 실행하세요.</div>';
|
||
}
|
||
}catch{
|
||
g.innerHTML='<div style="color:var(--muted);font-size:12px;padding:12px;text-align:center">이미지 로드 실패</div>';
|
||
}
|
||
}
|
||
|
||
async function openIPicker(idx){
|
||
modalSlide=idx;
|
||
pickerMode='image';
|
||
const g=document.getElementById('imodal-grid');
|
||
document.getElementById('ibg').classList.add('open');
|
||
await _loadPickerImages(g);
|
||
}
|
||
let pickerMode='image'; // 'image' | 'background'
|
||
|
||
function pickImg(url){
|
||
if(modalSlide>=0){
|
||
if(pickerMode==='background'){
|
||
sedDirty=true;
|
||
if(!outlineEdits[modalSlide])outlineEdits[modalSlide]={};
|
||
outlineEdits[modalSlide].bg_img_url=url;
|
||
// workspace-relative path for Python: strip /api/files/ prefix
|
||
const bgPath=url.startsWith('/api/files/')?url.slice('/api/files/'.length):url;
|
||
outlineEdits[modalSlide].bg_image=bgPath;
|
||
if(editorIdx===modalSlide) _sedUpdateBgImgThumb(url);
|
||
sedRenderPreview();
|
||
} else {
|
||
if(editorIdx===modalSlide) sedDirty=true;
|
||
if(!outlineEdits[modalSlide])outlineEdits[modalSlide]={};
|
||
outlineEdits[modalSlide].image_url=url;
|
||
renderOutline();
|
||
if(editorIdx===modalSlide){
|
||
const thumb=document.getElementById('sed-img-thumb');
|
||
if(thumb){thumb.style.backgroundImage=`url(${url})`;thumb.textContent='';}
|
||
const urlInp=document.getElementById('sed-img-url-inp');
|
||
if(urlInp) urlInp.value=url;
|
||
sedRenderPreview();
|
||
}
|
||
}
|
||
}
|
||
closeIModal();
|
||
}
|
||
|
||
function _sedUpdateBgImgThumb(url){
|
||
const th=document.getElementById('sed-bg-img-thumb');
|
||
const cl=document.getElementById('sed-bg-img-clear');
|
||
if(!th) return;
|
||
if(url){ th.style.backgroundImage=`url(${url})`; th.textContent=''; if(cl)cl.style.display=''; }
|
||
else { th.style.backgroundImage=''; th.textContent='🖼'; if(cl)cl.style.display='none'; }
|
||
}
|
||
|
||
async function sedPickBgImg(){
|
||
const i=editorIdx; if(i<0)return;
|
||
modalSlide=i;
|
||
pickerMode='background';
|
||
const g=document.getElementById('imodal-grid');
|
||
document.getElementById('ibg').classList.add('open');
|
||
await _loadPickerImages(g);
|
||
}
|
||
|
||
function sedClearBgImg(){
|
||
const i=editorIdx; if(i<0)return;
|
||
sedDirty=true;
|
||
if(!outlineEdits[i])outlineEdits[i]={};
|
||
outlineEdits[i].bg_img_url='';
|
||
outlineEdits[i].bg_image='';
|
||
_sedUpdateBgImgThumb('');
|
||
sedRenderPreview();
|
||
}
|
||
function closeIModal(){ document.getElementById('ibg').classList.remove('open'); }
|
||
|
||
function showImgPreview(url, label) {
|
||
document.getElementById('lb-img').src = url;
|
||
document.getElementById('lb-label').textContent = label || '';
|
||
document.getElementById('lbg').classList.add('open');
|
||
}
|
||
function closeImgPreview() { document.getElementById('lbg').classList.remove('open'); }
|
||
|
||
// ── PDF Capture viewer ───────────────────────────────────────────────────────
|
||
let pdfCapState = { path:'', name:'', page:0, total:1, sel:null, dragging:false, startX:0, startY:0, zoom:1.0, baseScale:1.0 };
|
||
|
||
function openPdfCapture(pdfPath, name) {
|
||
pdfCapState = { path:pdfPath, name:name||pdfPath, page:0, total:1, sel:null, dragging:false, startX:0, startY:0, zoom:1.0, baseScale:1.0 };
|
||
document.getElementById('pdf-modal-name').textContent = pdfCapState.name;
|
||
document.getElementById('pdf-zoom-lbl').textContent = '100%';
|
||
document.getElementById('pdfbg').classList.add('open');
|
||
pdfLoadPage(0);
|
||
const cv = document.getElementById('pdf-main-canvas');
|
||
cv.onmousedown = pdfSelStart;
|
||
cv.onmousemove = pdfSelMove;
|
||
cv.onmouseup = pdfSelEnd;
|
||
cv.onmouseleave= e => { if(pdfCapState.dragging) pdfSelEnd(e); };
|
||
cv.ontouchstart= e => { e.preventDefault(); const t=e.touches[0],r=cv.getBoundingClientRect(); pdfSelStart({clientX:t.clientX,clientY:t.clientY,_rect:r}); };
|
||
cv.ontouchmove = e => { e.preventDefault(); const t=e.touches[0],r=cv.getBoundingClientRect(); pdfSelMove({clientX:t.clientX,clientY:t.clientY,_rect:r}); };
|
||
cv.ontouchend = e => { e.preventDefault(); pdfSelEnd(null); };
|
||
const wrap = document.getElementById('pdf-canvas-wrap');
|
||
wrap.onwheel = e => {
|
||
e.preventDefault();
|
||
const delta = e.deltaY < 0 ? 0.12 : -0.12;
|
||
pdfCapState.zoom = Math.min(5, Math.max(0.2, pdfCapState.zoom + delta));
|
||
pdfApplyZoom();
|
||
};
|
||
}
|
||
|
||
// convert mouse event to canvas pixel coords
|
||
function pdfEvCoords(e) {
|
||
const cv=document.getElementById('pdf-main-canvas');
|
||
const rect=e._rect||cv.getBoundingClientRect();
|
||
const scaleX=cv.width/rect.width, scaleY=cv.height/rect.height;
|
||
return { x:(e.clientX-rect.left)*scaleX, y:(e.clientY-rect.top)*scaleY };
|
||
}
|
||
|
||
function pdfRedraw() {
|
||
const cv=document.getElementById('pdf-main-canvas');
|
||
const ctx=cv.getContext('2d');
|
||
ctx.clearRect(0,0,cv.width,cv.height);
|
||
if(pdfCapState.bmp) ctx.drawImage(pdfCapState.bmp,0,0);
|
||
const s=pdfCapState.sel;
|
||
if(s){
|
||
ctx.strokeStyle='#6366f1'; ctx.lineWidth=Math.max(2,cv.width/400); ctx.setLineDash([6,3]);
|
||
ctx.strokeRect(s.x,s.y,s.w,s.h);
|
||
ctx.fillStyle='rgba(99,102,241,0.14)'; ctx.fillRect(s.x,s.y,s.w,s.h);
|
||
}
|
||
}
|
||
|
||
async function pdfLoadPage(page) {
|
||
const msg=document.getElementById('pdf-load-msg');
|
||
const cv=document.getElementById('pdf-main-canvas');
|
||
msg.style.display=''; cv.style.display='none';
|
||
pdfClearSel(true);
|
||
const url=`/api/pptx/pdf-page?path=${encodeURIComponent(pdfCapState.path)}&page=${page}&dpi=150`;
|
||
try{
|
||
const r=await fetch(url,{credentials:'include'});
|
||
if(!r.ok) throw new Error('HTTP '+r.status);
|
||
const total=parseInt(r.headers.get('X-Page-Count')||'1');
|
||
pdfCapState.total=total; pdfCapState.page=page;
|
||
document.getElementById('pdf-page-lbl').textContent=`${page+1} / ${total}`;
|
||
const blob=await r.blob();
|
||
const bmp=await createImageBitmap(blob);
|
||
pdfCapState.bmp=bmp;
|
||
cv.width=bmp.width; cv.height=bmp.height;
|
||
// 캔버스를 창 크기에 맞게 CSS 스케일 (원본 해상도 유지, 표시만 축소)
|
||
const wrap=document.getElementById('pdf-canvas-wrap');
|
||
const maxW=wrap.clientWidth-4, maxH=wrap.clientHeight-4;
|
||
const baseScale=Math.min(1, maxW/bmp.width, maxH/bmp.height);
|
||
pdfCapState.baseScale=baseScale;
|
||
cv.style.width=Math.round(bmp.width*baseScale*pdfCapState.zoom)+'px';
|
||
cv.style.height=Math.round(bmp.height*baseScale*pdfCapState.zoom)+'px';
|
||
pdfRedraw();
|
||
msg.style.display='none'; cv.style.display='block';
|
||
}catch(e){ msg.textContent='로드 실패: '+e.message; }
|
||
}
|
||
|
||
function pdfApplyZoom() {
|
||
const cv=document.getElementById('pdf-main-canvas');
|
||
const bmp=pdfCapState.bmp;
|
||
if(!bmp) return;
|
||
const z=pdfCapState.zoom;
|
||
cv.style.width=Math.round(bmp.width*pdfCapState.baseScale*z)+'px';
|
||
cv.style.height=Math.round(bmp.height*pdfCapState.baseScale*z)+'px';
|
||
document.getElementById('pdf-zoom-lbl').textContent=Math.round(z*100)+'%';
|
||
}
|
||
function pdfZoomStep(dir) {
|
||
pdfCapState.zoom=Math.min(5,Math.max(0.2,pdfCapState.zoom+dir*0.2));
|
||
pdfApplyZoom();
|
||
}
|
||
function pdfZoomReset() { pdfCapState.zoom=1.0; pdfApplyZoom(); }
|
||
|
||
function pdfPrevPage(){ if(pdfCapState.page>0) pdfLoadPage(pdfCapState.page-1); }
|
||
function pdfNextPage(){ if(pdfCapState.page<pdfCapState.total-1) pdfLoadPage(pdfCapState.page+1); }
|
||
|
||
function pdfSelStart(e) {
|
||
const {x,y}=pdfEvCoords(e);
|
||
pdfCapState.dragging=true; pdfCapState.startX=x; pdfCapState.startY=y;
|
||
pdfCapState.sel=null;
|
||
document.getElementById('pdf-capture-btn').disabled=true;
|
||
document.getElementById('pdf-sel-info').textContent='';
|
||
pdfRedraw();
|
||
}
|
||
function pdfSelMove(e) {
|
||
if(!pdfCapState.dragging)return;
|
||
const {x,y}=pdfEvCoords(e);
|
||
const rx=Math.min(pdfCapState.startX,x), ry=Math.min(pdfCapState.startY,y);
|
||
const rw=Math.abs(x-pdfCapState.startX), rh=Math.abs(y-pdfCapState.startY);
|
||
pdfCapState.sel={x:rx,y:ry,w:rw,h:rh};
|
||
pdfRedraw();
|
||
}
|
||
function pdfSelEnd(e) {
|
||
if(!pdfCapState.dragging)return;
|
||
pdfCapState.dragging=false;
|
||
if(e){ const {x,y}=pdfEvCoords(e);
|
||
const rx=Math.min(pdfCapState.startX,x), ry=Math.min(pdfCapState.startY,y);
|
||
const rw=Math.abs(x-pdfCapState.startX), rh=Math.abs(y-pdfCapState.startY);
|
||
pdfCapState.sel={x:rx,y:ry,w:rw,h:rh};
|
||
}
|
||
const cv=document.getElementById('pdf-main-canvas');
|
||
const s=pdfCapState.sel;
|
||
if(s&&s.w>8&&s.h>8){
|
||
pdfCapState.sel=Object.assign(s,{fx:s.x/cv.width,fy:s.y/cv.height,fw:s.w/cv.width,fh:s.h/cv.height});
|
||
document.getElementById('pdf-capture-btn').disabled=false;
|
||
document.getElementById('pdf-sel-info').textContent=`${Math.round(s.w)}×${Math.round(s.h)}px 선택됨`;
|
||
} else { pdfCapState.sel=null; }
|
||
pdfRedraw();
|
||
}
|
||
function pdfClearSel(silent) {
|
||
pdfCapState.sel=null; pdfCapState.dragging=false;
|
||
document.getElementById('pdf-capture-btn').disabled=true;
|
||
document.getElementById('pdf-sel-info').textContent='';
|
||
if(!silent) pdfRedraw();
|
||
}
|
||
// PDF figures 자동 추출 → extractedImages에 추가 → 이미지 피커 열기
|
||
async function sedAutoExtract(){
|
||
let pdfs=Object.entries(prepLog)
|
||
.filter(([,s])=>s.pdfPath)
|
||
.map(([k,s])=>({key:k,path:s.pdfPath,name:k.startsWith('UPL_')?s.pdfPath.split('/').pop():k}));
|
||
|
||
// prepLog가 비었으면 프로젝트 폴더에서 PDF 검색
|
||
if(!pdfs.length){
|
||
const proj=(document.getElementById('cfg-project')?.value||'').trim() || outline?.project || existingProject || '';
|
||
if(proj){
|
||
try{
|
||
const r=await fetch(`/api/pptx/project-pdfs?project=${encodeURIComponent(proj)}`,{...jh()});
|
||
const d=await r.json();
|
||
if(d.pdfs?.length) pdfs=d.pdfs;
|
||
}catch{}
|
||
}
|
||
}
|
||
if(!pdfs.length){ alert('PDF를 찾을 수 없습니다.\n2단계에서 논문을 준비하거나 프로젝트 폴더에 PDF를 추가하세요.'); return; }
|
||
|
||
const thumb=document.getElementById('sed-img-thumb');
|
||
const savedBg=thumb.style.backgroundImage, savedTxt=thumb.textContent;
|
||
thumb.style.backgroundImage=''; thumb.textContent='⏳';
|
||
|
||
let added=0;
|
||
for(const pdf of pdfs){
|
||
try{
|
||
const r=await fetch('/api/pptx/extract-images',{...jh(),method:'POST',body:JSON.stringify({pdf_path:pdf.path})});
|
||
const d=await r.json();
|
||
if(d.success&&d.data?.files){
|
||
for(const f of d.data.files){
|
||
const url='/api/files/'+f;
|
||
if(!extractedImages.find(img=>img.url===url)){
|
||
extractedImages.push({url,label:pdf.name.replace(/\.pdf$/i,'')+': '+f.split('/').pop(),pdf:pdf.path});
|
||
added++;
|
||
}
|
||
}
|
||
}
|
||
}catch{}
|
||
}
|
||
renderImgGrid();
|
||
document.getElementById('imgcnt').textContent='('+extractedImages.length+')';
|
||
thumb.style.backgroundImage=savedBg; thumb.textContent=savedTxt;
|
||
|
||
if(!extractedImages.length){ alert('추출된 이미지가 없습니다. PDF에 그림/도표가 없거나 스캔본일 수 있습니다.'); return; }
|
||
sedPickImg();
|
||
}
|
||
|
||
// PDF 목록에서 캡처 열기 (sedmodal에서 호출)
|
||
async function sedOpenPdfPicker(){
|
||
let pdfs=Object.entries(prepLog)
|
||
.filter(([,s])=>s.pdfPath)
|
||
.map(([k,s])=>({key:k,path:s.pdfPath,name:k.startsWith('UPL_')?s.pdfPath.split('/').pop():k}));
|
||
|
||
// prepLog가 비었으면 프로젝트 폴더에서 PDF 검색
|
||
if(!pdfs.length){
|
||
const proj=(document.getElementById('cfg-project')?.value||'').trim() || outline?.project || existingProject || '';
|
||
if(proj){
|
||
try{
|
||
const r=await fetch(`/api/pptx/project-pdfs?project=${encodeURIComponent(proj)}`,{...jh()});
|
||
const d=await r.json();
|
||
if(d.pdfs?.length) pdfs=d.pdfs;
|
||
}catch{}
|
||
}
|
||
}
|
||
|
||
if(!pdfs.length){ alert('PDF를 찾을 수 없습니다.\n2단계에서 논문을 준비하거나 프로젝트 폴더에 PDF를 추가하세요.'); return; }
|
||
if(pdfs.length===1){ openPdfCapture(pdfs[0].path, pdfs[0].name); return; }
|
||
// 여러 PDF: 간단한 선택창
|
||
const names=pdfs.map((p,i)=>`${i+1}. ${p.name}`).join('\n');
|
||
const idx=prompt(`PDF를 선택하세요 (번호 입력):\n${names}`, '1');
|
||
if(!idx)return;
|
||
const chosen=pdfs[parseInt(idx)-1];
|
||
if(chosen) openPdfCapture(chosen.path, chosen.name);
|
||
}
|
||
|
||
async function pdfCapture() {
|
||
const s=pdfCapState; if(!s.sel)return;
|
||
const btn=document.getElementById('pdf-capture-btn');
|
||
btn.disabled=true; btn.textContent='캡처 중...';
|
||
try{
|
||
const r=await fetch('/api/pptx/pdf-crop',{...jh(),method:'POST',body:JSON.stringify({
|
||
pdf_path:s.path, page:s.page, fx:s.sel.fx, fy:s.sel.fy, fw:s.sel.fw, fh:s.sel.fh
|
||
})});
|
||
const d=await r.json();
|
||
if(!d.success) throw new Error(d.error||'캡처 실패');
|
||
const imgObj={url:d.url, label:`캡처 p${s.page+1} (${s.name})`, pdf:s.path};
|
||
extractedImages.push(imgObj);
|
||
renderImgGrid();
|
||
document.getElementById('imgcnt').textContent='('+extractedImages.length+')';
|
||
pdfClearSel();
|
||
setMsg(2,'캡처 완료 — 이미지 '+extractedImages.length+'개','var(--green)');
|
||
// sedmodal이 열려있으면 캡처 이미지를 현재 슬라이드에 바로 적용
|
||
if(editorIdx>=0){
|
||
sedDirty=true;
|
||
if(!outlineEdits[editorIdx])outlineEdits[editorIdx]={};
|
||
outlineEdits[editorIdx].image_url=imgObj.url;
|
||
outlineEdits[editorIdx].image_search='';
|
||
const thumb=document.getElementById('sed-img-thumb');
|
||
if(thumb){thumb.style.backgroundImage=`url(${imgObj.url})`;thumb.textContent='';}
|
||
const urlInp=document.getElementById('sed-img-url-inp');
|
||
if(urlInp) urlInp.value=imgObj.url;
|
||
sedRenderPreview();
|
||
closePdfModal();
|
||
}
|
||
}catch(e){ alert('캡처 실패: '+e.message); }
|
||
finally{ btn.disabled=false; btn.textContent='📷 캡처'; }
|
||
}
|
||
function closePdfModal(){ document.getElementById('pdfbg').classList.remove('open'); pdfClearSel(true); }
|
||
|
||
// ── PPTX creation ─────────────────────────────────────────────────────────────
|
||
async function createPptx(){
|
||
if(!outline)return;
|
||
const btn=document.getElementById('s3create'); btn.disabled=true; btn.textContent='생성 중...';
|
||
const stopTimer=startTimer('s3msg','⚙ PPTX 생성 중');
|
||
|
||
const sentEdits = JSON.parse(JSON.stringify(outlineEdits));
|
||
|
||
// 편집 내용을 슬라이드에 병합
|
||
const slides=(outline.slides||[]).map((s,i)=>{
|
||
const ed=outlineEdits[i]; if(!ed)return s;
|
||
return Object.assign({},s,
|
||
ed.title!==undefined?{title:ed.title}:{},
|
||
ed.subtitle!==undefined?{subtitle:ed.subtitle}:{},
|
||
ed.bullets!==undefined?{bullets:ed.bullets}:{},
|
||
ed.image_url!==undefined?{image_url:ed.image_url}:{},
|
||
ed.image_search!==undefined?{image_search:ed.image_search}:{},
|
||
ed.background!==undefined&&ed.background?{background:ed.background}:{},
|
||
ed.bg_image!==undefined?{bg_image:ed.bg_image}:{},
|
||
ed.bg_img_url!==undefined?{bg_img_url:ed.bg_img_url}:{},
|
||
ed.font_family!==undefined?{font_family:ed.font_family}:{},
|
||
ed.title_color!==undefined?{title_color:ed.title_color}:{},
|
||
ed.body_color!==undefined?{body_color:ed.body_color}:{},
|
||
ed.img_filter!==undefined?{img_filter:ed.img_filter}:{},
|
||
ed.img_blur_radius!==undefined?{img_blur_radius:ed.img_blur_radius}:{},
|
||
ed.img_corner_radius!==undefined?{img_corner_radius:ed.img_corner_radius}:{},
|
||
ed.font_size!==undefined?{font_size:ed.font_size}:{},
|
||
ed.title_size!==undefined?{title_size:ed.title_size}:{},
|
||
ed.img_scale!==undefined?{img_scale:ed.img_scale}:{},
|
||
ed.img_x!=null?{img_x:ed.img_x}:{},
|
||
ed.img_y!=null?{img_y:ed.img_y}:{},
|
||
ed.img_w!=null?{img_w:ed.img_w}:{},
|
||
ed.img_h!=null?{img_h:ed.img_h}:{},
|
||
ed.type!==undefined?{type:ed.type}:{},
|
||
ed.layout!==undefined?{layout:ed.layout}:{},
|
||
ed.text_y!=null?{text_y:ed.text_y}:{}
|
||
);
|
||
});
|
||
const cfgProject = (document.getElementById('cfg-project')?.value||'').trim();
|
||
const existingProject = pptxRelPath ? pptxRelPath.replace(/\\/g,'/').split('/')[0] : null;
|
||
const resolvedProject = cfgProject || existingProject || outline.project || null;
|
||
const spec=Object.assign({},outline,{slides},
|
||
resolvedProject ? {project: resolvedProject, title: resolvedProject} : {}
|
||
);
|
||
|
||
try{
|
||
const r=await fetch('/api/pptx/create',{method:'POST',...jh(),body:JSON.stringify({spec}),signal:AbortSignal.timeout(120000)});
|
||
const d=await r.json();
|
||
if(!r.ok||!d.success) throw new Error(d.error||'생성 실패');
|
||
|
||
stopTimer();
|
||
// 결과에서 PPTX 경로 추출
|
||
let fileUrl='';
|
||
if(d.data?.path){
|
||
const rel=d.data.path.replace(/\\/g,'/');
|
||
const wsIdx=rel.indexOf('/workspace/');
|
||
if(wsIdx>=0) fileUrl='/api/files/'+rel.slice(wsIdx+'/workspace/'.length).split('/').map(encodeURIComponent).join('/');
|
||
else fileUrl='/api/files/'+rel.split('/').map(encodeURIComponent).join('/');
|
||
}
|
||
if(!fileUrl&&d.stdout){
|
||
const m=d.stdout.match(/download_url:\s*(\/api\/files\/[^\s|]+)/);
|
||
if(m) fileUrl=m[1];
|
||
}
|
||
if(!fileUrl){
|
||
const fr=await fetch('/api/pptx/latest',cr()); const fd=await fr.json();
|
||
if(fd.found) fileUrl=fd.url;
|
||
}
|
||
if(!fileUrl) throw new Error('PPTX 경로를 찾을 수 없습니다');
|
||
|
||
// 편집 반영 완료 — outline.slides baking
|
||
Object.keys(sentEdits).forEach(k=>{
|
||
const idx=+k, ed=sentEdits[k]||{}, s=outline.slides?.[idx]; if(!s)return;
|
||
if(JSON.stringify(outlineEdits[idx]||{})!==JSON.stringify(ed)) return;
|
||
if(ed.title!==undefined) s.title=ed.title;
|
||
if(ed.subtitle!==undefined) s.subtitle=ed.subtitle;
|
||
if(ed.bullets!==undefined) s.bullets=ed.bullets;
|
||
if(ed.image_url!==undefined) s.image_url=ed.image_url;
|
||
if(ed.image_search!==undefined) s.image_search=ed.image_search;
|
||
if(ed.background) s.background=ed.background;
|
||
if(ed.bg_img_url!==undefined) s.bg_img_url=ed.bg_img_url;
|
||
if(ed.bg_image!==undefined) s.bg_image=ed.bg_image;
|
||
if(ed.font_family!==undefined) s.font_family=ed.font_family;
|
||
if(ed.title_color!==undefined) s.title_color=ed.title_color;
|
||
if(ed.body_color!==undefined) s.body_color=ed.body_color;
|
||
if(ed.img_filter!==undefined) s.img_filter=ed.img_filter;
|
||
if(ed.img_blur_radius!==undefined) s.img_blur_radius=ed.img_blur_radius;
|
||
if(ed.img_corner_radius!==undefined) s.img_corner_radius=ed.img_corner_radius;
|
||
if(ed.font_size!==undefined) s.font_size=ed.font_size;
|
||
if(ed.title_size!==undefined) s.title_size=ed.title_size;
|
||
if(ed.img_scale!==undefined) s.img_scale=ed.img_scale;
|
||
if(ed.img_x!==undefined) s.img_x=ed.img_x;
|
||
if(ed.img_y!==undefined) s.img_y=ed.img_y;
|
||
if(ed.img_w!==undefined) s.img_w=ed.img_w;
|
||
if(ed.img_h!==undefined) s.img_h=ed.img_h;
|
||
if(ed.type!==undefined) s.type=ed.type;
|
||
if(ed.layout!==undefined) s.layout=ed.layout;
|
||
if(ed.text_y!=null) s.text_y=ed.text_y;
|
||
delete outlineEdits[idx];
|
||
});
|
||
if(Object.keys(outlineEdits).length===0)
|
||
document.querySelectorAll('.preview-card[data-edited]').forEach(c=>delete c.dataset.edited);
|
||
|
||
const relPath=decodeURIComponent(fileUrl.replace(/^\/api\/files\//,''));
|
||
pptxRelPath=relPath;
|
||
const dlBtn=document.getElementById('dl-btn');
|
||
dlBtn.href=fileUrl; dlBtn.style.display='inline-flex'; dlBtn.download=relPath.split('/').pop()||'slides.pptx';
|
||
setMsg(3,'✅ 생성 완료!','var(--green)');
|
||
// 새로고침 후 복원을 위해 세션 저장
|
||
try{ localStorage.setItem('pptx-session',JSON.stringify({outline,pptxRelPath:relPath,skin:selectedSkin,ts:Date.now()})); }catch(_){}
|
||
goStep(4);
|
||
await loadPreview(relPath);
|
||
}catch(e){ stopTimer(); setMsg(3,'오류: '+e.message,'var(--red)'); }
|
||
finally{
|
||
btn.disabled=false; btn.textContent='✅ PPTX 생성 →';
|
||
if(regenPending){ regenPending=false; s4Regen(); }
|
||
}
|
||
}
|
||
|
||
// ── STEP 4: Preview ──────────────────────────────────────────────────────────
|
||
async function loadPreview(relPath){
|
||
const stopTimer=startTimer('s4msg','🖼 슬라이드 변환 중');
|
||
document.getElementById('preview-grid').innerHTML='<div style="color:var(--muted);font-size:12px;text-align:center;padding:32px;grid-column:1/-1">🖼 변환 중...</div>';
|
||
try{
|
||
const r=await fetch('/api/pptx/preview?path='+encodeURIComponent(relPath),{...cr(),signal:AbortSignal.timeout(90000)});
|
||
const d=await r.json();
|
||
if(!r.ok||!d.success) throw new Error(d.error||'프리뷰 생성 실패');
|
||
previewImages=d.images||[];
|
||
if(!previewImages.length) throw new Error('슬라이드 이미지 0개');
|
||
stopTimer();
|
||
renderPreview();
|
||
setMsg(4,previewImages.length+'장 미리보기','var(--green)');
|
||
document.getElementById('preview-hint').style.display='';
|
||
const regenBtn=document.getElementById('s4-regen');
|
||
if(regenBtn&&outline) regenBtn.style.display='';
|
||
}catch(e){
|
||
stopTimer();
|
||
const msg2=e.name==='TimeoutError'?'변환 시간 초과 (90s)':e.message;
|
||
setMsg(4,'프리뷰 오류: '+msg2,'var(--red)');
|
||
document.getElementById('preview-grid').innerHTML=
|
||
'<div style="color:var(--red);font-size:12px;text-align:center;padding:24px;grid-column:1/-1">'+esc(msg2)+'<br><br>'
|
||
+'<button class="btn bg" onclick="reloadPreview()" style="margin-top:8px">↺ 다시 시도</button></div>';
|
||
}
|
||
}
|
||
|
||
function renderPreview(){
|
||
const g=document.getElementById('preview-grid');
|
||
if(!previewImages.length){ g.innerHTML='<div style="color:var(--muted);font-size:13px;text-align:center;padding:24px;grid-column:1/-1">이미지 없음</div>'; return; }
|
||
const slides=outline?.slides||[];
|
||
const canEdit=!!(outline&&slides.length);
|
||
g.innerHTML=previewImages.map((url,i)=>{
|
||
const sl=slides[i];
|
||
const lbl=sl?`${i+1}. ${sl.title||sl.type||''}`:`슬라이드 ${i+1}`;
|
||
const editBtn=canEdit?`<button class="prev-edit-btn" onclick="event.stopPropagation();openSedFromPreview(${i})">✏ 편집</button>`:'';
|
||
return `<div class="preview-card" title="${esc(lbl)}">
|
||
${editBtn}
|
||
<img src="${esc(url)}" loading="lazy" alt="${esc(lbl)}" style="cursor:zoom-in"
|
||
onerror="this.style.display='none';this.nextElementSibling.style.display='flex'"
|
||
onclick="ssStart(${i})">
|
||
<div style="display:none;align-items:center;justify-content:center;height:120px;font-size:12px;color:var(--muted)">이미지 로드 실패</div>
|
||
<div class="preview-label" id="prev-lbl-${i}">${esc(lbl)}</div>
|
||
</div>`;
|
||
}).join('');
|
||
const ssBtn=document.getElementById('ss-launch');
|
||
if(ssBtn) ssBtn.disabled=false;
|
||
}
|
||
|
||
async function reloadPreview(){
|
||
if(pptxRelPath){ await loadPreview(pptxRelPath); return; }
|
||
// Try to find newest PPTX
|
||
try{
|
||
const r=await fetch('/api/pptx/latest',cr()); const d=await r.json();
|
||
if(d.found){ pptxRelPath=d.relPath; const dlBtn=document.getElementById('dl-btn'); dlBtn.href=d.url; dlBtn.style.display='inline-flex'; await loadPreview(d.relPath); }
|
||
else setMsg(4,'PPTX 파일이 없습니다','var(--red)');
|
||
}catch(e){ setMsg(4,'오류: '+e.message,'var(--red)'); }
|
||
}
|
||
|
||
// ── Slideshow ─────────────────────────────────────────────────────────────────
|
||
let ssIdx=0, ssAutoTimer=null, ssAutoOn=false;
|
||
|
||
function ssStart(idx=0){
|
||
if(!previewImages.length)return;
|
||
ssIdx=Math.max(0,Math.min(idx,previewImages.length-1));
|
||
ssRender();
|
||
document.getElementById('slideshow').classList.add('open');
|
||
document.addEventListener('keydown',ssKey);
|
||
}
|
||
|
||
function ssClose(){
|
||
document.getElementById('slideshow').classList.remove('open');
|
||
document.removeEventListener('keydown',ssKey);
|
||
if(ssAutoTimer){clearInterval(ssAutoTimer);ssAutoTimer=null;ssAutoOn=false;}
|
||
const b=document.getElementById('ss-auto-btn');if(b)b.textContent='⏵ 자동';
|
||
}
|
||
|
||
function ssRender(){
|
||
const url=previewImages[ssIdx]||'';
|
||
document.getElementById('ss-img').src=url;
|
||
document.getElementById('ss-counter').textContent=(ssIdx+1)+' / '+previewImages.length;
|
||
const sl=(outline?.slides||[])[ssIdx];
|
||
document.getElementById('ss-title').textContent=sl?(sl.title||sl.type||''):'';
|
||
}
|
||
|
||
function ssPrev(){ ssIdx=(ssIdx-1+previewImages.length)%previewImages.length; ssRender(); }
|
||
function ssNext(){ ssIdx=(ssIdx+1)%previewImages.length; ssRender(); }
|
||
|
||
function ssToggleAuto(){
|
||
ssAutoOn=!ssAutoOn;
|
||
const b=document.getElementById('ss-auto-btn');
|
||
if(ssAutoOn){ ssAutoTimer=setInterval(ssNext,3500); b.textContent='⏸ 정지'; }
|
||
else{ clearInterval(ssAutoTimer);ssAutoTimer=null; b.textContent='⏵ 자동'; }
|
||
}
|
||
|
||
function ssKey(e){
|
||
if(e.key==='Escape') ssClose();
|
||
else if(e.key==='ArrowLeft'||e.key==='ArrowUp') ssPrev();
|
||
else if(e.key==='ArrowRight'||e.key==='ArrowDown'||e.key===' '){e.preventDefault();ssNext();}
|
||
}
|
||
|
||
// ── Slide Editor ─────────────────────────────────────────────────────────────
|
||
let editorIdx = -1;
|
||
|
||
(function initSedSkins(){
|
||
const g=document.getElementById('sed-skin-grid');
|
||
SKINS.forEach(s=>{
|
||
const d=document.createElement('div');
|
||
d.className='sed-skin-sw'; d.style.background=s.c; d.title=s.label; d.dataset.v=s.v;
|
||
d.onclick=()=>sedSetBg(s.v); g.appendChild(d);
|
||
});
|
||
})();
|
||
|
||
let sedFromPreview=false;
|
||
let sedDirty=false; // 실제 편집이 있었을 때만 true
|
||
|
||
function openSed(i){
|
||
if(!outline||(outline.slides||[]).length===0)return;
|
||
editorIdx=Math.max(0,Math.min(i,(outline.slides||[]).length-1));
|
||
sedDirty=false;
|
||
const bg=document.getElementById('sedbg');
|
||
bg.classList.add('open');
|
||
const modal=bg.querySelector('.sedmodal');
|
||
const saved=JSON.parse(localStorage.getItem('sed-pos')||'null');
|
||
if(saved&&saved.left!=null&&saved.top!=null){
|
||
// 저장된 절대 좌표 복원: CSS 중앙정렬용 transform을 반드시 해제해야
|
||
// translate(-50%,-50%)가 더해져 모달이 화면 밖(헤더 잘림)으로 밀리는 것을 막는다.
|
||
// 새 탭(window.open)으로 새로고침된 직후엔 인라인 transform이 없어 이 버그가 발생했다.
|
||
modal.style.transform='none';
|
||
// 창 크기가 달라졌을 때를 대비해 화면 안으로 클램프
|
||
const mw=modal.offsetWidth||1200, mh=modal.offsetHeight||Math.round(window.innerHeight*0.9);
|
||
const left=Math.max(0,Math.min(saved.left,window.innerWidth-mw));
|
||
const top=Math.max(0,Math.min(saved.top,window.innerHeight-mh));
|
||
modal.style.left=left+'px'; modal.style.top=top+'px';
|
||
} else {
|
||
// 저장된 위치 없음 → CSS 기본 중앙정렬 복귀(이전 드래그 잔여 인라인 스타일 제거)
|
||
modal.style.removeProperty('left');
|
||
modal.style.removeProperty('top');
|
||
modal.style.removeProperty('transform');
|
||
}
|
||
sedInitDrag(modal);
|
||
// sedLoad after modal is visible so clientWidth/clientHeight are correct
|
||
sedLoad();
|
||
}
|
||
function openSedFromPreview(i){
|
||
sedFromPreview=true;
|
||
openSed(i);
|
||
}
|
||
function closeSed(){
|
||
// sedmodal 편집 내용을 outline.slides에 즉시 병합 (저장) — 실제 변경이 있을 때만
|
||
const i=editorIdx;
|
||
if(sedDirty && i>=0 && outline?.slides?.[i] && outlineEdits[i]){
|
||
const ed=outlineEdits[i], s=outline.slides[i];
|
||
if(ed.title!==undefined) s.title=ed.title;
|
||
if(ed.subtitle!==undefined) s.subtitle=ed.subtitle;
|
||
if(ed.bullets!==undefined) s.bullets=ed.bullets;
|
||
if(ed.image_url!==undefined) s.image_url=ed.image_url;
|
||
if(ed.image_search!==undefined)s.image_search=ed.image_search;
|
||
if(ed.background) s.background=ed.background;
|
||
if(ed.bg_img_url!==undefined) s.bg_img_url=ed.bg_img_url;
|
||
if(ed.bg_image!==undefined) s.bg_image=ed.bg_image;
|
||
if(ed.font_family!==undefined) s.font_family=ed.font_family;
|
||
if(ed.title_color!==undefined) s.title_color=ed.title_color;
|
||
if(ed.body_color!==undefined) s.body_color=ed.body_color;
|
||
if(ed.img_filter!==undefined) s.img_filter=ed.img_filter;
|
||
if(ed.img_blur_radius!==undefined) s.img_blur_radius=ed.img_blur_radius;
|
||
if(ed.img_corner_radius!==undefined) s.img_corner_radius=ed.img_corner_radius;
|
||
if(ed.font_size!==undefined)s.font_size=ed.font_size;
|
||
if(ed.title_size!==undefined)s.title_size=ed.title_size;
|
||
if(ed.img_scale!==undefined)s.img_scale=ed.img_scale;
|
||
if(ed.img_x!==undefined) s.img_x=ed.img_x;
|
||
if(ed.img_y!==undefined) s.img_y=ed.img_y;
|
||
if(ed.img_w!==undefined) s.img_w=ed.img_w;
|
||
if(ed.img_h!==undefined) s.img_h=ed.img_h;
|
||
if(ed.type!==undefined) s.type=ed.type;
|
||
if(ed.layout!==undefined) s.layout=ed.layout;
|
||
if(ed.text_y!=null) s.text_y=ed.text_y;
|
||
delete outlineEdits[i]; // 병합 완료 → 완전히 제거 (빈 {}객체 잔류 방지)
|
||
} else if(!sedDirty) {
|
||
// 편집 없이 닫음 → 임시 편집분 완전 제거
|
||
delete outlineEdits[i];
|
||
}
|
||
document.getElementById('sedbg').classList.remove('open');
|
||
const wasDirty=sedDirty;
|
||
const wasPreview=sedFromPreview;
|
||
const savedIdx=i;
|
||
sedDirty=false;
|
||
sedFromPreview=false;
|
||
editorIdx=-1;
|
||
renderOutline();
|
||
if(wasDirty) saveOutlineToProject();
|
||
if(wasPreview && wasDirty) afterSedPreview(savedIdx); // 실제 변경이 있을 때만
|
||
}
|
||
function afterSedPreview(editedIdx){
|
||
// 편집된 슬라이드만 라벨 갱신 + 수정됨 표시
|
||
const sl=outline?.slides?.[editedIdx];
|
||
if(sl){
|
||
const lbl=document.getElementById('prev-lbl-'+editedIdx);
|
||
if(lbl) lbl.textContent=`${editedIdx+1}. ${sl.title||sl.type||''}`;
|
||
const card=lbl?.closest?.('.preview-card');
|
||
if(card) card.dataset.edited='1';
|
||
}
|
||
// 자동 재생성 — 생성 중이면 s4Regen이 끝난 뒤 1회 재생성으로 예약
|
||
s4Regen();
|
||
}
|
||
let regenPending=false;
|
||
function s4Regen(){
|
||
const genBtn=document.getElementById('s3create');
|
||
if(genBtn&&genBtn.disabled){ regenPending=true; return; } // 생성 중 → 끝난 뒤 1회 예약
|
||
const btn=document.getElementById('s4-regen');
|
||
if(btn){ btn.classList.remove('bp'); btn.classList.add('bgreen'); }
|
||
createPptx();
|
||
}
|
||
|
||
function sedInitDrag(modal){
|
||
const hd=modal.querySelector('.sed-hd');
|
||
if(hd._sedDragBound) return;
|
||
hd._sedDragBound=true;
|
||
let ox=0,oy=0,mx=0,my=0;
|
||
hd.addEventListener('mousedown',e=>{
|
||
if(e.target.tagName==='BUTTON'||e.target.closest('button')) return;
|
||
e.preventDefault();
|
||
const r=modal.getBoundingClientRect();
|
||
// 현재 fixed 위치 확정
|
||
modal.style.left=r.left+'px'; modal.style.top=r.top+'px';
|
||
modal.style.transform='none';
|
||
ox=e.clientX-r.left; oy=e.clientY-r.top;
|
||
const onMove=e=>{
|
||
let nx=e.clientX-ox, ny=e.clientY-oy;
|
||
nx=Math.max(0,Math.min(nx,window.innerWidth-modal.offsetWidth));
|
||
ny=Math.max(0,Math.min(ny,window.innerHeight-modal.offsetHeight));
|
||
modal.style.left=nx+'px'; modal.style.top=ny+'px';
|
||
};
|
||
const onUp=()=>{
|
||
document.removeEventListener('mousemove',onMove);
|
||
document.removeEventListener('mouseup',onUp);
|
||
const r2=modal.getBoundingClientRect();
|
||
localStorage.setItem('sed-pos',JSON.stringify({left:r2.left,top:r2.top}));
|
||
};
|
||
document.addEventListener('mousemove',onMove);
|
||
document.addEventListener('mouseup',onUp);
|
||
});
|
||
}
|
||
function sedNav(dir){
|
||
const total=(outline?.slides||[]).length; if(!total)return;
|
||
editorIdx=(editorIdx+dir+total)%total;
|
||
sedLoad();
|
||
}
|
||
|
||
function sedLoad(){
|
||
const i=editorIdx; if(!outline)return;
|
||
const s=outline.slides[i]||{};
|
||
const ed=outlineEdits[i]||{};
|
||
const type=s.type||'content';
|
||
const title=ed.title!==undefined?ed.title:(s.title||'');
|
||
const subtitle=ed.subtitle!==undefined?ed.subtitle:(s.subtitle||'');
|
||
// body/content(string) → split into bullets array for editing
|
||
// bullets 배열이 있으면 그대로 사용, body/content 문자열이면 \n으로 분리
|
||
// (textarea에서는 \n\n이 항목 구분이지만, AI 생성 데이터는 \n 구분이므로 원본 파싱은 \n 사용)
|
||
const rawBullets = ed.bullets!==undefined ? ed.bullets
|
||
: s.bullets || s.bullet_points
|
||
|| (typeof (s.body||s.content)==='string' ? (s.body||s.content).split('\n') : []);
|
||
const bullets = Array.isArray(rawBullets) ? rawBullets
|
||
: typeof rawBullets==='string' ? rawBullets.split('\n') : [];
|
||
const imgUrl=ed.image_url!==undefined?ed.image_url:(s.image_url||'');
|
||
const bg=ed.background||s.background||selectedSkin||'';
|
||
const titleSz=ed.title_size!==undefined?ed.title_size:(s.title_size||(type==='title'?34:26));
|
||
const bodySz=ed.font_size!==undefined?ed.font_size:(s.font_size||18);
|
||
const imgScale=ed.img_scale!==undefined?ed.img_scale:(s.img_scale!=null?s.img_scale:50);
|
||
const layout=ed.layout||s.layout||'split-right';
|
||
const total=(outline.slides||[]).length;
|
||
|
||
document.getElementById('sed-num').textContent=i+1;
|
||
document.getElementById('sed-cur').textContent=i+1;
|
||
document.getElementById('sed-total').textContent=total;
|
||
document.getElementById('sed-type').textContent=type;
|
||
document.getElementById('sed-title-lbl').textContent=title||`슬라이드 ${i+1}`;
|
||
|
||
document.getElementById('sed-title-inp').value=title;
|
||
document.getElementById('sed-subtitle-inp').value=subtitle;
|
||
document.getElementById('sed-bullets-ta').value=Array.isArray(bullets)?bullets.join('\n\n'):(bullets||'');
|
||
document.getElementById('sed-title-size').value=titleSz;
|
||
document.getElementById('sed-title-size-val').textContent=titleSz+'pt';
|
||
document.getElementById('sed-body-size').value=bodySz;
|
||
document.getElementById('sed-body-size-val').textContent=bodySz+'pt';
|
||
document.getElementById('sed-img-scale').value=imgScale;
|
||
document.getElementById('sed-img-scale-val').textContent=imgScale+'%';
|
||
|
||
// Show/hide sections
|
||
const isTitle=type==='title';
|
||
const hasBul=['content','image','compare'].includes(type);
|
||
const hasImg=['content','image','title'].includes(type);
|
||
const hasLayout=['content','image','compare'].includes(type);
|
||
document.getElementById('sed-subtitle-sec').style.display=isTitle?'':'none';
|
||
document.getElementById('sed-bullets-sec').style.display=hasBul?'':'none';
|
||
document.getElementById('sed-img-sec').style.display=hasImg?'':'none';
|
||
document.getElementById('sed-layout-sec').style.display=hasLayout?'':'none';
|
||
|
||
// Layout buttons
|
||
document.querySelectorAll('.layout-btn').forEach(btn=>btn.classList.toggle('active',btn.dataset.layout===layout));
|
||
|
||
// Skin
|
||
document.querySelectorAll('.sed-skin-sw').forEach(sw=>sw.classList.toggle('active',sw.dataset.v===bg));
|
||
const sk=SKINS.find(s=>s.v===bg);
|
||
document.getElementById('sed-skin-lbl').textContent=sk?sk.label:'기본';
|
||
|
||
// Image thumb + URL input
|
||
const thumb=document.getElementById('sed-img-thumb');
|
||
const urlInp=document.getElementById('sed-img-url-inp');
|
||
if(imgUrl){
|
||
thumb.style.backgroundImage=`url(${imgUrl})`; thumb.textContent='';
|
||
if(urlInp) urlInp.value=imgUrl;
|
||
} else {
|
||
thumb.style.backgroundImage=''; thumb.textContent='🖼';
|
||
const srch=ed.image_search!==undefined?ed.image_search:(s.image_search||'');
|
||
if(urlInp) urlInp.value='';
|
||
if(srch) thumb.textContent='🔍 '+srch;
|
||
}
|
||
|
||
// Background image thumb
|
||
const bgImgUrl=ed.bg_img_url!==undefined?ed.bg_img_url:(s.bg_img_url||'');
|
||
_sedUpdateBgImgThumb(bgImgUrl);
|
||
|
||
// Font & color
|
||
document.getElementById('sed-font-family').value=ed.font_family!==undefined?ed.font_family:(s.font_family||'');
|
||
const tc=ed.title_color!==undefined?ed.title_color:(s.title_color||'');
|
||
const bc=ed.body_color!==undefined?ed.body_color:(s.body_color||'');
|
||
document.getElementById('sed-title-color').value=tc?('#'+tc.replace('#','')):'#1a1a2e';
|
||
document.getElementById('sed-body-color').value=bc?('#'+bc.replace('#','')):'#2d3748';
|
||
|
||
// Image FX
|
||
const fx=ed.img_filter!==undefined?ed.img_filter:(s.img_filter||'');
|
||
const blurR=ed.img_blur_radius!==undefined?ed.img_blur_radius:(s.img_blur_radius||5);
|
||
const roundR=ed.img_corner_radius!==undefined?ed.img_corner_radius:(s.img_corner_radius||15);
|
||
document.querySelectorAll('.fx-btn').forEach(b=>b.classList.toggle('active',b.dataset.fx===fx));
|
||
document.getElementById('sed-fx-blur').value=blurR;
|
||
document.getElementById('sed-fx-blur-val').textContent=blurR;
|
||
document.getElementById('sed-fx-radius').value=roundR;
|
||
document.getElementById('sed-fx-radius-val').textContent=roundR+'%';
|
||
document.getElementById('sed-fx-blur-row').style.display=fx==='blur'?'':'none';
|
||
document.getElementById('sed-fx-round-row').style.display=fx==='round'?'':'none';
|
||
|
||
requestAnimationFrame(()=>sedRenderPreview());
|
||
}
|
||
|
||
function sedChange(){
|
||
const i=editorIdx; if(i<0||!outline)return;
|
||
sedDirty=true;
|
||
if(!outlineEdits[i])outlineEdits[i]={};
|
||
const ed=outlineEdits[i];
|
||
ed.title=document.getElementById('sed-title-inp').value;
|
||
ed.subtitle=document.getElementById('sed-subtitle-inp').value;
|
||
const bval=document.getElementById('sed-bullets-ta').value;
|
||
// 빈 줄(\n\n)로 분리 → 각 그룹이 하나의 글머리 기호
|
||
// 그룹 내 단순 \n은 같은 점 안 줄바꿈
|
||
const _groups=bval.split(/\n\n+/);
|
||
ed.bullets=_groups.map(g=>g.replace(/^\n+|\n+$/g,'')).filter(g=>g.trim()!=='');
|
||
ed.title_size=parseInt(document.getElementById('sed-title-size').value)||26;
|
||
ed.font_size=parseInt(document.getElementById('sed-body-size').value)||18;
|
||
ed.img_scale=parseInt(document.getElementById('sed-img-scale').value)||50;
|
||
document.getElementById('sed-title-size-val').textContent=ed.title_size+'pt';
|
||
document.getElementById('sed-body-size-val').textContent=ed.font_size+'pt';
|
||
document.getElementById('sed-img-scale-val').textContent=ed.img_scale+'%';
|
||
// Font & color
|
||
ed.font_family=document.getElementById('sed-font-family').value;
|
||
const _tc=document.getElementById('sed-title-color').value.replace('#','');
|
||
const _bc=document.getElementById('sed-body-color').value.replace('#','');
|
||
ed.title_color=_tc; ed.body_color=_bc;
|
||
document.getElementById('sed-title-lbl').textContent=ed.title||`슬라이드 ${i+1}`;
|
||
sedRenderPreview();
|
||
}
|
||
|
||
function sedResetColor(which){
|
||
const i=editorIdx; if(i<0)return;
|
||
sedDirty=true;
|
||
if(!outlineEdits[i])outlineEdits[i]={};
|
||
const {dark}=skinColors(outlineEdits[i]?.background||outline?.slides?.[i]?.background||selectedSkin||'');
|
||
if(which==='title'){
|
||
outlineEdits[i].title_color=dark?'ffffff':'1a1a2e';
|
||
document.getElementById('sed-title-color').value=dark?'#ffffff':'#1a1a2e';
|
||
} else {
|
||
outlineEdits[i].body_color=dark?'e0e0e0':'2d3748';
|
||
document.getElementById('sed-body-color').value=dark?'#e0e0e0':'#2d3748';
|
||
}
|
||
sedRenderPreview();
|
||
}
|
||
|
||
function sedSetFx(fx){
|
||
const i=editorIdx; if(i<0)return;
|
||
sedDirty=true;
|
||
if(!outlineEdits[i])outlineEdits[i]={};
|
||
outlineEdits[i].img_filter=fx;
|
||
document.querySelectorAll('.fx-btn').forEach(b=>b.classList.toggle('active',b.dataset.fx===fx));
|
||
document.getElementById('sed-fx-blur-row').style.display=fx==='blur'?'':'none';
|
||
document.getElementById('sed-fx-round-row').style.display=fx==='round'?'':'none';
|
||
sedRenderPreview();
|
||
}
|
||
|
||
function sedFxSlider(){
|
||
const i=editorIdx; if(i<0)return;
|
||
sedDirty=true;
|
||
if(!outlineEdits[i])outlineEdits[i]={};
|
||
const blurR=parseInt(document.getElementById('sed-fx-blur').value)||5;
|
||
const roundR=parseInt(document.getElementById('sed-fx-radius').value)||15;
|
||
outlineEdits[i].img_blur_radius=blurR;
|
||
outlineEdits[i].img_corner_radius=roundR;
|
||
document.getElementById('sed-fx-blur-val').textContent=blurR;
|
||
document.getElementById('sed-fx-radius-val').textContent=roundR+'%';
|
||
sedRenderPreview();
|
||
}
|
||
|
||
function sedSetLayout(layout){
|
||
const i=editorIdx; if(i<0)return;
|
||
sedDirty=true;
|
||
if(!outlineEdits[i])outlineEdits[i]={};
|
||
outlineEdits[i].layout=layout;
|
||
if(outline?.slides?.[i]) outline.slides[i].layout=layout;
|
||
document.querySelectorAll('.layout-btn').forEach(btn=>btn.classList.toggle('active',btn.dataset.layout===layout));
|
||
sedRenderPreview();
|
||
}
|
||
|
||
function sedSetBg(v){
|
||
const i=editorIdx; if(i<0)return;
|
||
sedDirty=true;
|
||
if(!outlineEdits[i])outlineEdits[i]={};
|
||
outlineEdits[i].background=v;
|
||
document.querySelectorAll('.sed-skin-sw').forEach(sw=>sw.classList.toggle('active',sw.dataset.v===v));
|
||
const sk=SKINS.find(s=>s.v===v);
|
||
document.getElementById('sed-skin-lbl').textContent=sk?sk.label:'기본';
|
||
sedRenderPreview();
|
||
}
|
||
|
||
async function sedPickImg(){
|
||
modalSlide=editorIdx;
|
||
pickerMode='image';
|
||
const g=document.getElementById('imodal-grid');
|
||
document.getElementById('ibg').classList.add('open');
|
||
await _loadPickerImages(g);
|
||
}
|
||
|
||
function sedImgUrlInput(){
|
||
const i=editorIdx; if(i<0)return;
|
||
sedDirty=true;
|
||
const url=(document.getElementById('sed-img-url-inp')?.value||'').trim();
|
||
if(!outlineEdits[i])outlineEdits[i]={};
|
||
outlineEdits[i].image_url=url;
|
||
outlineEdits[i].image_search='';
|
||
const thumb=document.getElementById('sed-img-thumb');
|
||
if(url){ thumb.style.backgroundImage=`url(${url})`; thumb.textContent=''; }
|
||
else { thumb.style.backgroundImage=''; thumb.textContent='🖼'; }
|
||
sedRenderPreview();
|
||
}
|
||
|
||
function sedClearImg(){
|
||
const i=editorIdx; if(i<0)return;
|
||
sedDirty=true;
|
||
if(!outlineEdits[i])outlineEdits[i]={};
|
||
outlineEdits[i].image_url='';
|
||
outlineEdits[i].image_search='';
|
||
const thumb=document.getElementById('sed-img-thumb');
|
||
thumb.style.backgroundImage=''; thumb.textContent='🖼';
|
||
const urlInp=document.getElementById('sed-img-url-inp');
|
||
if(urlInp) urlInp.value='';
|
||
sedRenderPreview();
|
||
}
|
||
|
||
function sedRenderPreview(){
|
||
const i=editorIdx; if(i<0||!outline)return;
|
||
const prev=document.getElementById('sed-preview');
|
||
const stage=document.getElementById('sed-stage');
|
||
if(!prev||!stage)return;
|
||
// 편집창에서는 항상 HTML 미리보기 (드래그 등 인터랙션 지원)
|
||
const s=outline.slides[i]||{};
|
||
const ed=outlineEdits[i]||{};
|
||
const type=s.type||'content';
|
||
const titleRaw=(document.getElementById('sed-title-inp')?.value??ed.title??(s.title||''));
|
||
const subtitleRaw=(document.getElementById('sed-subtitle-inp')?.value??ed.subtitle??(s.subtitle||''));
|
||
// 줄바꿈을 <br>로 변환해서 미리보기에 반영
|
||
const titleHtml=esc(titleRaw).replace(/\n/g,'<br>');
|
||
const subtitleHtml=esc(subtitleRaw).replace(/\n/g,'<br>');
|
||
const title=titleRaw; // PPTX 생성 등 text 용도
|
||
const subtitle=subtitleRaw;
|
||
// 미리보기는 textarea 현재값 직접 사용 (빈 줄 = 단락 구분선 표시)
|
||
const ta=document.getElementById('sed-bullets-ta');
|
||
const _fbBullets=ed.bullets||s.bullets||s.bullet_points
|
||
||(typeof(s.body||s.content)==='string'?(s.body||s.content).split('\n'):[]);
|
||
const bullets=ta?ta.value.split(/\n\n+/).map(g=>g.replace(/^\n+|\n+$/g,'')).filter(g=>g.trim()!==''):(Array.isArray(_fbBullets)?_fbBullets:[]);
|
||
const imgUrlRaw=ed.image_url!==undefined?ed.image_url:(s.image_url||'');
|
||
const imgPath=ed.image_path!==undefined?ed.image_path:(s.image_path||'');
|
||
const imgProject=(outline?.project||'').replace(/[^a-zA-Z0-9가-힣_\-]/g,'_').replace(/_+/g,'_').toLowerCase();
|
||
const imgUrl=imgUrlRaw||(imgPath&&imgProject?`/api/files/${encodeURIComponent(imgProject)}/${encodeURIComponent(imgPath)}`:'');
|
||
const bg=ed.background||s.background||selectedSkin||'';
|
||
const titleSz=ed.title_size!==undefined?ed.title_size:(s.title_size||(type==='title'?34:26));
|
||
const bodySz=ed.font_size!==undefined?ed.font_size:(s.font_size||18);
|
||
const imgPct=ed.img_scale!==undefined?ed.img_scale:50;
|
||
const layout=ed.layout||s.layout||'split-right';
|
||
|
||
const {bg:bgc,bgImg,txt,muted:mutC,dark:isDark}=skinColors(bg);
|
||
const bgImgUrl=ed.bg_img_url!==undefined?ed.bg_img_url:(s.bg_img_url||'');
|
||
const activeBgImg=bgImgUrl||bgImg; // 이미지 배경 우선, 없으면 스킨
|
||
// Custom colors & font
|
||
const _tc=ed.title_color!==undefined?ed.title_color:(s.title_color||'');
|
||
const _bc=ed.body_color!==undefined?ed.body_color:(s.body_color||'');
|
||
const titleCol=_tc?('#'+_tc.replace('#','')):txt;
|
||
const bodyCol=_bc?('#'+_bc.replace('#','')):txt;
|
||
const fontFam=ed.font_family!==undefined?ed.font_family:(s.font_family||'');
|
||
const fontStyle=fontFam?`font-family:'${fontFam}',sans-serif;`:'';
|
||
// Image FX → CSS filter string
|
||
const imgFx=ed.img_filter!==undefined?ed.img_filter:(s.img_filter||'');
|
||
const blurR=ed.img_blur_radius!==undefined?ed.img_blur_radius:(s.img_blur_radius||5);
|
||
const roundR=ed.img_corner_radius!==undefined?ed.img_corner_radius:(s.img_corner_radius||15);
|
||
let imgCssFilter=''; let imgBorderRadius='';
|
||
if(imgFx==='blur') imgCssFilter=`filter:blur(${blurR*0.5}px)`;
|
||
else if(imgFx==='grayscale') imgCssFilter='filter:grayscale(100%)';
|
||
else if(imgFx==='contrast') imgCssFilter='filter:contrast(1.6) saturate(1.2)';
|
||
else if(imgFx==='bright') imgCssFilter='filter:brightness(1.4)';
|
||
else if(imgFx==='round') imgBorderRadius=`border-radius:${roundR}%;overflow:hidden`;
|
||
|
||
// Fit 16:9 into stage
|
||
// Force reflow so clientWidth/clientHeight are accurate after display:none→flex
|
||
void stage.offsetHeight;
|
||
const sw=stage.clientWidth-48, sh=stage.clientHeight-48;
|
||
let pw=sw, ph=pw/16*9;
|
||
if(ph>sh){ph=sh;pw=ph/9*16;}
|
||
pw=Math.floor(pw); ph=Math.floor(ph);
|
||
prev.style.width=pw+'px'; prev.style.height=ph+'px';
|
||
// 배경 이미지 (이미지 배경 > 스킨 배경)
|
||
if(activeBgImg && !imgUrl){
|
||
prev.style.background=bgc;
|
||
prev.style.backgroundImage=`url(${activeBgImg})`;
|
||
prev.style.backgroundSize='cover';
|
||
prev.style.backgroundPosition='center';
|
||
} else {
|
||
prev.style.background=bgc;
|
||
prev.style.backgroundImage='';
|
||
}
|
||
|
||
const sc=pw/960; // scale factor
|
||
const ts=Math.round(titleSz*sc);
|
||
const bs=Math.round(bodySz*sc);
|
||
|
||
function bulletHtml(list,color,size){
|
||
return (list||[]).map(b=>{
|
||
if(b.trim()==='') return `<div style="height:${Math.round(size*0.6)}px"></div>`;
|
||
const subM=b.match(/^[-–—]\s+(.*)/);
|
||
const isSub=!!subM;
|
||
const rawTxt=isSub?subM[1]:String(b);
|
||
// \n within a bullet → <br> soft break
|
||
const txt=esc(rawTxt).replace(/\n/g,'<br>');
|
||
const dot=isSub?'–':'●';
|
||
const dotOpacity=isSub?'.4':'.65';
|
||
const pad=isSub?`padding-left:${Math.round(14*sc)}px;`:'';
|
||
return `<div style="display:flex;gap:${Math.round(7*sc)}px;margin-bottom:${Math.round(5*sc)}px;align-items:flex-start;${pad}${fontStyle}">
|
||
<div style="width:${Math.round(5*sc)}px;height:${Math.round(5*sc)}px;min-width:${Math.round(5*sc)}px;border-radius:50%;background:${color};margin-top:${Math.round(size*.55)}px;opacity:${dotOpacity}"></div>
|
||
<div style="font-size:${size}px;color:${color};line-height:1.5;opacity:.92;word-break:break-word">${txt}</div>
|
||
</div>`;
|
||
}).join('');
|
||
}
|
||
// imgFxWrap: wrapper div that applies CSS filter / border-radius to images
|
||
const imgFxWrapStyle=imgBorderRadius?`style="${imgBorderRadius};overflow:hidden;width:100%;height:100%"`:'';
|
||
const imgFxFilterStyle=imgCssFilter?`style="${imgCssFilter};width:100%;height:100%"`:'';
|
||
function wrapImgFx(imgTag){
|
||
if(!imgBorderRadius && !imgCssFilter) return imgTag;
|
||
if(imgBorderRadius && imgCssFilter)
|
||
return `<div ${imgFxWrapStyle}><div ${imgFxFilterStyle}>${imgTag}</div></div>`;
|
||
if(imgBorderRadius) return `<div ${imgFxWrapStyle}>${imgTag}</div>`;
|
||
return `<div ${imgFxFilterStyle}>${imgTag}</div>`;
|
||
}
|
||
|
||
let html='';
|
||
const p=`${Math.round(18*sc)}px`;
|
||
const pt=`${Math.round(14*sc)}px`;
|
||
|
||
if(type==='title'){
|
||
const hasBg=!!imgUrl;
|
||
// pptx_gen.py: bg_image 있으면 y=2.8"/7.5"=37.3%, 없으면 y=2.5"/7.5"=33.3%
|
||
// 스킨 이미지(bgImg)가 있으면 bg_image가 설정되므로 37.3% 사용
|
||
const hasSkinImg=!!bgImg && !hasBg;
|
||
const titleTopPct=hasSkinImg||hasBg ? 0.373 : 0.333;
|
||
const titleTopPx=Math.round(ph*titleTopPct);
|
||
const titleLeftPx=Math.round(pw*0.06); // x=0.8/13.33"
|
||
const titleWidthPx=Math.round(pw*0.878); // w=11.7/13.33"
|
||
const textCol=hasBg?'#fff':(titleCol||txt);
|
||
const subCol=hasBg?'rgba(255,255,255,.8)':(bodyCol||mutC);
|
||
html=`${hasBg?`<div style="position:absolute;inset:0;background-image:url(${imgUrl});background-size:cover;background-position:center;${imgCssFilter}${imgBorderRadius?';'+imgBorderRadius:''}"></div><div style="position:absolute;inset:0;background:rgba(0,0,0,.48)"></div>`:''}
|
||
<div style="position:absolute;top:${titleTopPx}px;left:${titleLeftPx}px;width:${titleWidthPx}px;text-align:center;z-index:1">
|
||
<div style="font-size:${ts}px;font-weight:800;color:${textCol};line-height:1.35;margin-bottom:${Math.round(12*sc)}px">${titleHtml||'제목'}</div>
|
||
${subtitleRaw?`<div style="font-size:${bs}px;color:${subCol};line-height:1.4">${subtitleHtml}</div>`:''}
|
||
</div>`;
|
||
} else if(type==='section'){
|
||
html=`<div style="position:absolute;inset:0;display:flex;align-items:center;justify-content:center;padding:${p}">
|
||
<div style="text-align:center">
|
||
<div style="font-size:${Math.round(ts*1.2)}px;font-weight:800;color:${txt};line-height:1.35">${titleHtml}</div>
|
||
<div style="height:${Math.round(3*sc)}px;background:${txt};opacity:.25;margin:${Math.round(14*sc)}px auto 0;width:${Math.round(100*sc)}px;border-radius:2px"></div>
|
||
</div>
|
||
</div>`;
|
||
} else if(type==='content'||type==='image'){
|
||
if(layout==='fullscreen'){
|
||
if(imgUrl){
|
||
html=`<div style="position:absolute;inset:0;background-image:url(${imgUrl});background-size:cover;background-position:center${imgCssFilter?';'+imgCssFilter:''}${imgBorderRadius?';'+imgBorderRadius:''}"></div>
|
||
<div style="position:absolute;inset:0;background:linear-gradient(to top,rgba(0,0,0,.75) 50%,transparent 80%)"></div>
|
||
<div style="position:absolute;bottom:0;left:0;right:0;padding:${p};z-index:1">
|
||
<div style="font-size:${ts}px;font-weight:700;color:#fff;line-height:1.35;margin-bottom:${Math.round(8*sc)}px">${titleHtml}</div>
|
||
${bulletHtml(bullets,'rgba(255,255,255,.88)',bs)}
|
||
</div>`;
|
||
} else {
|
||
html=`<div style="position:absolute;inset:0;display:flex;align-items:center;justify-content:center;background:${mutC}22">
|
||
<div style="text-align:center;padding:${p}">
|
||
<div style="font-size:${Math.round(18*sc)}px;color:${mutC};margin-bottom:${Math.round(8*sc)}px">🖼</div>
|
||
<div style="font-size:${ts}px;font-weight:700;color:${txt};line-height:1.35;margin-bottom:${Math.round(8*sc)}px">${titleHtml}</div>
|
||
${bulletHtml(bullets,txt,bs)}
|
||
</div>
|
||
</div>`;
|
||
}
|
||
} else if(layout==='text'){
|
||
const defTY=titleRaw?0.2:0.067;
|
||
const handlePx=Math.round(10*sc);
|
||
const ty=Math.max(0,Math.round((ed.text_y!=null?ed.text_y:defTY)*ph)-handlePx);
|
||
html=`<div style="position:absolute;inset:0;overflow:hidden;${fontStyle}">
|
||
${titleRaw?`<div style="padding:${pt} ${p} ${Math.round(6*sc)}px;position:absolute;top:0;left:0;right:0">
|
||
<div style="font-size:${ts}px;font-weight:700;color:${titleCol};line-height:1.35">${titleHtml}</div>
|
||
<div style="height:${Math.round(2*sc)}px;background:${titleCol};opacity:.15;margin-top:${Math.round(7*sc)}px;border-radius:1px"></div>
|
||
</div>`:''}
|
||
<div id="sed-txt-wrapper" style="position:absolute;top:${ty}px;left:0;right:0;cursor:ns-resize;user-select:none">
|
||
<div style="height:${Math.round(10*sc)}px;display:flex;align-items:center;justify-content:center;gap:${Math.round(3*sc)}px;opacity:0.3;color:${bodyCol};font-size:${Math.round(9*sc)}px">⠿ 드래그</div>
|
||
<div style="padding:0 ${p};overflow:hidden">${bulletHtml(bullets,bodyCol,bs)}</div>
|
||
</div>
|
||
</div>`;
|
||
} else {
|
||
// split-right / split-left (이미지 없으면 placeholder)
|
||
const imgRight=layout!=='split-left';
|
||
const imgW=imgPct+'%';
|
||
const txtW=(100-imgPct)+'%';
|
||
const bpadx=Math.round(14*sc);
|
||
const defTY=titleRaw?0.2:0.067;
|
||
const handlePx=Math.round(10*sc);
|
||
const ty=Math.max(0,Math.round((ed.text_y!=null?ed.text_y:defTY)*ph)-handlePx);
|
||
const textSide=`<div style="width:${txtW};height:100%;position:relative;overflow:hidden;${fontStyle}">
|
||
${titleRaw?`<div style="padding:${pt} ${bpadx}px ${Math.round(6*sc)}px;position:absolute;top:0;left:0;right:0">
|
||
<div style="font-size:${ts}px;font-weight:700;color:${titleCol};line-height:1.35">${titleHtml}</div>
|
||
<div style="height:${Math.round(2*sc)}px;background:${titleCol};opacity:.15;margin-top:${Math.round(6*sc)}px;border-radius:1px"></div>
|
||
</div>`:''}
|
||
<div id="sed-txt-wrapper" style="position:absolute;top:${ty}px;left:0;right:0;cursor:ns-resize;user-select:none">
|
||
<div style="height:${Math.round(10*sc)}px;display:flex;align-items:center;justify-content:center;gap:${Math.round(3*sc)}px;opacity:0.3;color:${bodyCol};font-size:${Math.round(9*sc)}px">⠿ 드래그</div>
|
||
<div style="padding:0 ${bpadx}px;overflow:hidden">${bulletHtml(bullets,bodyCol,bs)}</div>
|
||
</div>
|
||
</div>`;
|
||
if(imgUrl){
|
||
const defX=imgRight?(1-(imgPct/100)):0;
|
||
const defW=imgPct/100;
|
||
const defY=0.2; const defH=0.75;
|
||
const ix=Math.round((ed.img_x!=null?ed.img_x:defX)*pw);
|
||
const iy=Math.round((ed.img_y!=null?ed.img_y:defY)*ph);
|
||
const iw=Math.round((ed.img_w!=null?ed.img_w:defW)*pw);
|
||
const ih=Math.round((ed.img_h!=null?ed.img_h:defH)*ph);
|
||
const H='8px';
|
||
const hStyle=`position:absolute;width:${H};height:${H};background:#6366f1;border:1.5px solid #fff;border-radius:2px;z-index:12;box-sizing:border-box`;
|
||
const _imgTag=`<img src="${imgUrl}" draggable="false" style="width:100%;height:100%;object-fit:contain;display:block;pointer-events:none" onerror="this.style.display='none'">`;
|
||
html=`<div style="position:absolute;inset:0">${textSide}</div>
|
||
<div id="sed-img-wrapper" style="position:absolute;left:${ix}px;top:${iy}px;width:${iw}px;height:${ih}px;z-index:10;cursor:move;box-sizing:border-box;border:1.5px dashed rgba(99,102,241,.55)">
|
||
${wrapImgFx(_imgTag)}
|
||
<div data-r="nw" style="${hStyle};left:-4px;top:-4px;cursor:nw-resize"></div>
|
||
<div data-r="ne" style="${hStyle};right:-4px;top:-4px;cursor:ne-resize"></div>
|
||
<div data-r="sw" style="${hStyle};left:-4px;bottom:-4px;cursor:sw-resize"></div>
|
||
<div data-r="se" style="${hStyle};right:-4px;bottom:-4px;cursor:se-resize"></div>
|
||
<div data-r="n" style="${hStyle};left:calc(50% - 4px);top:-4px;cursor:n-resize"></div>
|
||
<div data-r="s" style="${hStyle};left:calc(50% - 4px);bottom:-4px;cursor:s-resize"></div>
|
||
<div data-r="w" style="${hStyle};left:-4px;top:calc(50% - 4px);cursor:w-resize"></div>
|
||
<div data-r="e" style="${hStyle};right:-4px;top:calc(50% - 4px);cursor:e-resize"></div>
|
||
</div>`;
|
||
} else {
|
||
const imgPlaceholder=`<div style="width:${imgW};height:100%;display:flex;align-items:center;justify-content:center;color:${mutC};font-size:${Math.round(22*sc)}px;background:${mutC}18;border:${Math.round(1.5*sc)}px dashed ${mutC}55">🖼</div>`;
|
||
html=`<div style="position:absolute;inset:0;display:flex;align-items:stretch">
|
||
${imgRight?textSide+imgPlaceholder:imgPlaceholder+textSide}
|
||
</div>`;
|
||
}
|
||
}
|
||
} else if(type==='table'||type==='chart'||type==='timeline'){
|
||
const ico=type==='table'?'📊':type==='chart'?'📈':'⏳';
|
||
html=`<div style="position:absolute;inset:0;display:flex;flex-direction:column;padding:${p}">
|
||
<div style="font-size:${ts}px;font-weight:700;color:${txt};line-height:1.35;margin-bottom:${Math.round(12*sc)}px">${titleHtml}</div>
|
||
<div style="flex:1;border:${Math.round(2*sc)}px dashed ${mutC};border-radius:${Math.round(6*sc)}px;display:flex;align-items:center;justify-content:center;color:${mutC};font-size:${Math.round(24*sc)}px;gap:${Math.round(8*sc)}px">
|
||
${ico} <span style="font-size:${Math.round(13*sc)}px">${type}</span>
|
||
</div>
|
||
</div>`;
|
||
} else {
|
||
html=`<div style="position:absolute;inset:0;display:flex;align-items:center;justify-content:center">
|
||
<div style="font-size:${ts}px;font-weight:700;color:${txt};text-align:center;line-height:1.35">${titleHtml||type}</div>
|
||
</div>`;
|
||
}
|
||
|
||
prev.innerHTML=html;
|
||
|
||
// 이미지 드래그 + 리사이즈
|
||
const wrapper=prev.querySelector('#sed-img-wrapper');
|
||
if(wrapper) attachImgDrag(wrapper, prev, i, pw, ph);
|
||
|
||
// 텍스트 블록 Y축 드래그
|
||
const txWrapper=prev.querySelector('#sed-txt-wrapper');
|
||
if(txWrapper) attachTextDrag(txWrapper, prev, i, ph);
|
||
}
|
||
|
||
function attachImgDrag(wrapper, prevEl, slideIdx, pw, ph){
|
||
wrapper.addEventListener('mousedown', function(e){
|
||
e.preventDefault(); e.stopPropagation();
|
||
const dir=e.target.dataset?.r||''; // 핸들이면 방향, 아니면 이동
|
||
const startMx=e.clientX, startMy=e.clientY;
|
||
let x0=wrapper.offsetLeft, y0=wrapper.offsetTop;
|
||
let w0=wrapper.offsetWidth, h0=wrapper.offsetHeight;
|
||
const MIN=40;
|
||
|
||
function onMove(ev){
|
||
const dx=ev.clientX-startMx, dy=ev.clientY-startMy;
|
||
let x=x0,y=y0,w=w0,h=h0;
|
||
if(!dir){ // 이동
|
||
x=Math.max(0,Math.min(pw-w0,x0+dx));
|
||
y=Math.max(0,Math.min(ph-h0,y0+dy));
|
||
} else { // 리사이즈
|
||
if(dir.includes('e')){ w=Math.max(MIN,w0+dx); }
|
||
if(dir.includes('s')){ h=Math.max(MIN,h0+dy); }
|
||
if(dir.includes('w')){ const nw=Math.max(MIN,w0-dx); x=x0+w0-nw; w=nw; }
|
||
if(dir.includes('n')){ const nh=Math.max(MIN,h0-dy); y=y0+h0-nh; h=nh; }
|
||
// 경계 클램핑
|
||
if(x<0){w+=x;x=0;} if(y<0){h+=y;y=0;}
|
||
if(x+w>pw) w=pw-x; if(y+h>ph) h=ph-y;
|
||
}
|
||
wrapper.style.left=x+'px'; wrapper.style.top=y+'px';
|
||
wrapper.style.width=w+'px'; wrapper.style.height=h+'px';
|
||
}
|
||
function onUp(){
|
||
document.removeEventListener('mousemove',onMove);
|
||
document.removeEventListener('mouseup',onUp);
|
||
const ed=outlineEdits[slideIdx]||(outlineEdits[slideIdx]={});
|
||
ed.img_x=parseFloat((wrapper.offsetLeft/pw).toFixed(4));
|
||
ed.img_y=parseFloat((wrapper.offsetTop/ph).toFixed(4));
|
||
ed.img_w=parseFloat((wrapper.offsetWidth/pw).toFixed(4));
|
||
ed.img_h=parseFloat((wrapper.offsetHeight/ph).toFixed(4));
|
||
sedDirty=true;
|
||
}
|
||
document.addEventListener('mousemove',onMove);
|
||
document.addEventListener('mouseup',onUp);
|
||
});
|
||
}
|
||
|
||
function attachTextDrag(txw, prevEl, slideIdx, ph){
|
||
txw.addEventListener('mousedown', function(e){
|
||
if(e.target.closest('#sed-img-wrapper')) return;
|
||
e.preventDefault(); e.stopPropagation();
|
||
const startMy=e.clientY, y0=txw.offsetTop;
|
||
function onMove(ev){
|
||
const y=Math.max(0, Math.min(ph-20, y0+(ev.clientY-startMy)));
|
||
txw.style.top=y+'px';
|
||
}
|
||
function onUp(){
|
||
document.removeEventListener('mousemove',onMove);
|
||
document.removeEventListener('mouseup',onUp);
|
||
const ed=outlineEdits[slideIdx]||(outlineEdits[slideIdx]={});
|
||
const _hh=txw.firstElementChild?txw.firstElementChild.offsetHeight:0;
|
||
ed.text_y=parseFloat(((txw.offsetTop+_hh)/ph).toFixed(4));
|
||
sedDirty=true;
|
||
}
|
||
document.addEventListener('mousemove',onMove);
|
||
document.addEventListener('mouseup',onUp);
|
||
});
|
||
}
|
||
|
||
// ── modal click-outside close ────────────────────────────────────────────────
|
||
document.getElementById('abg').addEventListener('click',e=>{if(e.target===e.currentTarget)closeAbModal();});
|
||
window.addEventListener('resize',()=>{if(editorIdx>=0)sedRenderPreview();});
|
||
document.getElementById('ibg').addEventListener('click',e=>{if(e.target===e.currentTarget)closeIModal();});
|
||
document.getElementById('slideshow').addEventListener('click',e=>{if(e.target===e.currentTarget)ssClose();});
|
||
</script>
|
||
</body>
|
||
</html>
|