Add Excel modal resize/drag and pop-out new-window viewer
- Luckysheet modal: 8-direction resize handles (edge + corner), title bar drag-to-move - First drag/resize converts from flex-centered to absolute positioning - Minimize/close reset to centered state on reopen - New xlsx-viewer.html: standalone Luckysheet page for opening xlsx in separate window - Added "↗ 새 창" button on xlsx cards to open viewer in a resizable popup window - Viewer shares session cookies so auth works transparently Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
+156
-4
@@ -8566,6 +8566,7 @@ function _renderLinkButton(text, url, opts) {
|
||||
+ '<span style="font-size:14px">📊</span>'
|
||||
+ '<span style="font-weight:600;color:var(--text)">' + escHtml(text) + '</span>'
|
||||
+ '<button class="pptx-preview-btn" data-xlsx-url="' + xlsxSafeUrl + '" data-xlsx-save-url="' + xlsxSafeUrl + '" onclick="toggleXlsxEmbedBtn(this)" style="background:linear-gradient(135deg,#3b82f6,#2563eb)">✏️ 편집</button>'
|
||||
+ '<button class="pptx-preview-btn" onclick="openXlsxNewWindow(\'' + xlsxSafeUrl + '\',\'' + escHtml(text) + '\')" style="background:linear-gradient(135deg,#6366f1,#4f46e5)" title="새 창에서 열기">↗ 새 창</button>'
|
||||
+ '<a href="' + xlsxSafeUrl + '" download class="pptx-preview-btn" style="text-decoration:none">⬇ 다운로드</a>'
|
||||
+ '</div>'
|
||||
+ ''
|
||||
@@ -8607,6 +8608,13 @@ function togglePdfEmbed(url, containerId, btn) {
|
||||
|
||||
var _luckyCurrentSaveUrl = null;
|
||||
|
||||
function openXlsxNewWindow(url, title) {
|
||||
var q = '/xlsx-viewer.html?file=' + encodeURIComponent(url)
|
||||
+ '&save=' + encodeURIComponent(url)
|
||||
+ '&title=' + encodeURIComponent(title || '스프레드시트');
|
||||
window.open(q, '_blank', 'width=1200,height=800,menubar=no,toolbar=no,location=no,status=no,scrollbars=no,resizable=yes');
|
||||
}
|
||||
|
||||
function toggleXlsxEmbedBtn(btn) {
|
||||
var url = encodeURI(btn.getAttribute('data-xlsx-url'));
|
||||
var card = btn.closest('.xlsx-card');
|
||||
@@ -8622,10 +8630,21 @@ function openLuckyModal(url, title, saveUrl) {
|
||||
var container = document.getElementById('lucky-container');
|
||||
if (!modal || !container) return;
|
||||
|
||||
// Reset box to centered state on each open
|
||||
var box = document.getElementById('lucky-modal-box');
|
||||
if (box) {
|
||||
box.style.position = 'relative';
|
||||
box.style.left = ''; box.style.top = '';
|
||||
box.style.width = '96vw'; box.style.height = '92vh';
|
||||
box._isAbsolute = false; box.dataset.minimized = '0';
|
||||
}
|
||||
modal.style.display = 'flex';
|
||||
modal.style.alignItems = 'center';
|
||||
modal.style.justifyContent = 'center';
|
||||
titleEl.textContent = title || '스프레드시트';
|
||||
_luckyCurrentSaveUrl = saveUrl || null;
|
||||
container.innerHTML = '<div style="display:flex;align-items:center;justify-content:center;height:100%;color:#666;font-size:13px">Luckysheet 로딩 중… (최초 1회 ~3MB)</div>';
|
||||
initLuckyModalInteraction();
|
||||
|
||||
fetch(url, { headers: authHeaders() })
|
||||
.then(function(r) { if (!r.ok) throw new Error('HTTP ' + r.status); return r.arrayBuffer(); })
|
||||
@@ -8664,9 +8683,46 @@ function openLuckyModal(url, title, saveUrl) {
|
||||
});
|
||||
}
|
||||
|
||||
function minimizeLuckyModal() {
|
||||
var box = document.getElementById('lucky-modal-box');
|
||||
var modal = document.getElementById('lucky-modal');
|
||||
var btn = event.target;
|
||||
if (!box || !modal) return;
|
||||
var minimized = box.dataset.minimized === '1';
|
||||
if (minimized) {
|
||||
box.style.height = box._savedHeight || '92vh';
|
||||
box.dataset.minimized = '0';
|
||||
btn.textContent = '–';
|
||||
btn.title = '최소화';
|
||||
if (!box._isAbsolute) { modal.style.alignItems = 'center'; modal.style.justifyContent = 'center'; }
|
||||
} else {
|
||||
box._savedHeight = box.style.height || '92vh';
|
||||
box.dataset.minimized = '1';
|
||||
btn.innerHTML = '◻';
|
||||
btn.title = '복원';
|
||||
if (box._isAbsolute) {
|
||||
box.style.top = (window.innerHeight - 52) + 'px';
|
||||
box.style.height = 'auto';
|
||||
} else {
|
||||
box.style.height = 'auto';
|
||||
modal.style.alignItems = 'flex-end';
|
||||
modal.style.justifyContent = 'center';
|
||||
modal.style.paddingBottom = '0';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function closeLuckyModal() {
|
||||
var modal = document.getElementById('lucky-modal');
|
||||
if (modal) modal.style.display = 'none';
|
||||
var box = document.getElementById('lucky-modal-box');
|
||||
if (modal) { modal.style.display = 'none'; modal.style.alignItems = 'center'; modal.style.justifyContent = 'center'; }
|
||||
if (box) {
|
||||
box.style.position = 'relative';
|
||||
box.style.left = ''; box.style.top = '';
|
||||
box.style.width = '96vw'; box.style.height = '92vh';
|
||||
box._isAbsolute = false; box._savedHeight = null;
|
||||
box.dataset.minimized = '0';
|
||||
}
|
||||
try { luckysheet.destroy(); } catch(e) {}
|
||||
_luckyCurrentSaveUrl = null;
|
||||
}
|
||||
@@ -8676,6 +8732,101 @@ document.getElementById('lucky-modal')?.addEventListener('click', function(e) {
|
||||
if (e.target === this) closeLuckyModal();
|
||||
});
|
||||
|
||||
function initLuckyModalInteraction() {
|
||||
var box = document.getElementById('lucky-modal-box');
|
||||
var modal = document.getElementById('lucky-modal');
|
||||
if (!box || box._interactInit) return;
|
||||
box._interactInit = true;
|
||||
|
||||
// Inject 8 resize handles
|
||||
[
|
||||
{ dir:'n', css:'top:0;left:10px;right:10px;height:6px;cursor:n-resize' },
|
||||
{ dir:'s', css:'bottom:0;left:10px;right:10px;height:6px;cursor:s-resize' },
|
||||
{ dir:'e', css:'right:0;top:10px;bottom:10px;width:6px;cursor:e-resize' },
|
||||
{ dir:'w', css:'left:0;top:10px;bottom:10px;width:6px;cursor:w-resize' },
|
||||
{ dir:'ne', css:'top:0;right:0;width:12px;height:12px;cursor:ne-resize' },
|
||||
{ dir:'nw', css:'top:0;left:0;width:12px;height:12px;cursor:nw-resize' },
|
||||
{ dir:'se', css:'bottom:0;right:0;width:12px;height:12px;cursor:se-resize' },
|
||||
{ dir:'sw', css:'bottom:0;left:0;width:12px;height:12px;cursor:sw-resize' },
|
||||
].forEach(function(d) {
|
||||
var h = document.createElement('div');
|
||||
h.className = 'lucky-rh';
|
||||
h.dataset.dir = d.dir;
|
||||
h.style.cssText = 'position:absolute;z-index:200;' + d.css;
|
||||
box.appendChild(h);
|
||||
});
|
||||
|
||||
function toAbsolute() {
|
||||
if (box._isAbsolute) return;
|
||||
var rect = box.getBoundingClientRect();
|
||||
modal.style.display = 'block';
|
||||
box.style.position = 'absolute';
|
||||
box.style.left = rect.left + 'px';
|
||||
box.style.top = rect.top + 'px';
|
||||
box.style.width = rect.width + 'px';
|
||||
box.style.height = rect.height + 'px';
|
||||
box._isAbsolute = true;
|
||||
}
|
||||
|
||||
// Title bar drag
|
||||
var header = box.firstElementChild;
|
||||
if (header) {
|
||||
header.style.cursor = 'grab';
|
||||
header.addEventListener('mousedown', function(e) {
|
||||
if (e.target.tagName === 'BUTTON') return;
|
||||
toAbsolute();
|
||||
var rect = box.getBoundingClientRect();
|
||||
var ox = e.clientX - rect.left, oy = e.clientY - rect.top;
|
||||
header.style.cursor = 'grabbing';
|
||||
function onMove(e) {
|
||||
box.style.left = (e.clientX - ox) + 'px';
|
||||
box.style.top = Math.max(0, e.clientY - oy) + 'px';
|
||||
}
|
||||
function onUp() {
|
||||
header.style.cursor = 'grab';
|
||||
document.removeEventListener('mousemove', onMove);
|
||||
document.removeEventListener('mouseup', onUp);
|
||||
}
|
||||
document.addEventListener('mousemove', onMove);
|
||||
document.addEventListener('mouseup', onUp);
|
||||
e.preventDefault();
|
||||
});
|
||||
}
|
||||
|
||||
// Resize handle drag
|
||||
box.addEventListener('mousedown', function(e) {
|
||||
var handle = e.target.closest ? e.target.closest('.lucky-rh') : null;
|
||||
if (!handle) return;
|
||||
toAbsolute();
|
||||
var dir = handle.dataset.dir;
|
||||
var rect = box.getBoundingClientRect();
|
||||
var sx = e.clientX, sy = e.clientY;
|
||||
var sl = rect.left, st = rect.top, sw = rect.width, sh = rect.height;
|
||||
document.body.style.userSelect = 'none';
|
||||
document.body.style.cursor = handle.style.cursor;
|
||||
function onMove(e) {
|
||||
var dx = e.clientX - sx, dy = e.clientY - sy;
|
||||
var nl = sl, nt = st, nw = sw, nh = sh;
|
||||
if (dir.includes('e')) nw = Math.max(380, sw + dx);
|
||||
if (dir.includes('s')) nh = Math.max(180, sh + dy);
|
||||
if (dir.includes('w')) { nw = Math.max(380, sw - dx); nl = sl + sw - nw; }
|
||||
if (dir.includes('n')) { nh = Math.max(180, sh - dy); nt = st + sh - nh; }
|
||||
box.style.left = nl + 'px'; box.style.top = nt + 'px';
|
||||
box.style.width = nw + 'px'; box.style.height = nh + 'px';
|
||||
}
|
||||
function onUp() {
|
||||
document.body.style.userSelect = '';
|
||||
document.body.style.cursor = '';
|
||||
document.removeEventListener('mousemove', onMove);
|
||||
document.removeEventListener('mouseup', onUp);
|
||||
}
|
||||
document.addEventListener('mousemove', onMove);
|
||||
document.addEventListener('mouseup', onUp);
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
});
|
||||
}
|
||||
|
||||
function saveLuckyModal() {
|
||||
var url = _luckyCurrentSaveUrl;
|
||||
if (!url) { alert('저장할 파일 경로가 없습니다.'); return; }
|
||||
@@ -11744,11 +11895,12 @@ function handleErrorResponseWsEvent(e) {
|
||||
</div>
|
||||
<!-- ── Luckysheet Modal ─────────────────────────────────────────── -->
|
||||
<div id="lucky-modal" style="display:none;position:fixed;inset:0;z-index:9999;background:rgba(0,0,0,0.55);align-items:center;justify-content:center">
|
||||
<div style="background:#fff;width:96vw;height:92vh;border-radius:10px;display:flex;flex-direction:column;overflow:hidden;box-shadow:0 8px 40px rgba(0,0,0,0.35)">
|
||||
<div style="display:flex;align-items:center;gap:10px;padding:10px 14px;background:#f5f5f5;border-bottom:1px solid #ddd;flex-shrink:0">
|
||||
<div id="lucky-modal-box" style="background:#fff;width:96vw;height:92vh;border-radius:10px;display:flex;flex-direction:column;overflow:hidden;box-shadow:0 8px 40px rgba(0,0,0,0.35);position:relative">
|
||||
<div style="display:flex;align-items:center;gap:10px;padding:10px 14px;background:#f5f5f5;border-bottom:1px solid #ddd;flex-shrink:0;cursor:default">
|
||||
<span id="lucky-modal-title" style="font-weight:700;font-size:14px;color:#222;flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap"></span>
|
||||
<button id="lucky-modal-save" onclick="saveLuckyModal()" style="background:linear-gradient(135deg,#3b82f6,#2563eb);color:#fff;border:none;border-radius:6px;padding:5px 14px;font-size:13px;font-weight:600;cursor:pointer">💾 저장</button>
|
||||
<button onclick="closeLuckyModal()" style="background:#e5e7eb;color:#333;border:none;border-radius:6px;padding:5px 12px;font-size:13px;cursor:pointer">✕ 닫기</button>
|
||||
<button onclick="minimizeLuckyModal()" title="최소화" style="background:#e5e7eb;color:#333;border:none;border-radius:6px;padding:5px 10px;font-size:13px;cursor:pointer">–</button>
|
||||
<button onclick="closeLuckyModal()" title="닫기" style="background:#e5e7eb;color:#333;border:none;border-radius:6px;padding:5px 10px;font-size:13px;cursor:pointer">✕</button>
|
||||
</div>
|
||||
<div id="lucky-container" style="flex:1;position:relative;color-scheme:light"></div>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ko">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>스프레드시트</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Nanum+Gothic:wght@400;700;800&display=swap" rel="stylesheet">
|
||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/luckysheet@2.1.13/dist/plugins/css/pluginsCss.css">
|
||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/luckysheet@2.1.13/dist/plugins/plugins.css">
|
||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/luckysheet@2.1.13/dist/css/luckysheet.css">
|
||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/luckysheet@2.1.13/dist/assets/iconfont/iconfont.css">
|
||||
<style>
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
html, body { width: 100%; height: 100%; overflow: hidden; background: #fff; font-family: 'Nanum Gothic', sans-serif; }
|
||||
#toolbar {
|
||||
display: flex; align-items: center; gap: 8px;
|
||||
padding: 7px 14px; background: #f5f5f5; border-bottom: 1px solid #ddd;
|
||||
flex-shrink: 0; height: 46px;
|
||||
}
|
||||
#toolbar h1 { font-size: 14px; font-weight: 700; color: #222; flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.tb-btn {
|
||||
background: linear-gradient(135deg, #3b82f6, #2563eb); color: #fff;
|
||||
border: none; border-radius: 6px; padding: 5px 14px;
|
||||
font-size: 13px; font-weight: 600; cursor: pointer; font-family: inherit;
|
||||
}
|
||||
.tb-btn:disabled { opacity: 0.6; cursor: default; }
|
||||
#container {
|
||||
position: absolute; top: 46px; left: 0; right: 0; bottom: 0;
|
||||
color-scheme: light;
|
||||
}
|
||||
#container, #container * { font-family: 'Nanum Gothic', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif !important; -webkit-font-smoothing: antialiased; }
|
||||
#container .iconfont { font-family: 'iconfont' !important; }
|
||||
#loading { display: flex; align-items: center; justify-content: center; height: 100%; color: #666; font-size: 13px; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="toolbar">
|
||||
<h1 id="title">스프레드시트</h1>
|
||||
<button class="tb-btn" id="save-btn" onclick="saveFile()">💾 저장</button>
|
||||
</div>
|
||||
<div id="container">
|
||||
<div id="loading">Luckysheet 로딩 중… (최초 1회 ~3MB)</div>
|
||||
</div>
|
||||
|
||||
<script src="https://cdn.jsdelivr.net/npm/luckysheet@2.1.13/dist/plugins/js/plugin.js"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/luckysheet@2.1.13/dist/luckysheet.umd.js"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/luckyexcel@1.0.1/dist/luckyexcel.umd.js"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/xlsx@0.18.5/dist/xlsx.full.min.js"></script>
|
||||
<script>
|
||||
(function() {
|
||||
var params = new URLSearchParams(location.search);
|
||||
var fileUrl = params.get('file');
|
||||
var saveUrl = params.get('save') || fileUrl;
|
||||
var title = decodeURIComponent(params.get('title') || '스프레드시트');
|
||||
|
||||
document.getElementById('title').textContent = title;
|
||||
document.title = title;
|
||||
|
||||
function showError(msg) {
|
||||
document.getElementById('container').innerHTML =
|
||||
'<div style="padding:24px;color:#b91c1c;font-size:13px">' + msg + '</div>';
|
||||
}
|
||||
|
||||
if (!fileUrl) { showError('파일 URL이 없습니다.'); return; }
|
||||
|
||||
fetch(fileUrl, { credentials: 'include' })
|
||||
.then(function(r) {
|
||||
if (r.status === 401 || r.status === 403) throw new Error('인증 오류 — 메인 탭에서 먼저 로그인하세요.');
|
||||
if (!r.ok) throw new Error('HTTP ' + r.status);
|
||||
return r.arrayBuffer();
|
||||
})
|
||||
.then(function(buf) {
|
||||
var file = new File([buf], 'sheet.xlsx', {
|
||||
type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'
|
||||
});
|
||||
LuckyExcel.transformExcelToLucky(file, function(exportInfo) {
|
||||
document.getElementById('container').innerHTML = '';
|
||||
luckysheet.create({
|
||||
container: 'container',
|
||||
data: exportInfo.sheets,
|
||||
title: '',
|
||||
lang: 'en',
|
||||
showinfobar: false,
|
||||
showstatisticBar: false,
|
||||
sheetBottomConfig: true,
|
||||
allowUpdate: true,
|
||||
enableAddRow: true,
|
||||
enableAddCol: true,
|
||||
showToolbar: true,
|
||||
plugins: [],
|
||||
defaultFontSize: 11,
|
||||
defaultFontName: 'Nanum Gothic',
|
||||
});
|
||||
}, function(err) {
|
||||
showError('Excel 변환 오류: ' + String(err?.message || err));
|
||||
});
|
||||
})
|
||||
.catch(function(e) { showError('불러오기 실패: ' + e.message); });
|
||||
|
||||
window.saveFile = function() {
|
||||
if (!saveUrl) { alert('저장할 파일 경로가 없습니다.'); return; }
|
||||
var btn = document.getElementById('save-btn');
|
||||
btn.disabled = true; btn.textContent = '저장 중…';
|
||||
var relPath = saveUrl.replace(/^\/api\/files\//, '');
|
||||
try {
|
||||
var sheets = luckysheet.getAllSheets();
|
||||
var wb = XLSX.utils.book_new();
|
||||
sheets.forEach(function(sheet) {
|
||||
var aoa = (sheet.data || []).map(function(row) {
|
||||
return (row || []).map(function(cell) { return cell ? cell.v : null; });
|
||||
});
|
||||
XLSX.utils.book_append_sheet(wb, XLSX.utils.aoa_to_sheet(aoa), sheet.name || 'Sheet1');
|
||||
});
|
||||
var buf = XLSX.write(wb, { type: 'array', bookType: 'xlsx' });
|
||||
fetch('/api/files/' + relPath, {
|
||||
method: 'PUT',
|
||||
credentials: 'include',
|
||||
headers: { 'Content-Type': 'application/octet-stream' },
|
||||
body: new Uint8Array(buf),
|
||||
}).then(function(r) {
|
||||
btn.disabled = false;
|
||||
if (r.ok) { btn.textContent = '✅ 저장됨'; setTimeout(function() { btn.textContent = '💾 저장'; }, 2000); }
|
||||
else { btn.textContent = '❌ 실패'; setTimeout(function() { btn.textContent = '💾 저장'; }, 2000); }
|
||||
}).catch(function(e) {
|
||||
btn.disabled = false; btn.textContent = '❌ 오류';
|
||||
setTimeout(function() { btn.textContent = '💾 저장'; }, 2000);
|
||||
});
|
||||
} catch(e) {
|
||||
btn.disabled = false; btn.textContent = '❌ 오류';
|
||||
setTimeout(function() { btn.textContent = '💾 저장'; }, 2000);
|
||||
alert('저장 오류: ' + e.message);
|
||||
}
|
||||
};
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
Reference in New Issue
Block a user