@@ -96,46 +126,227 @@ if ($effectiveLgIdx) {
var empty = document.getElementById('wsEmpty');
var tabs = {}; // id -> {url,title,frame,btn}
var order = [];
- var activeId = null;
var MAX = 12;
+ // 분할 레이아웃 상태
+ var layout = 'single'; // single | lr | tb | quad
+ var slots = [null]; // 칸별로 배치된 tab id (길이 = 칸 수)
+ var focused = 0; // 포커스된 칸 인덱스
+
function norm(u) { try { var a = new URL(u, location.origin); return a.pathname + a.search; } catch (e) { return u; } }
function withEmbed(u) {
try { var a = new URL(u, location.origin); a.searchParams.set('embed', '1'); return a.pathname + a.search; }
catch (e) { return u + (u.indexOf('?') >= 0 ? '&' : '?') + 'embed=1'; }
}
+ // 분할 비율 (좌 컬럼 폭 / 위 행 높이), 드래그로 조절
+ var vRatio = 0.5, hRatio = 0.5;
+ var RATIO_MIN = 0.12, RATIO_MAX = 0.88;
+ function pct(r) { return (r * 100).toFixed(3) + '%'; }
+
+ // 레이아웃별 칸 사각형 (2px 간격, 비율 반영) — left/top/width/height CSS 문자열
+ function rectsFor(mode) {
+ var Lw = 'calc(' + pct(vRatio) + ' - 1px)'; // 좌 컬럼 폭
+ var Rl = 'calc(' + pct(vRatio) + ' + 1px)'; // 우 컬럼 시작
+ var Rw = 'calc(' + pct(1 - vRatio) + ' - 1px)'; // 우 컬럼 폭
+ var Th = 'calc(' + pct(hRatio) + ' - 1px)'; // 위 행 높이
+ var Bt = 'calc(' + pct(hRatio) + ' + 1px)'; // 아래 행 시작
+ var Bh = 'calc(' + pct(1 - hRatio) + ' - 1px)'; // 아래 행 높이
+ if (mode === 'lr') return [
+ { l: '0', t: '0', w: Lw, h: '100%' },
+ { l: Rl, t: '0', w: Rw, h: '100%' }
+ ];
+ if (mode === 'tb') return [
+ { l: '0', t: '0', w: '100%', h: Th },
+ { l: '0', t: Bt, w: '100%', h: Bh }
+ ];
+ if (mode === 'quad') return [
+ { l: '0', t: '0', w: Lw, h: Th },
+ { l: Rl, t: '0', w: Rw, h: Th },
+ { l: '0', t: Bt, w: Lw, h: Bh },
+ { l: Rl, t: Bt, w: Rw, h: Bh }
+ ];
+ return [{ l: '0', t: '0', w: '100%', h: '100%' }]; // single
+ }
+
var STORE_KEY = 'jrj_ws_tabs';
var WS_OWNER = '= (string) (session()->get('mb_idx') ?? '') ?>'; // 탭 저장 소유자(로그인 사용자) 식별
function persist() {
try {
- var data = {
+ sessionStorage.setItem(STORE_KEY, JSON.stringify({
owner: WS_OWNER,
tabs: order.map(function (id) { return { url: tabs[id].url, title: tabs[id].title }; }),
- active: activeId
- };
- sessionStorage.setItem(STORE_KEY, JSON.stringify(data));
+ layout: layout,
+ focused: focused,
+ vRatio: vRatio,
+ hRatio: hRatio,
+ slots: slots.map(function (id) { return (id && tabs[id]) ? tabs[id].url : null; })
+ }));
} catch (e) {}
}
- function activate(id) {
- if (!tabs[id]) return;
- activeId = id;
+ // 분할 칸 헤더·빈칸 안내 요소 4개 미리 생성
+ var slotHeads = [], slotEmpties = [];
+ for (var si = 0; si < 4; si++) {
+ (function (idx) {
+ var h = document.createElement('div');
+ h.className = 'ws-slot-head';
+ var nm = document.createElement('span'); nm.className = 'sh-name';
+ var rl = document.createElement('span'); rl.className = 'sh-btn sh-reload'; rl.textContent = '↻'; rl.title = '이 칸 새로고침';
+ var cl = document.createElement('span'); cl.className = 'sh-btn sh-clear'; cl.textContent = '×'; cl.title = '이 칸 비우기';
+ h.appendChild(nm); h.appendChild(rl); h.appendChild(cl);
+ h.addEventListener('click', function (e) {
+ if (e.target === rl) { var sid = slots[idx]; if (sid) reloadTab(sid); return; }
+ if (e.target === cl) { slots[idx] = null; render(); return; }
+ focusSlot(idx);
+ });
+ panels.appendChild(h);
+ var emp = document.createElement('div');
+ emp.className = 'ws-slot-empty';
+ emp.textContent = '+ 위 탭에서 이 칸에 표시할 화면을 선택하세요';
+ emp.addEventListener('click', function () { focusSlot(idx); });
+ panels.appendChild(emp);
+ slotHeads.push(h); slotEmpties.push(emp);
+ })(si);
+ }
+
+ // 분할 구분선(드래그 핸들) + 드래그용 투명 오버레이
+ var vGutter = document.createElement('div'); vGutter.className = 'ws-gutter v';
+ var hGutter = document.createElement('div'); hGutter.className = 'ws-gutter h';
+ var dragOverlay = document.createElement('div'); dragOverlay.className = 'ws-drag-overlay';
+ panels.appendChild(vGutter); panels.appendChild(hGutter); panels.appendChild(dragOverlay);
+
+ function clampRatio(r) { return Math.min(RATIO_MAX, Math.max(RATIO_MIN, r)); }
+ function startDrag(axis) {
+ dragOverlay.className = 'ws-drag-overlay ' + axis;
+ dragOverlay.style.display = 'block';
+ function move(ev) {
+ var rect = panels.getBoundingClientRect();
+ if (axis === 'v') vRatio = clampRatio((ev.clientX - rect.left) / rect.width);
+ else hRatio = clampRatio((ev.clientY - rect.top) / rect.height);
+ render();
+ }
+ function up() {
+ document.removeEventListener('mousemove', move);
+ document.removeEventListener('mouseup', up);
+ dragOverlay.style.display = 'none';
+ persist();
+ }
+ document.addEventListener('mousemove', move);
+ document.addEventListener('mouseup', up);
+ }
+ vGutter.addEventListener('mousedown', function (e) { e.preventDefault(); startDrag('v'); });
+ hGutter.addEventListener('mousedown', function (e) { e.preventDefault(); startDrag('h'); });
+ // 더블클릭 시 50%로 초기화
+ vGutter.addEventListener('dblclick', function () { vRatio = 0.5; render(); persist(); });
+ hGutter.addEventListener('dblclick', function () { hRatio = 0.5; render(); persist(); });
+
+ var layoutBtns = Array.prototype.slice.call(document.querySelectorAll('#wsLayout button'));
+
+ // CSS 길이 가감 (calc(0 + 28px) 같은 무효 표현 방지)
+ function addPx(val, px) { return val === '0' ? (px + 'px') : ('calc(' + val + ' + ' + px + 'px)'); }
+ function subPx(val, px) { return 'calc(' + val + ' - ' + px + 'px)'; }
+
+ function render() {
+ var rects = rectsFor(layout);
+ var n = rects.length;
+ var split = layout !== 'single';
+ // single 모드에서 포커스 칸이 비면 가장 최근 탭으로 자동 채움
+ if (!split && !(slots[0] && tabs[slots[0]]) && order.length) {
+ slots[0] = order[order.length - 1];
+ }
+ if (focused >= n) focused = n - 1;
+ var shown = {};
+ for (var i = 0; i < 4; i++) {
+ var head = slotHeads[i], emp = slotEmpties[i];
+ if (i >= n) { head.style.display = 'none'; emp.style.display = 'none'; continue; }
+ var r = rects[i];
+ var bodyTop = split ? addPx(r.t, 28) : r.t;
+ var bodyH = split ? subPx(r.h, 28) : r.h;
+ if (split) {
+ head.style.display = 'flex';
+ head.style.left = r.l; head.style.top = r.t; head.style.width = r.w;
+ head.classList.toggle('focused', i === focused);
+ var sid = slots[i];
+ head.querySelector('.sh-name').textContent = (sid && tabs[sid]) ? tabs[sid].title : '비어 있음';
+ } else {
+ head.style.display = 'none';
+ }
+ var id = slots[i];
+ if (id && tabs[id]) {
+ shown[id] = true;
+ var f = tabs[id].frame;
+ f.style.left = r.l; f.style.top = bodyTop; f.style.width = r.w; f.style.height = bodyH;
+ f.style.display = 'block';
+ emp.style.display = 'none';
+ } else if (split) {
+ emp.style.display = 'flex';
+ emp.style.left = r.l; emp.style.top = bodyTop; emp.style.width = r.w; emp.style.height = bodyH;
+ } else {
+ emp.style.display = 'none';
+ }
+ }
+ // 어느 칸에도 없는 iframe 은 숨김 / 포커스 칸 프레임에 active 표시
Object.keys(tabs).forEach(function (k) {
- tabs[k].frame.classList.toggle('active', k === id);
- tabs[k].btn.classList.toggle('active', k === id);
+ if (!shown[k]) tabs[k].frame.style.display = 'none';
+ tabs[k].frame.classList.toggle('active', k === slots[focused]);
});
- if (empty) empty.style.display = 'none';
- // 현재 탭에 맞춰 좌측 사이드바(대메뉴/소메뉴) 강조 동기화
- try { if (window.govPortalNav && tabs[id]) window.govPortalNav.syncByUrl(tabs[id].url); } catch (e) {}
+ // 탭 버튼 강조 (표시 중 = active, 포커스 칸 = focused-tab)
+ Object.keys(tabs).forEach(function (k) {
+ var pos = slots.indexOf(k);
+ tabs[k].btn.classList.toggle('active', pos >= 0 && pos < n);
+ tabs[k].btn.classList.toggle('focused-tab', split && slots[focused] === k);
+ });
+ // 분할 구분선 위치/표시
+ if (layout === 'lr' || layout === 'quad') { vGutter.style.display = 'block'; vGutter.style.left = 'calc(' + pct(vRatio) + ' - 4px)'; }
+ else { vGutter.style.display = 'none'; }
+ if (layout === 'tb' || layout === 'quad') { hGutter.style.display = 'block'; hGutter.style.top = 'calc(' + pct(hRatio) + ' - 4px)'; }
+ else { hGutter.style.display = 'none'; }
+ // 레이아웃 버튼 강조
+ layoutBtns.forEach(function (b) { b.classList.toggle('active', b.getAttribute('data-mode') === layout); });
+ // 전체 빈 상태
+ if (empty) empty.style.display = order.length === 0 ? 'flex' : 'none';
+ // 포커스 칸 화면에 맞춰 좌측 사이드바 동기화
+ var fid = slots[focused];
+ try { if (window.govPortalNav) window.govPortalNav.syncByUrl(fid && tabs[fid] ? tabs[fid].url : ' '); } catch (e) {}
persist();
}
+ function focusSlot(i) { focused = i; render(); }
+
+ // 포커스 칸에 탭 배치 (이미 다른 칸에 있으면 두 칸을 맞바꿈)
+ function placeInFocused(id) {
+ if (!tabs[id]) return;
+ if (layout === 'single') { slots = [id]; }
+ else {
+ var j = slots.indexOf(id);
+ if (j >= 0 && j !== focused) { var tmp = slots[focused]; slots[focused] = id; slots[j] = tmp; }
+ else { slots[focused] = id; }
+ }
+ render();
+ }
+
+ function setLayout(mode) {
+ var n = rectsFor(mode).length;
+ var keep = [], seen = {};
+ slots.forEach(function (id) { if (id && tabs[id] && !seen[id]) { seen[id] = true; keep.push(id); } });
+ var ns = keep.slice(0, n);
+ for (var i = 0; i < order.length && ns.length < n; i++) { if (ns.indexOf(order[i]) < 0) ns.push(order[i]); }
+ while (ns.length < n) ns.push(null);
+ slots = ns;
+ layout = mode;
+ if (focused >= n) focused = n - 1;
+ render();
+ }
+ layoutBtns.forEach(function (b) {
+ b.addEventListener('click', function () { setLayout(b.getAttribute('data-mode')); });
+ });
+
function reloadTab(id) {
var t = tabs[id];
if (!t) return;
try { t.frame.contentWindow.location.reload(); }
- catch (e) { t.frame.src = t.frame.src; } // 교차출처 등 예외 시 src 재설정
+ catch (e) { t.frame.src = t.frame.src; }
}
function closeTab(id) {
@@ -144,31 +355,32 @@ if ($effectiveLgIdx) {
tabs[id].btn.remove();
delete tabs[id];
order = order.filter(function (x) { return x !== id; });
- if (activeId === id) {
- var next = order[order.length - 1];
- if (next) activate(next); else { activeId = null; if (empty) empty.style.display = 'flex'; }
- }
- persist();
+ for (var i = 0; i < slots.length; i++) { if (slots[i] === id) slots[i] = null; }
+ render();
}
- function openTab(url, title) {
+ function openTab(url, title, opts) {
var id = norm(url);
- if (tabs[id]) { activate(id); return; }
+ if (tabs[id]) { if (!(opts && opts.noFocus)) placeInFocused(id); return; }
if (order.length >= MAX) { closeTab(order[0]); } // 오래된 탭 정리
var frame = document.createElement('iframe');
frame.className = 'ws-frame';
frame.src = withEmbed(url);
frame.setAttribute('title', title || '탭');
- // iframe 내부에 포커스가 있어도 단축키가 동작하도록 같은 출처 문서에 핸들러 부착
+ // iframe 내부 포커스에서도 단축키 동작 + 칸 클릭 시 그 칸 포커스
frame.addEventListener('load', function () {
- try { frame.contentDocument.addEventListener('keydown', handleShortcut); } catch (e) {}
+ try {
+ var d = frame.contentDocument;
+ d.addEventListener('keydown', handleShortcut);
+ d.addEventListener('mousedown', function () { var j = slots.indexOf(id); if (j >= 0 && j !== focused) focusSlot(j); }, true);
+ } catch (e) {}
});
panels.appendChild(frame);
var btn = document.createElement('div');
btn.className = 'ws-tab';
btn.setAttribute('role', 'tab');
- btn.title = title || '탭'; // 잘린 이름 전체를 툴팁으로
+ btn.title = title || '탭';
var nameSpan = document.createElement('span');
nameSpan.className = 't-name';
nameSpan.textContent = title || '탭';
@@ -183,53 +395,53 @@ if ($effectiveLgIdx) {
btn.appendChild(nameSpan); btn.appendChild(refresh); btn.appendChild(close);
btn.addEventListener('click', function (e) {
if (e.target === close) { closeTab(id); }
- else if (e.target === refresh) { activate(id); reloadTab(id); }
- else { activate(id); }
+ else if (e.target === refresh) { placeInFocused(id); reloadTab(id); }
+ else { placeInFocused(id); }
});
- // 가운데(휠) 클릭으로 탭 닫기 — 브라우저 탭과 동일한 동작
btn.addEventListener('mousedown', function (e) { if (e.button === 1) e.preventDefault(); });
btn.addEventListener('auxclick', function (e) { if (e.button === 1) { e.preventDefault(); closeTab(id); } });
bar.appendChild(btn);
tabs[id] = { url: url, title: title, frame: frame, btn: btn };
order.push(id);
- activate(id);
+ if (!(opts && opts.noFocus)) placeInFocused(id);
}
window.wsOpenTab = openTab;
- // 키보드 단축키 (브라우저 기본 단축키와 충돌하지 않도록 Alt 기반)
- // Alt+1~9: 해당 번호 탭으로 · Alt+W: 현재 탭 닫기 · Alt+[ / Alt+]: 이전/다음 탭
+ // 키보드 단축키 (Alt 기반, 포커스 칸 기준)
+ // Alt+1~9: 번호 탭을 포커스 칸에 · Alt+W: 포커스 칸 탭 닫기 · Alt+[ / Alt+]: 포커스 칸 탭 전환
function handleShortcut(e) {
if (!e.altKey || e.ctrlKey || e.metaKey) return;
var k = e.key;
if (k >= '1' && k <= '9') {
var i = parseInt(k, 10) - 1;
- if (order[i]) { e.preventDefault(); activate(order[i]); }
+ if (order[i]) { e.preventDefault(); placeInFocused(order[i]); }
} else if (k === 'w' || k === 'W' || k === 'ㅈ') {
- if (activeId) { e.preventDefault(); closeTab(activeId); }
+ var fid = slots[focused];
+ if (fid) { e.preventDefault(); closeTab(fid); }
} else if (k === '[' || k === ']') {
- if (!activeId || order.length < 2) return;
+ var cur = order.indexOf(slots[focused]);
+ if (order.length < 2) return;
e.preventDefault();
- var cur = order.indexOf(activeId);
+ if (cur < 0) cur = 0;
var nx = k === ']' ? (cur + 1) % order.length : (cur - 1 + order.length) % order.length;
- activate(order[nx]);
+ placeInFocused(order[nx]);
}
}
document.addEventListener('keydown', handleShortcut);
- // 좌측 사이드바 소메뉴 클릭 → 탭으로 열기 (전체 페이지 이동 대신)
+ // 좌측 사이드바 소메뉴 클릭 → 포커스 칸에 열기
document.querySelector('.sidebar').addEventListener('click', function (e) {
var a = e.target.closest('a[href]');
if (!a) return;
var href = a.getAttribute('href') || '';
- // 지자체 선택(sb-gray)·모바일앱(sb-teal)은 그대로 이동, 나머지(소메뉴·하단 링크)는 탭으로
if (a.closest('.sb-gray') || a.closest('.sb-teal')) return;
if (/\/logout|\/login/.test(href) || href.charAt(0) === '#' || href === '') return;
e.preventDefault();
openTab(href, (a.textContent || '').trim());
});
- // 상단 대메뉴의 직접 링크(자식 없는 대메뉴)도 탭으로
+ // 상단 대메뉴 직접 링크도 포커스 칸에 열기
document.querySelectorAll('.portal-nav-link[href]').forEach(function (lnk) {
lnk.addEventListener('click', function (e) {
var href = lnk.getAttribute('href') || '';
@@ -239,19 +451,24 @@ if ($effectiveLgIdx) {
});
});
- // 첫 화면: 세션에 저장된 탭이 있으면 복원(관리자 페이지 등 전체 이동 후 복귀·새로고침 대응),
- // 없으면 대시보드 탭 자동 열기
+ // 첫 화면: 세션 저장 복원(레이아웃·분할 배치 포함), 없으면 대시보드 1분할
(function restore() {
var saved = null;
try { saved = JSON.parse(sessionStorage.getItem(STORE_KEY) || 'null'); } catch (e) {}
- // 저장된 탭의 소유자가 현재 로그인 사용자와 다르면(같은 브라우저 탭에서 계정 전환 등) 복원하지 않고 초기화
- if (saved && saved.owner !== WS_OWNER) {
- try { sessionStorage.removeItem(STORE_KEY); } catch (e) {}
- saved = null;
- }
+ if (saved && saved.owner !== WS_OWNER) { try { sessionStorage.removeItem(STORE_KEY); } catch (e) {} saved = null; }
if (saved && saved.tabs && saved.tabs.length) {
- saved.tabs.forEach(function (t) { if (t && t.url) openTab(t.url, t.title); });
- if (saved.active && tabs[saved.active]) activate(saved.active);
+ saved.tabs.forEach(function (t) { if (t && t.url) openTab(t.url, t.title, { noFocus: true }); });
+ layout = (saved.layout === 'lr' || saved.layout === 'tb' || saved.layout === 'quad') ? saved.layout : 'single';
+ if (typeof saved.vRatio === 'number') vRatio = clampRatio(saved.vRatio);
+ if (typeof saved.hRatio === 'number') hRatio = clampRatio(saved.hRatio);
+ var n = rectsFor(layout).length;
+ slots = [];
+ for (var i = 0; i < n; i++) {
+ var u = saved.slots && saved.slots[i] ? norm(saved.slots[i]) : null;
+ slots.push(u && tabs[u] ? u : null);
+ }
+ focused = Math.min(Math.max(parseInt(saved.focused, 10) || 0, 0), n - 1);
+ render();
return;
}
openTab('= base_url('/') ?>', '업무 현황');
diff --git a/e2e/workspace.spec.js b/e2e/workspace.spec.js
index 5f33b23..84e59b0 100644
--- a/e2e/workspace.spec.js
+++ b/e2e/workspace.spec.js
@@ -93,6 +93,46 @@ test.describe('워크스페이스 탭', () => {
await expect(page.locator('.ws-tab')).toHaveCount(1);
});
+ test('분할 보기: 2분할(좌우)·4분할에서 여러 화면 동시 표시', async ({ page }) => {
+ await login(page, 'admin');
+ await page.goto('/admin/select-local-government');
+ await page.evaluate(() => {
+ const r = document.querySelector('input[name="lg_idx"][value="1"]');
+ if (r) { r.checked = true; r.form.submit(); }
+ });
+ await page.waitForTimeout(700);
+ await page.goto('/workspace');
+ await page.waitForTimeout(2500);
+
+ // 분할 컨트롤 버튼 4개 존재
+ await expect(page.locator('#wsLayout button')).toHaveCount(4);
+
+ // 소메뉴 2개를 탭으로 열기
+ await page.locator('.sidebar .my-menu-list a').nth(0).click();
+ await page.waitForTimeout(1200);
+ await page.locator('.sidebar .my-menu-list a').nth(1).click();
+ await page.waitForTimeout(1200);
+ // 단일 모드: 화면에 보이는 프레임은 1개
+ await expect(page.locator('.ws-frame:visible')).toHaveCount(1);
+
+ // 2분할(좌우) → 보이는 프레임 2개 + 칸 헤더 2개
+ await page.locator('#wsLayout button[data-mode="lr"]').click();
+ await page.waitForTimeout(600);
+ await expect(page.locator('.ws-frame:visible')).toHaveCount(2);
+ await expect(page.locator('.ws-slot-head:visible')).toHaveCount(2);
+
+ // 4분할 → 칸 헤더 4개
+ await page.locator('#wsLayout button[data-mode="quad"]').click();
+ await page.waitForTimeout(600);
+ await expect(page.locator('.ws-slot-head:visible')).toHaveCount(4);
+
+ // 다시 1분할 → 보이는 프레임 1개
+ await page.locator('#wsLayout button[data-mode="single"]').click();
+ await page.waitForTimeout(600);
+ await expect(page.locator('.ws-frame:visible')).toHaveCount(1);
+ await expect(page.locator('.ws-slot-head:visible')).toHaveCount(0);
+ });
+
test('편의: 가운데클릭 닫기·사이드바 동기화', async ({ page }) => {
await login(page, 'admin');
await page.goto('/admin/select-local-government');