Files

868 lines
32 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
window.createFlowRenderer = function createFlowRenderer(options) {
const {
elements,
getState,
setSelectedChain,
openEditor,
closeEditor,
saveFlowModel,
closeSitemapModal,
askStepLabel,
getNextFlow,
getPivotLabel,
getSubFlowLinkInfo,
formatConnectorReason,
setSubFlowLinkReason,
remapSubFlowLinksOnRename,
syncSubFlowLinks,
getSubFlowLinks,
setSubFlowLinks,
getMainBarMode = () => false,
getLv2BarMode = () => false,
setMainBarMode = () => {},
setLv2BarMode = () => {},
onBarModeLayoutChange = () => {}
} = options || {};
const {
mainFlowEl,
mainPanelEl,
mainCommonSectionEl,
drillColumnsEl
} = elements || {};
let barHoverTooltipEl = null;
function ensureBarHoverTooltip() {
if (barHoverTooltipEl && document.body.contains(barHoverTooltipEl)) {
return barHoverTooltipEl;
}
const existing = document.getElementById('barStepHoverTooltip');
if (existing) {
barHoverTooltipEl = existing;
return barHoverTooltipEl;
}
const tooltip = document.createElement('div');
tooltip.id = 'barStepHoverTooltip';
tooltip.className = 'bar-step-tooltip';
tooltip.setAttribute('role', 'tooltip');
document.body.appendChild(tooltip);
barHoverTooltipEl = tooltip;
return barHoverTooltipEl;
}
function hideBarStepTooltip() {
const tooltip = ensureBarHoverTooltip();
tooltip.classList.remove('show');
}
function positionBarStepTooltip(btn) {
const tooltip = ensureBarHoverTooltip();
const rect = btn.getBoundingClientRect();
let left = rect.right + 10;
const top = rect.top + (rect.height / 2);
tooltip.style.left = `${Math.round(left)}px`;
tooltip.style.top = `${Math.round(top)}px`;
const tooltipRect = tooltip.getBoundingClientRect();
const viewportPadding = 8;
if ((left + tooltipRect.width + viewportPadding) > window.innerWidth) {
left = Math.max(viewportPadding, rect.left - tooltipRect.width - 10);
tooltip.style.left = `${Math.round(left)}px`;
}
}
function showBarStepTooltip(btn, label) {
const barPanel = btn.closest('.flow-panel.flow-bar-mode');
if (!barPanel) return;
const text = String(label || '').trim();
if (!text) return;
const tooltip = ensureBarHoverTooltip();
tooltip.textContent = text;
tooltip.classList.add('show');
positionBarStepTooltip(btn);
}
function bindBarStepTooltip(btn, label) {
btn.addEventListener('mouseenter', () => showBarStepTooltip(btn, label));
btn.addEventListener('mousemove', () => positionBarStepTooltip(btn));
btn.addEventListener('mouseleave', hideBarStepTooltip);
btn.addEventListener('focus', () => showBarStepTooltip(btn, label));
btn.addEventListener('blur', hideBarStepTooltip);
}
function formatUpdatedAtText(value) {
const text = String(value || '').trim();
if (!text) return '';
const parsed = new Date(text);
if (Number.isNaN(parsed.getTime())) return text;
return `최종 수정 ${parsed.toLocaleString('ko-KR', {
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit'
})}`;
}
function getEntityMetaValue(mapName, key) {
const state = getState();
const entityMeta = state && state.entityMeta ? state.entityMeta : {};
const map = entityMeta[mapName];
if (!map || typeof map !== 'object') return '';
return String(map[key] || '').trim();
}
function getStepMetaText(stepKey) {
const stableId = getEntityMetaValue('stepStableIdByKey', stepKey);
const parts = [];
if (stableId) parts.push(`ID: ${stableId}`);
return parts.join('\n');
}
function getFlowMetaText(flowKey) {
const stableId = getEntityMetaValue('flowStableIdByKey', flowKey);
const contentHash = getEntityMetaValue('flowContentHashByKey', flowKey);
const parts = [];
if (stableId) parts.push(`ID: ${stableId}`);
if (contentHash) parts.push(`HASH: ${contentHash}`);
return parts.join('\n');
}
function getLinkMetaText(mainStep, fromStep, toStep) {
const linkKey = `${mainStep}||${fromStep}>>${toStep}`;
const stableId = getEntityMetaValue('linkStableIdByKey', linkKey);
const parts = [];
if (stableId) parts.push(`ID: ${stableId}`);
return parts.join('\n');
}
function renderVerticalFlow(container, list, activeValue, onSelect, renderOptions) {
const listEl = document.createElement('div');
listEl.className = 'flow-list';
const {
allowEdit = false,
onEdit = null,
onDelete = null,
onMoveUp = null,
onMoveDown = null,
onInsertAfter = null,
onAddEnd = null,
getMetaText = null,
getConnectorInfo = null,
onEditConnector = null,
onClearConnector = null
} = renderOptions || {};
list.forEach((step, idx) => {
if (allowEdit) {
const row = document.createElement('div');
row.className = 'step-row';
const btn = document.createElement('button');
btn.type = 'button';
btn.className = 'step-btn';
if (activeValue === step) btn.classList.add('active');
const metaText = typeof getMetaText === 'function' ? getMetaText(step, idx) : '';
if (metaText) {
btn.classList.add('with-meta');
// 편집 모드에서는 메타 정보를 버튼 하단(step-edit-meta)에만 표시한다.
// 버튼 내부 중복 표시는 제거한다.
btn.innerHTML = `<span class="step-main">${step}</span>`;
} else {
btn.textContent = step;
}
btn.setAttribute('data-step-label', String(step));
btn.setAttribute('aria-label', String(step));
btn.removeAttribute('title');
bindBarStepTooltip(btn, step);
btn.addEventListener('click', () => onSelect(step));
row.appendChild(btn);
if (metaText) {
const meta = document.createElement('div');
meta.className = 'step-edit-meta';
meta.textContent = metaText;
row.appendChild(meta);
}
const actions = document.createElement('div');
actions.className = 'step-actions';
const upBtn = document.createElement('button');
upBtn.type = 'button';
upBtn.className = 'icon-btn';
upBtn.title = '위로 이동';
upBtn.textContent = '↑';
upBtn.disabled = idx === 0;
upBtn.style.opacity = idx === 0 ? '0.4' : '1';
upBtn.addEventListener('click', () => onMoveUp && onMoveUp(idx));
actions.appendChild(upBtn);
const downBtn = document.createElement('button');
downBtn.type = 'button';
downBtn.className = 'icon-btn';
downBtn.title = '아래로 이동';
downBtn.textContent = '↓';
downBtn.disabled = idx === list.length - 1;
downBtn.style.opacity = idx === list.length - 1 ? '0.4' : '1';
downBtn.addEventListener('click', () => onMoveDown && onMoveDown(idx));
actions.appendChild(downBtn);
const editBtn = document.createElement('button');
editBtn.type = 'button';
editBtn.className = 'icon-btn';
editBtn.title = '이름 수정';
editBtn.textContent = '✎';
editBtn.addEventListener('click', () => onEdit && onEdit(idx));
actions.appendChild(editBtn);
const delBtn = document.createElement('button');
delBtn.type = 'button';
delBtn.className = 'icon-btn delete';
delBtn.title = '삭제';
delBtn.textContent = '×';
delBtn.addEventListener('click', () => onDelete && onDelete(idx));
actions.appendChild(delBtn);
row.appendChild(actions);
listEl.appendChild(row);
} else {
const btn = document.createElement('button');
btn.type = 'button';
btn.className = 'step-btn';
if (activeValue === step) btn.classList.add('active');
const metaText = typeof getMetaText === 'function' ? getMetaText(step, idx) : '';
if (metaText) {
btn.classList.add('with-meta');
btn.innerHTML = `<span class="step-main">${step}</span><span class="step-meta">${metaText}</span>`;
} else {
btn.textContent = step;
}
btn.setAttribute('data-step-label', String(step));
btn.setAttribute('aria-label', String(step));
btn.removeAttribute('title');
bindBarStepTooltip(btn, step);
btn.addEventListener('click', () => onSelect(step));
listEl.appendChild(btn);
}
if (idx < list.length - 1) {
const connectorInfo = typeof getConnectorInfo === 'function'
? (getConnectorInfo(step, idx, list[idx + 1]) || { broken: false, reason: '' })
: { broken: false, reason: '' };
if (allowEdit) {
const insertWrap = document.createElement('div');
insertWrap.className = 'insert-row';
const arrow = document.createElement('div');
arrow.className = `down-arrow${connectorInfo.broken ? ' broken' : ''}`;
arrow.textContent = '↓';
insertWrap.appendChild(arrow);
if (connectorInfo.reason) {
const reason = document.createElement('div');
reason.className = 'link-reason';
reason.textContent = formatConnectorReason(connectorInfo.reason);
insertWrap.appendChild(reason);
}
const connectorMetaText = String(connectorInfo.metaText || '').trim();
if (connectorMetaText) {
const meta = document.createElement('div');
meta.className = 'step-edit-meta';
meta.textContent = connectorMetaText;
insertWrap.appendChild(meta);
}
if (typeof onEditConnector === 'function') {
const linkBtn = document.createElement('button');
linkBtn.type = 'button';
linkBtn.className = 'link-edit-btn';
linkBtn.textContent = connectorInfo.broken ? '수동 처리 수정' : '수동 처리 추가';
linkBtn.addEventListener('click', () => onEditConnector(idx));
insertWrap.appendChild(linkBtn);
if (connectorInfo.broken && typeof onClearConnector === 'function') {
const clearBtn = document.createElement('button');
clearBtn.type = 'button';
clearBtn.className = 'link-clear-btn';
clearBtn.textContent = '자동 복구';
clearBtn.addEventListener('click', () => onClearConnector(idx));
insertWrap.appendChild(clearBtn);
}
}
const insertBtn = document.createElement('button');
insertBtn.type = 'button';
insertBtn.className = 'insert-btn';
insertBtn.title = '아래에 삽입';
insertBtn.textContent = '+';
insertBtn.addEventListener('click', () => onInsertAfter && onInsertAfter(idx));
insertWrap.appendChild(insertBtn);
listEl.appendChild(insertWrap);
} else {
const arrow = document.createElement('div');
arrow.className = `down-arrow${connectorInfo.broken ? ' broken' : ''}`;
arrow.textContent = '↓';
listEl.appendChild(arrow);
if (connectorInfo.reason) {
const reason = document.createElement('div');
reason.className = 'link-reason';
reason.textContent = connectorInfo.reason;
listEl.appendChild(reason);
}
}
}
});
if (allowEdit) {
const addEndWrap = document.createElement('div');
addEndWrap.className = 'insert-row';
const addEndBtn = document.createElement('button');
addEndBtn.type = 'button';
addEndBtn.className = 'add-end-btn';
addEndBtn.textContent = '+ 마지막에 추가';
addEndBtn.addEventListener('click', () => onAddEnd && onAddEnd());
addEndWrap.appendChild(addEndBtn);
listEl.appendChild(addEndWrap);
}
container.appendChild(listEl);
}
function renderCommonSection() {
const state = getState();
const { editMode, commonItems, commonSubFlow, selectedChain } = state;
mainCommonSectionEl.innerHTML = '';
const head = document.createElement('div');
head.className = 'common-head';
const title = document.createElement('p');
title.className = 'common-title';
title.textContent = '공통';
head.appendChild(title);
if (editMode) {
const addBtn = document.createElement('button');
addBtn.type = 'button';
addBtn.className = 'common-add-btn';
addBtn.textContent = '+ 공통 추가';
addBtn.addEventListener('click', () => {
const next = askStepLabel('');
if (!next) return;
commonItems.push(next);
if (!commonSubFlow[next]) commonSubFlow[next] = [];
saveFlowModel();
renderCommonSection();
});
head.appendChild(addBtn);
}
mainCommonSectionEl.appendChild(head);
const list = document.createElement('div');
list.className = 'common-list';
if (!commonItems.length) {
const empty = document.createElement('div');
empty.className = 'common-empty';
empty.textContent = '공통 항목이 없습니다.';
list.appendChild(empty);
} else {
commonItems.forEach((item, idx) => {
const commonMetaText = editMode ? getStepMetaText(`common|${item}`) : '';
const isActiveCommon = selectedChain[0] === `common::${item}`;
if (editMode) {
const row = document.createElement('div');
row.className = 'common-row';
const btn = document.createElement('button');
btn.type = 'button';
btn.className = 'common-btn';
if (isActiveCommon) btn.classList.add('active');
btn.textContent = item;
btn.setAttribute('data-step-label', String(item));
btn.setAttribute('aria-label', String(item));
btn.removeAttribute('title');
bindBarStepTooltip(btn, item);
btn.addEventListener('click', () => {
api.openFirstSubStepFromCommonFlow(item);
});
row.appendChild(btn);
if (commonMetaText) {
const meta = document.createElement('div');
meta.className = 'step-edit-meta';
meta.textContent = commonMetaText;
row.appendChild(meta);
}
const actions = document.createElement('div');
actions.className = 'step-actions';
const upBtn = document.createElement('button');
upBtn.type = 'button';
upBtn.className = 'icon-btn';
upBtn.title = '위로 이동';
upBtn.textContent = '↑';
upBtn.disabled = idx === 0;
upBtn.style.opacity = idx === 0 ? '0.4' : '1';
upBtn.addEventListener('click', () => {
if (idx <= 0) return;
[commonItems[idx - 1], commonItems[idx]] = [commonItems[idx], commonItems[idx - 1]];
saveFlowModel();
renderCommonSection();
});
actions.appendChild(upBtn);
const downBtn = document.createElement('button');
downBtn.type = 'button';
downBtn.className = 'icon-btn';
downBtn.title = '아래로 이동';
downBtn.textContent = '↓';
downBtn.disabled = idx === commonItems.length - 1;
downBtn.style.opacity = idx === commonItems.length - 1 ? '0.4' : '1';
downBtn.addEventListener('click', () => {
if (idx >= commonItems.length - 1) return;
[commonItems[idx + 1], commonItems[idx]] = [commonItems[idx], commonItems[idx + 1]];
saveFlowModel();
renderCommonSection();
});
actions.appendChild(downBtn);
const editBtn = document.createElement('button');
editBtn.type = 'button';
editBtn.className = 'icon-btn';
editBtn.title = '이름 수정';
editBtn.textContent = '✎';
editBtn.addEventListener('click', () => {
const next = askStepLabel(item);
if (!next || next === item) return;
commonItems[idx] = next;
if (commonSubFlow[item]) {
commonSubFlow[next] = commonSubFlow[item];
delete commonSubFlow[item];
}
saveFlowModel();
renderCommonSection();
});
actions.appendChild(editBtn);
const delBtn = document.createElement('button');
delBtn.type = 'button';
delBtn.className = 'icon-btn delete';
delBtn.title = '삭제';
delBtn.textContent = '×';
delBtn.addEventListener('click', () => {
if (!window.confirm(`'${item}' 공통 항목을 삭제할까요?`)) return;
commonItems.splice(idx, 1);
delete commonSubFlow[item];
if (selectedChain[0] === `common::${item}`) {
setSelectedChain([]);
closeEditor();
}
saveFlowModel();
renderCommonSection();
});
actions.appendChild(delBtn);
row.appendChild(actions);
list.appendChild(row);
} else {
const btn = document.createElement('button');
btn.type = 'button';
btn.className = 'common-btn';
if (isActiveCommon) btn.classList.add('active');
btn.textContent = item;
btn.setAttribute('data-step-label', String(item));
btn.setAttribute('aria-label', String(item));
btn.removeAttribute('title');
bindBarStepTooltip(btn, item);
btn.addEventListener('click', () => {
api.openFirstSubStepFromCommonFlow(item);
});
list.appendChild(btn);
}
});
}
mainCommonSectionEl.appendChild(list);
}
function openFirstSubStepFromCommonFlow(item, fromSitemap = false) {
const targetCommonItem = String(item || '').trim();
if (!targetCommonItem) return;
const { commonSubFlow } = getState();
const subSteps = commonSubFlow[targetCommonItem] || [];
if (subSteps.length) {
const firstSubStep = subSteps[0];
setSelectedChain([`common::${targetCommonItem}`, firstSubStep]);
api.renderAll();
openEditor(`0|${targetCommonItem}|${firstSubStep}`, firstSubStep);
} else {
setSelectedChain([`common::${targetCommonItem}`]);
api.renderAll();
openEditor(`common|${targetCommonItem}`, targetCommonItem);
}
if (fromSitemap) closeSitemapModal();
}
function openFirstSubStepFromProjectFlow(mainStep) {
const targetMainStep = String(mainStep || '').trim();
if (!targetMainStep) return;
const { subFlow } = getState();
const subSteps = subFlow[targetMainStep] || [];
if (subSteps.length) {
const firstSubStep = subSteps[0];
setSelectedChain([targetMainStep, firstSubStep]);
api.renderAll();
openEditor(`0|${targetMainStep}|${firstSubStep}`, firstSubStep);
return;
}
setSelectedChain([targetMainStep]);
api.renderAll();
openEditor(`main|${targetMainStep}`, targetMainStep);
}
function renderMain() {
const { mainSteps, selectedChain, editMode, subFlow } = getState();
const mainBarMode = Boolean(!editMode && getMainBarMode());
mainFlowEl.innerHTML = '';
mainFlowEl.scrollTop = 0;
mainPanelEl.scrollTop = 0;
mainPanelEl.classList.toggle('flow-bar-mode', mainBarMode);
mainPanelEl.removeAttribute('data-bar-level');
const mainTitleEl = mainPanelEl.querySelector('.panel-head .title');
const mainHeadEl = mainPanelEl.querySelector('.panel-head');
if (mainHeadEl) {
const existingBtn = mainHeadEl.querySelector('.bar-collapse-btn');
if (existingBtn) existingBtn.remove();
if (!editMode && !mainBarMode) {
const collapseBtn = document.createElement('button');
collapseBtn.type = 'button';
collapseBtn.className = 'bar-collapse-btn';
collapseBtn.title = '바 보기로 전환';
collapseBtn.setAttribute('aria-label', '바 보기로 전환');
collapseBtn.textContent = '';
collapseBtn.addEventListener('click', () => {
setMainBarMode(true);
onBarModeLayoutChange();
api.renderAll();
});
mainHeadEl.appendChild(collapseBtn);
}
}
if (mainTitleEl) {
mainTitleEl.classList.remove('bar-toggle-title');
mainTitleEl.title = editMode ? getFlowMetaText('main') : '';
mainTitleEl.onclick = null;
}
if (mainBarMode) {
const restoreBtn = document.createElement('button');
restoreBtn.type = 'button';
restoreBtn.className = 'bar-restore-btn';
restoreBtn.textContent = '+';
restoreBtn.addEventListener('click', () => {
setMainBarMode(false);
onBarModeLayoutChange();
api.renderAll();
});
mainFlowEl.appendChild(restoreBtn);
}
renderVerticalFlow(mainFlowEl, mainSteps, selectedChain[0], (step) => {
openFirstSubStepFromProjectFlow(step);
}, {
allowEdit: editMode,
getMetaText: editMode ? (step) => getStepMetaText(`main|${step}`) : null,
onEdit: (idx) => {
const oldStep = mainSteps[idx];
const next = askStepLabel(oldStep);
if (!next || next === oldStep) return;
mainSteps[idx] = next;
if (subFlow[oldStep]) {
subFlow[next] = subFlow[oldStep];
delete subFlow[oldStep];
const migrated = {};
Object.entries(getSubFlowLinks()).forEach(([k, v]) => {
if (k.startsWith(`${oldStep}||`)) {
migrated[k.replace(`${oldStep}||`, `${next}||`)] = v;
} else {
migrated[k] = v;
}
});
setSubFlowLinks(migrated);
} else if (!subFlow[next]) {
subFlow[next] = [];
}
if (selectedChain[0] === oldStep) {
const nextChain = [...selectedChain];
nextChain[0] = next;
setSelectedChain(nextChain);
}
saveFlowModel();
api.renderAll();
},
onDelete: (idx) => {
const target = mainSteps[idx];
if (!window.confirm(`'${target}' 스텝을 삭제할까요?`)) return;
mainSteps.splice(idx, 1);
delete subFlow[target];
const nextLinks = { ...getSubFlowLinks() };
Object.keys(nextLinks).forEach((k) => {
if (k.startsWith(`${target}||`)) delete nextLinks[k];
});
setSubFlowLinks(nextLinks);
if (selectedChain[0] === target) {
setSelectedChain([]);
closeEditor();
}
saveFlowModel();
api.renderAll();
},
onMoveUp: (idx) => {
if (idx <= 0) return;
[mainSteps[idx - 1], mainSteps[idx]] = [mainSteps[idx], mainSteps[idx - 1]];
saveFlowModel();
api.renderAll();
},
onMoveDown: (idx) => {
if (idx >= mainSteps.length - 1) return;
[mainSteps[idx + 1], mainSteps[idx]] = [mainSteps[idx], mainSteps[idx + 1]];
saveFlowModel();
api.renderAll();
},
onInsertAfter: (idx) => {
const next = askStepLabel('');
if (!next) return;
mainSteps.splice(idx + 1, 0, next);
if (!subFlow[next]) subFlow[next] = [];
saveFlowModel();
api.renderAll();
},
onAddEnd: () => {
const next = askStepLabel('');
if (!next) return;
mainSteps.push(next);
if (!subFlow[next]) subFlow[next] = [];
saveFlowModel();
api.renderAll();
}
});
renderCommonSection();
}
function renderDrillColumns() {
const state = getState();
const { selectedChain, editMode, commonSubFlow, subFlow, drillFlow, stepMeta } = state;
drillColumnsEl.innerHTML = '';
if (!selectedChain[0]) return;
let level = 0;
while (true) {
if (level >= 1) break;
const pivot = selectedChain[level];
const list = getNextFlow(level, pivot);
if (!list.length) break;
const panel = document.createElement('section');
panel.className = 'flow-panel';
const panelLevel = level;
const lv2BarMode = Boolean(!editMode && panelLevel === 0 && getLv2BarMode());
panel.classList.toggle('flow-bar-mode', lv2BarMode);
panel.removeAttribute('data-bar-level');
const panelPivot = getPivotLabel(pivot);
const isCommonPivot = String(pivot).startsWith('common::');
const titleRow = document.createElement('div');
titleRow.className = 'panel-head';
const title = document.createElement('h2');
title.className = 'title';
title.textContent = panelLevel === 0 ? 'STEP FLOW' : `${panelPivot} FLOW`;
if (editMode && panelLevel === 0) {
title.title = getFlowMetaText(`0|${panelPivot}`);
}
if (!editMode && panelLevel === 0) {
title.classList.remove('bar-toggle-title');
title.title = '';
}
titleRow.appendChild(title);
if (!editMode && panelLevel === 0 && !lv2BarMode) {
const collapseBtn = document.createElement('button');
collapseBtn.type = 'button';
collapseBtn.className = 'bar-collapse-btn';
collapseBtn.title = '바 보기로 전환';
collapseBtn.setAttribute('aria-label', '바 보기로 전환');
collapseBtn.textContent = '';
collapseBtn.addEventListener('click', () => {
setLv2BarMode(true);
onBarModeLayoutChange();
api.renderAll();
});
titleRow.appendChild(collapseBtn);
}
panel.appendChild(titleRow);
const subtitleSpacer = document.createElement('p');
subtitleSpacer.className = 'project-title';
subtitleSpacer.textContent = panelLevel === 0 ? panelPivot : '프로젝트';
panel.appendChild(subtitleSpacer);
if (lv2BarMode) {
const restoreBtn = document.createElement('button');
restoreBtn.type = 'button';
restoreBtn.className = 'bar-restore-btn';
restoreBtn.textContent = '+';
restoreBtn.addEventListener('click', () => {
setLv2BarMode(false);
onBarModeLayoutChange();
api.renderAll();
});
panel.appendChild(restoreBtn);
}
renderVerticalFlow(panel, list, selectedChain[panelLevel + 1], (step) => {
const nextChain = selectedChain.slice(0, panelLevel + 1);
nextChain[panelLevel + 1] = step;
setSelectedChain(nextChain);
api.renderAll();
openEditor(`${panelLevel}|${panelPivot}|${step}`, step);
}, {
getMetaText: editMode
? (step) => getStepMetaText(`${panelLevel}|${panelPivot}|${step}`)
: (panelLevel === 0 && !isCommonPivot ? (step) => {
const key = `0|${panelPivot}|${step}`;
const info = stepMeta[key] || {};
const team = info.team || '-';
const system = info.system || '-';
return `${team} | ${system}`;
} : null),
getConnectorInfo: panelLevel === 0 ? (fromStep, idx, toStep) => {
const link = getSubFlowLinkInfo(panelPivot, fromStep, toStep);
return {
broken: Boolean(link.reason),
reason: link.reason || '',
metaText: editMode ? getLinkMetaText(panelPivot, fromStep, toStep) : ''
};
} : null,
onEditConnector: panelLevel === 0 ? (idx) => {
const fromStep = list[idx];
const toStep = list[idx + 1];
if (!fromStep || !toStep) return;
const current = getSubFlowLinkInfo(panelPivot, fromStep, toStep).reason || '';
const value = window.prompt(`'${fromStep}' -> '${toStep}' 사이의 수동 처리 내용을 입력하세요.\n(비우면 자동 연결로 복구)`, current);
if (value === null) return;
setSubFlowLinkReason(panelPivot, fromStep, toStep, value);
saveFlowModel();
api.renderAll();
} : null,
onClearConnector: panelLevel === 0 ? (idx) => {
const fromStep = list[idx];
const toStep = list[idx + 1];
if (!fromStep || !toStep) return;
setSubFlowLinkReason(panelPivot, fromStep, toStep, '');
saveFlowModel();
api.renderAll();
} : null,
allowEdit: editMode,
onEdit: (idx) => {
const targetList = panelLevel === 0
? (isCommonPivot ? (commonSubFlow[panelPivot] || []) : (subFlow[panelPivot] || []))
: (drillFlow[panelPivot] || []);
const oldStep = targetList[idx];
const next = askStepLabel(oldStep);
if (!next || next === oldStep) return;
targetList[idx] = next;
if (panelLevel === 0) remapSubFlowLinksOnRename(panelPivot, oldStep, next);
if (drillFlow[oldStep]) {
drillFlow[next] = drillFlow[oldStep];
delete drillFlow[oldStep];
}
if (selectedChain[panelLevel + 1] === oldStep) {
const nextChain = [...selectedChain];
nextChain[panelLevel + 1] = next;
setSelectedChain(nextChain);
}
if (panelLevel === 0) syncSubFlowLinks(panelPivot);
saveFlowModel();
api.renderAll();
},
onDelete: (idx) => {
const targetList = panelLevel === 0
? (isCommonPivot ? (commonSubFlow[panelPivot] || []) : (subFlow[panelPivot] || []))
: (drillFlow[panelPivot] || []);
const target = targetList[idx];
if (!window.confirm(`'${target}' 스텝을 삭제할까요?`)) return;
targetList.splice(idx, 1);
if (selectedChain[panelLevel + 1] === target) {
setSelectedChain(selectedChain.slice(0, panelLevel + 1));
closeEditor();
}
if (panelLevel === 0) syncSubFlowLinks(panelPivot);
saveFlowModel();
api.renderAll();
},
onMoveUp: (idx) => {
const targetList = panelLevel === 0
? (isCommonPivot ? (commonSubFlow[panelPivot] || []) : (subFlow[panelPivot] || []))
: (drillFlow[panelPivot] || []);
if (idx <= 0) return;
[targetList[idx - 1], targetList[idx]] = [targetList[idx], targetList[idx - 1]];
if (panelLevel === 0) syncSubFlowLinks(panelPivot);
saveFlowModel();
api.renderAll();
},
onMoveDown: (idx) => {
const targetList = panelLevel === 0
? (isCommonPivot ? (commonSubFlow[panelPivot] || []) : (subFlow[panelPivot] || []))
: (drillFlow[panelPivot] || []);
if (idx >= targetList.length - 1) return;
[targetList[idx + 1], targetList[idx]] = [targetList[idx], targetList[idx + 1]];
if (panelLevel === 0) syncSubFlowLinks(panelPivot);
saveFlowModel();
api.renderAll();
},
onInsertAfter: (idx) => {
const targetList = panelLevel === 0
? (isCommonPivot ? (commonSubFlow[panelPivot] || []) : (subFlow[panelPivot] || []))
: (drillFlow[panelPivot] || []);
const next = askStepLabel('');
if (!next) return;
targetList.splice(idx + 1, 0, next);
if (!drillFlow[next]) drillFlow[next] = [];
if (panelLevel === 0) syncSubFlowLinks(panelPivot);
saveFlowModel();
api.renderAll();
},
onAddEnd: () => {
const targetList = panelLevel === 0
? (isCommonPivot ? (commonSubFlow[panelPivot] || []) : (subFlow[panelPivot] || []))
: (drillFlow[panelPivot] || []);
const next = askStepLabel('');
if (!next) return;
targetList.push(next);
if (!drillFlow[next]) drillFlow[next] = [];
if (panelLevel === 0) syncSubFlowLinks(panelPivot);
saveFlowModel();
api.renderAll();
}
});
drillColumnsEl.appendChild(panel);
if (!selectedChain[panelLevel + 1]) break;
level += 1;
}
}
function renderAll() {
renderMain();
renderDrillColumns();
}
const api = {
renderVerticalFlow,
renderCommonSection,
openFirstSubStepFromCommonFlow,
openFirstSubStepFromProjectFlow,
renderMain,
renderDrillColumns,
renderAll
};
return api;
};