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 = `${step}`;
} 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 = `${step}${metaText}`;
} 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;
};