feat: establish legacy parity migration baseline

This commit is contained in:
2026-07-15 07:10:01 +09:00
parent e468f43886
commit f14800656b
36 changed files with 3166 additions and 0 deletions

View File

@@ -0,0 +1,232 @@
(function () {
"use strict";
const webview = window.chrome && window.chrome.webview;
const searchInput = document.getElementById("stock-search");
const searchButton = document.getElementById("stock-search-button");
const stockResults = document.getElementById("stock-results");
const cutList = document.getElementById("cut-list");
const playlistRows = document.getElementById("playlist-rows");
const playlistDropZone = document.getElementById("playlist-drop-zone");
const status = document.getElementById("operator-status");
const dialog = document.getElementById("legacy-dialog");
const dialogMessage = document.getElementById("legacy-dialog-message");
const dialogOk = document.getElementById("legacy-dialog-ok");
let uiBusy = false;
function send(type, payload) {
if (webview && !uiBusy) webview.postMessage({ type: type, payload: payload || {} });
}
function createStockResultElement(row) {
const button = document.createElement("button");
button.type = "button";
button.dataset.resultIndex = String(row.index);
button.setAttribute("role", "option");
button.addEventListener("click", function () {
send("select-stock", { resultIndex: Number(button.dataset.resultIndex) });
});
return button;
}
function renderStockResults(state) {
state.searchResults.forEach(function (row, position) {
let button = stockResults.children.item(position);
if (!button || button.dataset.resultIndex !== String(row.index)) {
button = createStockResultElement(row);
if (position < stockResults.children.length) {
stockResults.replaceChild(button, stockResults.children.item(position));
} else {
stockResults.appendChild(button);
}
}
button.className = "stock-result" +
(state.selectedStockIndex === row.index ? " selected" : "");
button.setAttribute("aria-disabled", state.isBusy === true ? "true" : "false");
button.textContent = row.displayName;
button.setAttribute("aria-selected", state.selectedStockIndex === row.index ? "true" : "false");
});
while (stockResults.children.length > state.searchResults.length) {
stockResults.lastElementChild.remove();
}
}
function pointerPosition(event) {
const bounds = cutList.getBoundingClientRect();
return {
x: Math.max(0, Math.round(event.clientX - bounds.left)),
y: Math.max(0, Math.round(event.clientY - bounds.top))
};
}
function createCutElement(row) {
const element = document.createElement("div");
element.setAttribute("role", "option");
element.dataset.physicalIndex = String(row.physicalIndex);
element.draggable = true;
const number = document.createElement("span");
number.className = "cut-number";
number.textContent = row.displayOrdinal === null ? "" : String(row.displayOrdinal);
const label = document.createElement("span");
label.textContent = row.rawLabel;
element.append(number, label);
element.addEventListener("pointerdown", function (event) {
const position = pointerPosition(event);
send("cut-pointer-down", {
physicalIndex: row.physicalIndex,
control: event.ctrlKey,
shift: event.shiftKey,
x: position.x,
y: position.y,
timestampMilliseconds: Math.max(0, Math.round(event.timeStamp))
});
});
element.addEventListener("dragstart", function (event) {
if (event.dataTransfer) {
event.dataTransfer.effectAllowed = "move";
event.dataTransfer.setData("text/plain", "legacy-selected-cuts");
}
});
return element;
}
function renderCuts(state) {
state.cutRows.forEach(function (row, index) {
let element = cutList.children.item(index);
if (!element || element.dataset.physicalIndex !== String(row.physicalIndex)) {
element = createCutElement(row);
if (index < cutList.children.length) {
cutList.replaceChild(element, cutList.children.item(index));
} else {
cutList.appendChild(element);
}
}
element.className = "cut-row" +
(row.isSelected ? " selected" : "") +
(row.isSeparator ? " separator" : "");
element.setAttribute("aria-selected", row.isSelected ? "true" : "false");
element.setAttribute("aria-disabled", state.isBusy === true ? "true" : "false");
element.draggable = state.isBusy !== true;
});
while (cutList.children.length > state.cutRows.length) {
cutList.lastElementChild.remove();
}
}
function createPlaylistElement(row) {
const element = document.createElement("div");
element.className = "playlist-row playlist-data-row";
element.dataset.rowId = row.rowId;
const enabledCell = document.createElement("div");
enabledCell.className = "enabled-cell";
const enabled = document.createElement("input");
enabled.type = "checkbox";
enabled.addEventListener("change", function () {
send("set-playlist-enabled", {
rowId: element.dataset.rowId,
enabled: enabled.checked
});
});
enabledCell.appendChild(enabled);
element.appendChild(enabledCell);
for (let index = 0; index < 5; index += 1) {
element.appendChild(document.createElement("div"));
}
return element;
}
function renderPlaylist(state) {
state.playlist.forEach(function (row, position) {
let element = playlistRows.children.item(position);
if (!element || element.dataset.rowId !== row.rowId) {
element = createPlaylistElement(row);
if (position < playlistRows.children.length) {
playlistRows.replaceChild(element, playlistRows.children.item(position));
} else {
playlistRows.appendChild(element);
}
}
const enabled = element.querySelector("input");
enabled.checked = row.isEnabled;
enabled.setAttribute("aria-disabled", state.isBusy === true ? "true" : "false");
[row.marketText, row.stockName, row.graphicType, row.subtype, row.pageText]
.forEach(function (text, cellIndex) {
const cell = element.children.item(cellIndex + 1);
cell.textContent = text;
cell.title = text;
});
});
while (playlistRows.children.length > state.playlist.length) {
playlistRows.lastElementChild.remove();
}
}
function renderDialog(state) {
if (state.dialog && state.dialog.message) {
dialogMessage.textContent = state.dialog.message;
dialog.hidden = false;
dialogOk.focus();
} else {
dialog.hidden = true;
}
}
function render(state) {
const isBusy = state.isBusy === true;
uiBusy = isBusy;
document.body.classList.toggle("busy", isBusy);
document.body.setAttribute("aria-busy", isBusy ? "true" : "false");
searchInput.readOnly = isBusy;
searchButton.setAttribute("aria-disabled", isBusy ? "true" : "false");
if (document.activeElement !== searchInput) searchInput.value = state.searchText || "";
renderStockResults(state);
renderCuts(state);
renderPlaylist(state);
renderDialog(state);
status.textContent = isBusy ? "종목을 검색하고 있습니다." : (state.statusMessage || "");
}
searchButton.addEventListener("click", function () {
send("search-stocks", { text: searchInput.value, trigger: "button" });
});
searchInput.addEventListener("keypress", function (event) {
if (event.key === "Enter") {
event.preventDefault();
send("search-stocks", { text: searchInput.value, trigger: "enter" });
}
});
playlistDropZone.addEventListener("dragover", function (event) {
event.preventDefault();
if (event.dataTransfer) event.dataTransfer.dropEffect = "move";
});
playlistDropZone.addEventListener("drop", function (event) {
event.preventDefault();
send("drop-selected-cuts", {});
});
dialogOk.addEventListener("click", function () {
send("dismiss-dialog", {});
});
if (webview) {
webview.addEventListener("message", function (event) {
if (event.data && event.data.type === "state" && event.data.payload) {
render(event.data.payload);
}
});
send("ready", {});
} else {
status.textContent = "WebView2 브리지를 사용할 수 없습니다.";
}
}());

