From 912ffdbe2399436fb44da85fb919a475246f0527 Mon Sep 17 00:00:00 2001 From: taekyoungc Date: Wed, 10 Jun 2026 10:19:37 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20=EC=9B=8C=ED=81=AC=EC=8A=A4=ED=8E=98?= =?UTF-8?q?=EC=9D=B4=EC=8A=A4=20=EB=B6=84=ED=95=A0=20=EB=B3=B4=EA=B8=B0(2?= =?UTF-8?q?=C2=B74=EB=B6=84=ED=95=A0)=20+=20=EA=B5=AC=EB=B6=84=EC=84=A0=20?= =?UTF-8?q?=EB=93=9C=EB=9E=98=EA=B7=B8=20=ED=81=AC=EA=B8=B0=20=EC=A1=B0?= =?UTF-8?q?=EC=A0=88?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 탭바에 분할 레이아웃 버튼 추가: 1분할 / 2분할(좌우) / 2분할(상하) / 4분할 - iframe reparent 없이 absolute 위치만 재계산해 작업 상태 보존 - 포커스된 칸에 탭 클릭으로 화면 배치, 칸 헤더(↻ 새로고침 · × 비우기) - 칸 안 클릭 시 해당 칸 포커스 - 분할 구분선 드래그로 칸 크기(비율) 조절, 더블클릭 50% 초기화 - 드래그 중 투명 오버레이로 iframe 위에서도 이벤트 유지 - 비율 12~88% 제한 - 레이아웃·칸 배치·비율을 세션에 저장/복원(계정별 격리 유지) - 단축키를 포커스 칸 기준으로 동작하도록 정리 - 매뉴얼: [화면 구성·워크스페이스] 에 분할 보기·크기 조절 절 추가, 개요 안내 보강 - e2e: 분할 보기(2·4분할 전환) 케이스 추가 Co-Authored-By: Claude Opus 4.8 --- app/Docs/manual/00_overview.md | 2 +- app/Docs/manual/05_workspace.md | 41 +++- app/Views/bag/layout/workspace.php | 323 ++++++++++++++++++++++++----- e2e/workspace.spec.js | 40 ++++ 4 files changed, 345 insertions(+), 61 deletions(-) diff --git a/app/Docs/manual/00_overview.md b/app/Docs/manual/00_overview.md index f10595f..1a9d139 100644 --- a/app/Docs/manual/00_overview.md +++ b/app/Docs/manual/00_overview.md @@ -31,7 +31,7 @@ - 메뉴를 클릭하면 **탭으로 열립니다.** 여러 화면을 열어두고 전환해도 입력하던 내용이 유지됩니다. - 각 화면에서 **"이 화면 설명"(❓) 버튼**을 누르면, 그 화면에 해당하는 매뉴얼이 새 탭으로 열립니다. -> 탭 사용법, 탭별 새로고침, **키보드 단축키**(Alt+1~9 / Alt+W / Alt+[ / Alt+]) 등 자세한 내용은 좌측 목차 **[화면 구성·워크스페이스·단축키]** 를 참고하세요. +> 탭 사용법, **분할 보기(2·4분할)·구분선 드래그로 크기 조절**, **키보드 단축키**(Alt+1~9 / Alt+W / Alt+[ / Alt+]) 등 자세한 내용은 좌측 목차 **[화면 구성·워크스페이스·단축키]** 를 참고하세요. ## 4. 사용자 역할(권한) diff --git a/app/Docs/manual/05_workspace.md b/app/Docs/manual/05_workspace.md index 2e94d32..1bbf911 100644 --- a/app/Docs/manual/05_workspace.md +++ b/app/Docs/manual/05_workspace.md @@ -38,28 +38,55 @@ - **다른 아이디로 로그인하면** 이전 사용자의 탭은 복원되지 않고 **기본 화면으로 초기화**됩니다. (계정별로 분리됩니다.) - 복원되는 것은 **열려 있던 화면 목록**입니다. 관리자 페이지를 거치는 등 작업공간을 완전히 벗어났던 경우, 각 화면은 새로 불러와지므로 **입력 중이던 폼 내용까지 그대로 살아나지는 않습니다.** -## 3. 키보드 단축키 +## 3. 분할 보기 (여러 화면 한눈에) + +여러 화면을 **동시에 펼쳐 놓고** 비교하거나 함께 작업할 수 있습니다. 탭바 오른쪽의 **분할 버튼**으로 화면을 나눕니다. + +| 버튼 | 모양 | 설명 | +|---|---|---| +| **1분할** | □ | 한 화면만 크게 (기본) | +| **2분할 (좌우)** | ◫ | 화면을 왼쪽/오른쪽 두 칸으로 | +| **2분할 (상하)** | ⬓ | 화면을 위/아래 두 칸으로 | +| **4분할** | ⊞ | 2×2 네 칸으로 | + +### 칸에 화면 배치하기 + +- 분할하면 열어 둔 화면들이 칸에 자동으로 채워집니다. 빈 칸에는 안내 문구가 표시됩니다. +- **칸을 클릭하면 그 칸이 "선택"**(파란 테두리)됩니다. 이 상태에서 **위 탭바의 탭을 클릭**하면 그 화면이 **선택된 칸**에 들어갑니다. +- 각 칸 위의 **헤더**에는 화면 이름과 함께 **↻(이 칸 새로고침)·×(이 칸 비우기)** 버튼이 있습니다. + +### 칸 크기 조절 + +- 칸 사이의 **구분선에 마우스를 올리면 ↔/↕ 커서**가 나타납니다. **드래그**하면 칸 크기(비율)를 자유롭게 조절할 수 있습니다. +- 구분선을 **더블클릭**하면 **50:50으로 초기화**됩니다. +- 조절한 분할 모양·크기도 새로고침·관리자 왕복 후 **그대로 복원**됩니다. + +> 분할 상태에서도 각 화면의 작업 내용은 그대로 유지됩니다. 마음껏 나눴다 합쳤다 해도 입력하던 내용이 사라지지 않습니다. + +## 4. 키보드 단축키 자주 쓰는 동작은 키보드로 더 빠르게 할 수 있습니다. 브라우저 기본 단축키와 겹치지 않도록 **Alt 키**를 함께 누르는 방식입니다. +동작은 **현재 선택된 칸**을 기준으로 적용됩니다(1분할이면 그 한 화면). + | 단축키 | 동작 | |---|---| -| **Alt + 1 ~ 9** | 왼쪽에서 **n번째 탭**으로 이동 | -| **Alt + W** | **현재 탭 닫기** | -| **Alt + ]** | **다음 탭**으로 이동 | -| **Alt + [** | **이전 탭**으로 이동 | +| **Alt + 1 ~ 9** | **n번째 탭**을 선택된 칸에 표시 | +| **Alt + W** | 선택된 칸의 **탭 닫기** | +| **Alt + ]** | 선택된 칸을 **다음 탭**으로 | +| **Alt + [** | 선택된 칸을 **이전 탭**으로 | > macOS 에서도 동일하게 **Option(⌥)** 키가 Alt 역할을 합니다 (예: ⌥ + 1). > > 참고: `Ctrl/⌘ + W`, `Ctrl/⌘ + 숫자`, `Ctrl + Tab` 은 **브라우저 자체가 먼저 가로채기** 때문에 이 시스템에서 다른 용도로 바꿀 수 없어, 위와 같이 Alt 조합을 사용합니다. -## 4. 그 밖의 이동 +## 5. 그 밖의 이동 - **관리자** 버튼(헤더 오른쪽, 관리자 권한일 때) — 메뉴·코드·판매소 등 **관리자 설정 화면**으로 이동합니다. 갔다가 워크스페이스로 돌아오면 열어 두었던 탭이 복원됩니다. - **지자체 선택**(왼쪽 사이드바 아래) — 슈퍼 관리자가 **작업할 지자체를 바꿀 때** 사용합니다. - **로그아웃**(헤더 오른쪽) — 시스템에서 나갑니다. -## 5. 도움말 보는 법 +## 6. 도움말 보는 법 - 각 작업 화면의 **"이 화면 설명"(❓) 버튼** — 지금 보고 있는 화면에 해당하는 매뉴얼이 새 탭으로 열립니다. - 이 매뉴얼 왼쪽 위 **검색창** — 모든 매뉴얼 페이지에서 단어를 찾아, 결과를 누르면 해당 페이지의 그 단어 위치로 이동해 **노란색으로 표시**해 줍니다. diff --git a/app/Views/bag/layout/workspace.php b/app/Views/bag/layout/workspace.php index 773b325..505c616 100644 --- a/app/Views/bag/layout/workspace.php +++ b/app/Views/bag/layout/workspace.php @@ -43,17 +43,39 @@ if ($effectiveLgIdx) { html, body { height: 100%; } body.gov-portal-shell { height: 100vh; overflow: hidden; } .ws-main { display: flex; flex-direction: column; flex: 1; min-width: 0; } - .ws-tabbar { display: flex; align-items: stretch; gap: 2px; background: #e9eef5; border-bottom: 1px solid var(--border); padding: 4px 6px 0; overflow-x: auto; min-height: 36px; } + .ws-topbar { display: flex; align-items: stretch; background: #e9eef5; border-bottom: 1px solid var(--border); } + .ws-tabbar { display: flex; align-items: stretch; gap: 2px; padding: 4px 6px 0; overflow-x: auto; min-height: 36px; flex: 1; min-width: 0; } .ws-tab { display: inline-flex; align-items: center; gap: .4rem; max-width: 200px; padding: .35rem .6rem; background: #f5f7fa; border: 1px solid var(--border); border-bottom: none; border-radius: 7px 7px 0 0; font-size: .78rem; color: #555; cursor: pointer; white-space: nowrap; } .ws-tab .t-name { overflow: hidden; text-overflow: ellipsis; max-width: 150px; } .ws-tab.active { background: #fff; color: var(--navy); font-weight: 700; box-shadow: 0 -2px 0 #007bff inset; } + .ws-tab.focused-tab { box-shadow: 0 -2px 0 #243a5e inset; } .ws-tab .t-refresh, .ws-tab .t-close { width: 16px; height: 16px; line-height: 14px; text-align: center; border-radius: 50%; color: #999; font-size: 12px; } .ws-tab .t-refresh:hover { background: #dbeafe; color: #1d4ed8; } .ws-tab .t-close:hover { background: #e2e8f0; color: #333; } - .ws-panels { flex: 1; position: relative; min-height: 0; background: #f0f4f8; } - .ws-frame { position: absolute; inset: 0; width: 100%; height: 100%; border: 0; display: none; background: #f0f4f8; } - .ws-frame.active { display: block; } - .ws-empty { position: absolute; inset: 0; display: flex; flex-direction: column; align-items: center; justify-content: center; color: #94a3b8; font-size: .9rem; gap: .5rem; } + /* 분할 레이아웃 컨트롤 */ + .ws-layout { display: flex; align-items: center; gap: 3px; padding: 4px 8px; flex-shrink: 0; border-left: 1px solid var(--border); } + .ws-layout button { width: 30px; height: 26px; border: 1px solid var(--border); background: #f5f7fa; border-radius: 6px; color: #64748b; cursor: pointer; font-size: 12px; display: inline-flex; align-items: center; justify-content: center; } + .ws-layout button:hover { background: #e2e8f0; color: #334155; } + .ws-layout button.active { background: #243a5e; color: #fff; border-color: #243a5e; } + .ws-panels { flex: 1; position: relative; min-height: 0; background: #cbd5e1; } + .ws-frame { position: absolute; left: 0; top: 0; width: 100%; height: 100%; border: 0; display: none; background: #fff; box-sizing: border-box; } + /* 분할 칸 헤더 */ + .ws-slot-head { position: absolute; display: none; align-items: center; gap: .3rem; height: 28px; padding: 0 .3rem 0 .6rem; background: #eef2f7; border-bottom: 1px solid var(--border); font-size: .72rem; color: #475569; box-sizing: border-box; cursor: pointer; z-index: 3; } + .ws-slot-head.focused { background: #dbeafe; color: var(--navy); box-shadow: inset 0 2px 0 #007bff; font-weight: 700; } + .ws-slot-head .sh-name { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } + .ws-slot-head .sh-btn { width: 18px; height: 18px; line-height: 16px; text-align: center; border-radius: 50%; color: #94a3b8; flex-shrink: 0; font-size: 12px; } + .ws-slot-head .sh-btn:hover { background: #fff; color: #334155; } + .ws-slot-empty { position: absolute; display: none; align-items: center; justify-content: center; background: #f8fafc; color: #94a3b8; font-size: .8rem; box-sizing: border-box; cursor: pointer; z-index: 1; text-align: center; padding: 1rem; } + /* 분할 구분선 드래그 핸들 */ + .ws-gutter { position: absolute; display: none; z-index: 6; background: transparent; } + .ws-gutter.v { width: 9px; height: 100%; top: 0; cursor: col-resize; } + .ws-gutter.h { width: 100%; height: 9px; left: 0; cursor: row-resize; } + .ws-gutter:hover { background: rgba(37, 99, 235, .25); } + /* 드래그 중 iframe 위에서도 마우스 이벤트를 받기 위한 오버레이 */ + .ws-drag-overlay { position: absolute; inset: 0; z-index: 50; display: none; } + .ws-drag-overlay.v { cursor: col-resize; } + .ws-drag-overlay.h { cursor: row-resize; } + .ws-empty { position: absolute; inset: 0; display: flex; flex-direction: column; align-items: center; justify-content: center; color: #94a3b8; font-size: .9rem; gap: .5rem; z-index: 2; background: #f0f4f8; } @@ -78,7 +100,15 @@ if ($effectiveLgIdx) {
-
+
+
+
+ + + + +
+
@@ -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 = '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('', '업무 현황'); 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');