Files
MBN_STOCK_WEBVIEW/Web/market-nav-reorder.js

253 lines
9.1 KiB
JavaScript

(function (root, factory) {
"use strict";
const api = Object.freeze(factory());
if (typeof module === "object" && module.exports) module.exports = api;
if (root) root.MbnMarketNavReorder = api;
})(typeof globalThis === "object" ? globalThis : this, function () {
"use strict";
const fixedMarket = "dashboard";
const originalMarkets = Object.freeze([
"overseas", "exchange", "index", "kospi", "kosdaq",
"compare", "theme", "foreign-stock", "expert", "halted"
]);
const originalMarketSet = new Set(originalMarkets);
const dragMime = "application/x-mbn-market-nav";
const dragPrefix = "mbn-market-nav:v1:";
function isOriginalMarket(value) {
return typeof value === "string" && originalMarketSet.has(value);
}
function normalizeOrder(value) {
if (!Array.isArray(value) || value.length !== originalMarkets.length) return null;
const seen = new Set();
for (const market of value) {
if (!isOriginalMarket(market) || seen.has(market)) return null;
seen.add(market);
}
return Object.freeze([...value]);
}
// MainForm.swapTabPages exchanges the source and hovered TabPage. Keep the
// exact identity-based swap instead of deriving screen meaning from indices.
function swapOrder(order, sourceMarket, targetMarket) {
const normalized = normalizeOrder(order);
if (!normalized || !isOriginalMarket(sourceMarket) ||
!isOriginalMarket(targetMarket)) return null;
if (sourceMarket === targetMarket) {
return Object.freeze({ changed: false, order: normalized });
}
const sourceIndex = normalized.indexOf(sourceMarket);
const targetIndex = normalized.indexOf(targetMarket);
const next = [...normalized];
[next[sourceIndex], next[targetIndex]] = [next[targetIndex], next[sourceIndex]];
return Object.freeze({ changed: true, order: Object.freeze(next) });
}
function moveAdjacent(order, sourceMarket, direction) {
const normalized = normalizeOrder(order);
if (!normalized || !isOriginalMarket(sourceMarket) ||
!Number.isInteger(direction) || ![-1, 1].includes(direction)) return null;
const sourceIndex = normalized.indexOf(sourceMarket);
const targetIndex = sourceIndex + direction;
if (targetIndex < 0 || targetIndex >= normalized.length) {
return Object.freeze({ changed: false, order: normalized });
}
return swapOrder(normalized, sourceMarket, normalized[targetIndex]);
}
function createDragToken(market) {
if (!isOriginalMarket(market)) throw new TypeError("A reorderable market is required.");
return `${dragPrefix}${market}`;
}
function parseDragToken(value) {
if (typeof value !== "string" || !value.startsWith(dragPrefix)) return null;
const market = value.slice(dragPrefix.length);
return isOriginalMarket(market) && value === createDragToken(market) ? market : null;
}
function directMarketButton(nav, target, includeDashboard = false) {
if (!nav || !target || typeof target.closest !== "function") return null;
const button = target.closest("button[data-market]");
if (!button || button.parentElement !== nav) return null;
const market = button.dataset?.market;
if (isOriginalMarket(market) || (includeDashboard && market === fixedMarket)) return button;
return null;
}
function readOrder(nav) {
if (!nav || !nav.children) return null;
const buttons = Array.from(nav.children);
if (buttons.length !== originalMarkets.length + 1) return null;
const dashboard = directMarketButton(nav, buttons[0], true);
if (!dashboard || dashboard.dataset.market !== fixedMarket) return null;
return normalizeOrder(buttons.slice(1).map(button => {
const direct = directMarketButton(nav, button);
return direct?.dataset?.market;
}));
}
function applyOrder(nav, order) {
const normalized = normalizeOrder(order);
const current = readOrder(nav);
if (!normalized || !current) return false;
const byMarket = new Map(Array.from(nav.children).map(button => [
button.dataset.market,
button
]));
const dashboard = byMarket.get(fixedMarket);
if (!dashboard || typeof nav.append !== "function" || typeof nav.prepend !== "function") {
return false;
}
nav.prepend(dashboard);
for (const market of normalized) nav.append(byMarket.get(market));
return true;
}
function createController(options) {
if (!options || typeof options !== "object" || Array.isArray(options)) {
throw new TypeError("Market-nav reorder options are required.");
}
const nav = options.nav;
if (!nav || typeof nav.addEventListener !== "function" ||
typeof nav.removeEventListener !== "function") {
throw new TypeError("A market nav element is required.");
}
const isBlocked = typeof options.isBlocked === "function" ? options.isBlocked : () => false;
const onReordered = typeof options.onReordered === "function" ? options.onReordered : () => {};
let mounted = false;
let draggingMarket = null;
function blocked() {
try {
return isBlocked() === true;
} catch {
return true;
}
}
function reorder(result, reason, sourceButton) {
if (!result?.changed || !applyOrder(nav, result.order)) return false;
if (typeof sourceButton?.focus === "function") sourceButton.focus();
onReordered(Object.freeze({
reason,
order: result.order,
activeMarket: Array.from(nav.children)
.find(button => button.classList?.contains("active"))?.dataset?.market ?? null
}));
return true;
}
function onDragStart(event) {
const button = directMarketButton(nav, event.target);
const market = button?.dataset?.market;
if (!button || blocked() || !event.dataTransfer) {
event.preventDefault?.();
draggingMarket = null;
return;
}
draggingMarket = market;
button.classList?.add("dragging");
event.dataTransfer.effectAllowed = "move";
event.dataTransfer.setData(dragMime, createDragToken(market));
}
function onDragOver(event) {
if (blocked() || !draggingMarket || !directMarketButton(nav, event.target)) return;
event.preventDefault?.();
if (event.dataTransfer) event.dataTransfer.dropEffect = "move";
}
function clearDragging() {
draggingMarket = null;
for (const button of Array.from(nav.children)) button.classList?.remove("dragging");
}
function onDrop(event) {
const target = directMarketButton(nav, event.target);
if (!target || blocked() || !draggingMarket || !event.dataTransfer) {
clearDragging();
return;
}
event.preventDefault?.();
const sourceMarket = parseDragToken(event.dataTransfer.getData(dragMime));
if (!sourceMarket || sourceMarket !== draggingMarket) {
clearDragging();
return;
}
const order = readOrder(nav);
const source = Array.from(nav.children)
.find(button => button.dataset?.market === sourceMarket);
const result = swapOrder(order, sourceMarket, target.dataset.market);
reorder(result, "drag", source);
clearDragging();
}
function onKeyDown(event) {
if (!event.altKey || event.ctrlKey || event.metaKey || event.shiftKey ||
!["ArrowUp", "ArrowDown"].includes(event.key)) return;
const button = directMarketButton(nav, event.target);
if (!button) return;
event.preventDefault?.();
event.stopPropagation?.();
if (blocked() || event.repeat) return;
const direction = event.key === "ArrowUp" ? -1 : 1;
reorder(
moveAdjacent(readOrder(nav), button.dataset.market, direction),
"keyboard",
button);
}
function mount() {
if (mounted) return true;
if (!readOrder(nav)) return false;
for (const button of Array.from(nav.children)) {
const reorderable = isOriginalMarket(button.dataset?.market);
button.draggable = reorderable;
if (reorderable) {
button.setAttribute?.("aria-keyshortcuts", "Alt+ArrowUp Alt+ArrowDown");
button.setAttribute?.("title", "드래그 또는 Alt+화살표로 업무 탭 순서 변경");
}
}
nav.addEventListener("dragstart", onDragStart);
nav.addEventListener("dragover", onDragOver);
nav.addEventListener("drop", onDrop);
nav.addEventListener("dragend", clearDragging);
nav.addEventListener("keydown", onKeyDown);
mounted = true;
return true;
}
function destroy() {
if (!mounted) return;
nav.removeEventListener("dragstart", onDragStart);
nav.removeEventListener("dragover", onDragOver);
nav.removeEventListener("drop", onDrop);
nav.removeEventListener("dragend", clearDragging);
nav.removeEventListener("keydown", onKeyDown);
clearDragging();
mounted = false;
}
return Object.freeze({ mount, destroy, readOrder: () => readOrder(nav) });
}
return {
fixedMarket,
originalMarkets,
dragMime,
isOriginalMarket,
normalizeOrder,
swapOrder,
moveAdjacent,
createDragToken,
parseDragToken,
readOrder,
applyOrder,
createController
};
});