View File

@@ -0,0 +1,114 @@
<!doctype html>
<html lang="ko">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>V-Stock 증권정보송출시스템</title>
<link rel="stylesheet" href="styles.css">
</head>
<body>
<main class="operator-shell" aria-label="V-Stock 증권정보송출시스템">
<section class="left-panel" aria-label="종목 및 컷 선택">
<header class="brand-line">
<div class="brand"><span>매일경제TV</span><strong>VRi</strong></div>
<div class="connection-state" aria-label="Tornado2 연결 상태">미연결</div>
</header>
<div class="search-line">
<label for="stock-search">종목</label>
<input id="stock-search" maxlength="32767" autocomplete="off" spellcheck="false">
<button id="stock-search-button" type="button"><span aria-hidden="true"></span> 검색</button>
</div>
<div id="stock-results" class="stock-results" role="listbox" aria-label="종목명"></div>
<div class="manual-actions" aria-label="수동 입력">
<button type="button" disabled>주요매출 구성</button>
<button type="button" disabled>성장성 지표</button>
<button type="button" disabled>매출액</button>
<button type="button" disabled>영업이익</button>
</div>
<div id="cut-list" class="cut-list" role="listbox" aria-label="종목 컷" aria-multiselectable="true"></div>
<div id="operator-status" class="operator-status" aria-live="polite"></div>
</section>
<section class="catalog-panel" aria-label="분류별 그래픽">
<nav class="market-tabs" aria-label="시장 분류">
<button class="active" type="button" disabled>해외</button>
<button type="button" disabled>환율</button>
<button type="button" disabled>지수</button>
<button type="button" disabled>코스피</button>
<button type="button" disabled>코스닥</button>
<button type="button" disabled>비교</button>
<button type="button" disabled>테마</button>
<button type="button" disabled>해외종목</button>
<button type="button" disabled>전문가</button>
<button type="button" disabled>정지</button>
</nav>
<label class="expand-line"><input type="checkbox" checked disabled> Expand</label>
<div class="category-title">Category</div>
<div class="category-tree" aria-label="원본 카테고리">
<div class="tree-root"><strong>[1열판기본]</strong></div>
<div class="tree-item">▸ 다우</div>
<div class="tree-item">▸ 나스닥</div>
<div class="tree-item">▸ S&amp;P</div>
<div class="tree-item">▸ 독일</div>
<div class="tree-item">▸ 영국</div>
<div class="tree-item">▸ 프랑스</div>
<div class="tree-item">▸ 니케이</div>
<div class="tree-item">▸ 중국 상해</div>
<div class="tree-item">▸ 홍콩 항셍</div>
<div class="tree-item">▸ 대만 가권</div>
<div class="tree-item">▸ 싱가포르 지수</div>
<div class="tree-item">▸ 필리핀 지수</div>
<div class="tree-item">▸ 말레이시아 지수</div>
<div class="tree-root second"><strong>[2열판-]</strong></div>
<div class="tree-item">▸ 공산품 [원면, 목화(면화)]</div>
<div class="tree-item">▸ 원자재 [국제 금, 국제 은]</div>
</div>
</section>
<section class="playlist-panel" aria-label="플레이리스트">
<div class="playlist-toolbar">
<label><input type="checkbox" checked disabled> 전체</label>
<label><input type="checkbox" checked disabled> 5일선</label>
<label><input type="checkbox" checked disabled> 20일선</label>
<button type="button" disabled></button>
<button type="button" disabled></button>
<span class="toolbar-spacer"></span>
<label><input type="checkbox" disabled> 배경없음(F3)</label>
<button type="button" disabled>배경파일(F2)</button>
<input class="background-name" value="기본.vrv" readonly>
<button type="button" disabled>DB 저장</button>
<button type="button" disabled>DB 불러오기</button>
<button type="button" disabled>선택삭제</button>
<button type="button" disabled>전체삭제</button>
</div>
<div id="playlist-drop-zone" class="playlist-grid">
<div class="playlist-header playlist-row">
<div>송출</div><div>종류</div><div>종목</div><div>컷파일</div><div>세부사항</div><div>Page</div>
</div>
<div id="playlist-rows" class="playlist-rows"></div>
</div>
<div class="playout-actions">
<button class="take-in" type="button" disabled> TAKE IN [F8]</button>
<button class="next" type="button" disabled> NEXT</button>
<button class="take-out" type="button" disabled>✖ TAKE OUT[Esc]</button>
</div>
<div class="playout-status">원본 호환 UI 기준선 · 송출 연결 안 함</div>
</section>
</main>
<div id="legacy-dialog" class="dialog-backdrop" hidden>
<div class="legacy-dialog" role="alertdialog" aria-modal="true" aria-labelledby="legacy-dialog-message">
<div id="legacy-dialog-message"></div>
<button id="legacy-dialog-ok" type="button">확인</button>
</div>
</div>
<script src="app.js"></script>
</body>
</html>

View File

@@ -0,0 +1,92 @@
:root {
color-scheme: light;
font-family: "NanumGothic", "맑은 고딕", sans-serif;
font-size: 13px;
background: #efefef;
color: #111;
}
* { box-sizing: border-box; }
html, body { width: 100%; height: 100%; margin: 0; overflow: hidden; }
body.busy { cursor: wait; }
body.busy .operator-shell { pointer-events: none; }
button, input { font: inherit; }
button { border: 1px solid #a8a8a8; background: linear-gradient(#fff, #e9e9e9); color: #111; }
button:not(:disabled) { cursor: pointer; }
button:disabled { color: #555; opacity: 1; }
.operator-shell {
display: grid;
grid-template-columns: 430px 500px minmax(760px, 1fr);
width: 100%;
min-width: 1690px;
height: 100vh;
background: #efefef;
}
.left-panel, .catalog-panel, .playlist-panel { min-height: 0; border-right: 1px solid #bbb; }
.left-panel { display: flex; flex-direction: column; padding: 6px 15px 4px 20px; background: #f5f5f5; }
.brand-line { height: 68px; display: flex; align-items: center; justify-content: space-between; }
.brand { display: flex; align-items: center; gap: 12px; white-space: nowrap; }
.brand span { color: #ed671f; font-size: 28px; font-weight: 800; letter-spacing: -2px; }
.brand strong { font: italic 900 38px Arial, sans-serif; letter-spacing: -5px; }
.connection-state { padding: 10px 12px; background: #c40000; color: white; font: 700 18px Consolas, monospace; }
.search-line { display: grid; grid-template-columns: 40px 162px 1fr 117px; align-items: center; gap: 3px; height: 38px; }
.search-line label { font-weight: 700; }
.search-line input { grid-column: 2; height: 27px; padding: 2px 7px; border: 1px solid #888; font-size: 16px; }
.search-line button { grid-column: 4; height: 31px; font-size: 14px; }
.search-line button span { font-size: 21px; margin-right: 20px; }
.stock-results { height: 174px; border: 1px solid #999; background: #fff; overflow-y: auto; }
.stock-result { display: block; width: 100%; height: 20px; padding: 1px 5px; border: 0; background: #fff; text-align: left; font-size: 16px; line-height: 18px; }
.stock-result.selected { background: #696969; color: #fff; }
.manual-actions { display: grid; grid-template-columns: repeat(4, 1fr); gap: 6px; margin: 9px 0; }
.manual-actions button { height: 34px; font-size: 12px; font-weight: 700; }
.cut-list { flex: 1; min-height: 0; border: 1px solid #999; background: #fff; overflow-y: auto; user-select: none; }
.cut-row { display: grid; grid-template-columns: 62px 1fr; min-height: 23px; border-bottom: 1px solid #ddd; background: #fff; font-size: 15px; }
.cut-row > span { padding: 2px 7px; pointer-events: none; }
.cut-row .cut-number { border-right: 1px solid #ddd; text-align: center; color: #145ab4; }
.cut-row.selected { background: #dcdcdc; }
.cut-row.separator { background: #fff; }
.operator-status { min-height: 18px; padding-top: 2px; color: #a40000; font-size: 11px; }
.catalog-panel { display: flex; flex-direction: column; padding: 0 8px; background: #f3f3f3; }
.market-tabs { display: flex; height: 29px; border-bottom: 1px solid #999; }
.market-tabs button { min-width: 48px; padding: 3px 8px; border-width: 0 1px 0 0; background: #efefef; }
.market-tabs button.active { background: #fff; border-top: 2px solid #e8781d; }
.expand-line { height: 31px; padding: 7px 8px 0; }
.category-title { height: 27px; border-bottom: 1px solid #777; font-size: 12px; }
.category-tree { flex: 1; overflow: auto; padding: 7px 12px; background: #fff; border-left: 1px solid #aaa; border-right: 1px solid #aaa; }
.tree-root { margin: 5px 0 2px; color: #168000; font-size: 16px; }
.tree-root.second { margin-top: 15px; }
.tree-item { padding: 3px 0 3px 29px; font-size: 15px; }
.playlist-panel { display: grid; grid-template-rows: 58px minmax(0, 1fr) 76px 42px; padding: 0 8px; background: #efefef; }
.playlist-toolbar { display: flex; align-items: center; gap: 8px; white-space: nowrap; }
.playlist-toolbar button { height: 25px; padding: 2px 9px; }
.playlist-toolbar .toolbar-spacer { flex: 1; }
.background-name { width: 95px; height: 25px; border: 1px solid #888; background: #fff; }
.playlist-grid { min-height: 0; border: 1px solid #888; background: #d5d5d5; overflow: auto; }
.playlist-row { display: grid; grid-template-columns: 40px 100px 260px 200px minmax(255px, 1fr) 52px; min-width: 907px; }
.playlist-header { position: sticky; top: 0; z-index: 2; height: 29px; background: #efefef; border-bottom: 1px solid #999; }
.playlist-header > div { display: flex; align-items: center; justify-content: center; border-right: 1px solid #aaa; }
.playlist-data-row { min-height: 25px; background: #fff; border-bottom: 1px solid #ddd; }
.playlist-data-row > div { padding: 3px 6px; border-right: 1px solid #ddd; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.playlist-data-row .enabled-cell { text-align: center; }
.playout-actions { display: flex; align-items: center; justify-content: space-between; gap: 18px; }
.playout-actions button { height: 51px; background: #fff; font-size: 25px; font-weight: 700; }
.take-in { width: 215px; }
.next { width: 180px; }
.take-out { width: 212px; }
.playout-status { border-top: 1px solid #bbb; display: flex; align-items: center; justify-content: center; color: #666; }
.dialog-backdrop { position: fixed; inset: 0; z-index: 20; background: rgba(0, 0, 0, .25); }
.dialog-backdrop[hidden] { display: none; }
.legacy-dialog { position: absolute; left: 50%; top: 50%; width: 320px; padding: 24px; transform: translate(-50%, -50%); border: 1px solid #777; background: #fff; box-shadow: 0 8px 30px rgba(0, 0, 0, .3); text-align: center; }
.legacy-dialog button { min-width: 80px; margin-top: 24px; padding: 6px 15px; }