Files
Aislo/B07_DesignDetail/B07_DesignDetail_UI_Page.ts
T

546 lines
23 KiB
TypeScript

/* =============================================================================
* B07_DesignDetail_UI_Page.ts
* 로그인 후 07: 4차 워크플로우 (상세 설계) — 독립형 2D CAD 임베드
*
* B07_DesignDetail/openwebcad를 프로젝트 소유 B07 CAD 앱으로 빌드하여
* /b07-cad 경로로 서빙한다. 업무 도면은 추후 same-origin postMessage로
* JSON만 전달하며 DXF/DWG 파싱은 이 브라우저 앱에서 수행하지 않는다.
*
* 레이아웃 (사용자 지시): 사이드 패널 빈 상태 유지 + 상세 영역 CAD 화면.
* 제약 준수 (frontend.md §2 3단 레이아웃): createWorkflowLayout 재사용.
* ========================================================================== */
import "./B07_DesignDetail_UI_Style.css";
import { ui_locales, currentLanguageIndex } from "@ui/ui_template_locale";
import {
createButton,
hideLoadingOverlay,
showLoadingOverlay,
showToast,
} from "@ui/ui_template_elements";
import { createWorkflowLayout } from "@ui/ui_template_workflow_layout";
import { CURRENT_PROJECT_ID_KEY } from "@config/config_frontend";
// 좌측 패널(도면 목록·설계 정보)은 파일이 700줄을 넘어 떼어냈다(2026-09-04).
import {
buildDesignInfoPanel,
buildDrawingSidePanel,
foldedGroups,
isCrossSheet,
} from "./B07_DesignDetail_UI_Panels";
import { workflowSteps } from "../A00_Common/b_page_scaffold";
import {
clearWorkflowStateCache,
fetchWorkflowState,
goToWorkflowStage,
WORKFLOW_STEP_ROUTES,
type WorkflowState,
} from "../A00_Common/b_workflow_nav";
import {
completeDesignStage,
exportDrawing,
fetchCrossLayout,
fetchDesignDrawingList,
fetchFrameTemplate,
importFrameTemplate,
resetFrameTemplate,
saveCrossLayout,
saveFrameTemplate,
type CadDrawing,
type CrossLayout,
type DesignDrawingItem,
type DesignDrawingResponse,
type QuantityTable,
} from "./B07_DesignDetail_Api_Fetch";
import {
fetchState,
forgetAll,
forgetDrawing,
onFetchState,
openDrawing,
startPrefetch,
syncDrawings,
} from "./B07_DesignDetail_Prefetch";
import { buildGuideToggle, guideShown } from "./B07_DesignDetail_UI_GuideToggle";
import {
hasRedrawable,
recoveryScopeOf,
redrawFromSource,
resetDrawings,
saveDrawingsNow,
saveEditedDrawings,
} from "./B07_DesignDetail_UI_Save";
import { openDrawingSettings } from "./B07_DesignDetail_UI_Settings";
// CAD iframe 부모 흐름 · 도각 편집은 M02 와 한 벌로 공용에 둔다(PLAN 10-3).
import { createCadHost, type CadSaveResult } from "@ui/cad_host/cad_host";
import { createFrameTemplateEditor } from "@ui/cad_host/cad_host_frame_edit";
import { createCadTabs, type CadTab } from "@ui/cad_host/cad_host_tabs";
/** CAD 앱 수량 패널로 넘기는 설계 컨텍스트 (openwebcad DesignMeta와 동일 형식). */
interface DesignMeta {
kind: DesignDrawingItem["kind"];
/** 도면 식별자 — CAD가 자동백업 칸을 도면별로 나누는 데 쓴다. */
drawingId: string;
title: string;
info: string;
/** 참이면 CAD 읽기 전용 — 잠금 없음(요구 281)이라 늘 거짓 · 저장본도 그대로 고침. */
confirmed: boolean;
quantityTable: QuantityTable | null;
}
/** CAD 저장 응답 (도면 + 편집된 수량표). */
type SaveResult = CadSaveResult<CadDrawing, QuantityTable>;
/** locale 헬퍼 */
function L(key: keyof typeof ui_locales): string {
return ui_locales[key][currentLanguageIndex];
}
/* -----------------------------------------------------------------------------
* 화면 조립
* -------------------------------------------------------------------------- */
/** [평면도 관리]에서 고쳐 화면을 다시 세울 때 — 보던 장 id. 다시 세운 뒤 그 장을 연다(없으면 첫 장). */
let reopenDrawingId: string | undefined;
/** 횡단 장 배치를 바꿔 다시 세울 때 — 보던 장 번호(배치가 바뀌면 장 id 가 바뀜 · 140-3). */
let reopenCrossSheet: number | undefined;
export async function renderB07DesignDetail(root: HTMLElement): Promise<void> {
const projectId = localStorage.getItem(CURRENT_PROJECT_ID_KEY);
// 311 다시 세우면 옛 목록 접힘 그대로 — 기다리기 전에 읽음(처음 들어오면 B07 목록이 없어 기본값)
const folded = foldedGroups(root);
let workflowState: WorkflowState | undefined;
let drawings: DesignDrawingItem[] = [];
let drawingError: string | undefined;
let devBypass = false;
if (projectId) {
const [workflowResult, drawingResult] = await Promise.allSettled([
fetchWorkflowState(projectId),
fetchDesignDrawingList(projectId),
]);
if (workflowResult.status === "fulfilled") workflowState = workflowResult.value;
if (drawingResult.status === "fulfilled") {
drawings = drawingResult.value.drawings;
devBypass = drawingResult.value.dev_bypass === true;
} else
drawingError =
drawingResult.reason instanceof Error
? drawingResult.reason.message
: "도면 목록을 불러오지 못했습니다.";
}
const cad = createCadHost<CadDrawing, DesignMeta, QuantityTable>({
title: L("B07_Design_Title"),
// 편집 통지는 **미저장 표시**(탭 ●)만 세운다 — 저장은 페이지를 옮길 때(요구 281).
onChanged: (dirty) => {
// 받는 중(508-3b) = CAD 엔 앞 도면이 남아 있음 — 그 고침을 고른 탭에 묻히지 않음
if (frameEditor.isEditing() || pending) return;
cadDirty = dirty;
if (dirty) tabs.markDirty();
},
onExport: (fileFormat) => void exportDrawingFile(fileFormat),
onGuideChange: (area) => frameEditor.guideChanged(area),
});
// 180-3 안내선 보이기 / 숨기기 — 숨김은 화면을 다시 세워도 이어 줌(`…_UI_GuideToggle` 모듈 값)
if (!guideShown()) cad.setGuideVisible(false);
const guideToggle = buildGuideToggle((visible) => cad.setGuideVisible(visible));
let currentDrawing: DesignDrawingItem | undefined;
let currentIndex = -1;
let drawingListEl: HTMLElement | undefined;
const infoPanelHost = document.createElement("div");
infoPanelHost.className = "b07-info-host";
// 표준도 제원 칸·[여기서 고치기]는 2026-09-13 B08 「구조물도」 탭으로 이관(PLAN 3장 ④).
const updateInfoPanel = (drawing: DesignDrawingItem, response: DesignDrawingResponse): void => {
guideToggle.disabled = !isCrossSheet(drawing); // 장의 안내선만
if (drawing.kind !== "cross") {
infoPanelHost.replaceChildren();
return;
}
// 장은 id 가 `cross_s…`(여러 측점 묶음), 측점 도면은 `cross_…m` 이다. `chainage_m` 은
// 장에도 첫 측점 값이 실려 오므로 그것으로는 갈리지 않는다.
const scope = isCrossSheet(drawing) ? "sheet" : "station";
infoPanelHost.replaceChildren(
buildDesignInfoPanel(drawing.label, response.design ?? null, scope),
);
};
let crossLayoutRequest: Promise<CrossLayout> | undefined;
const crossLayout = (): Promise<CrossLayout> =>
(crossLayoutRequest ??= fetchCrossLayout(projectId as string)).catch((error) => {
crossLayoutRequest = undefined; // 실패는 남기지 않음 — 다음 ⚙ 에 다시 받음
throw error;
});
/** 장 배치 저장(140-3 · ⚙ 횡단 모달) — 서버 쓰기 → 화면 다시 세움 · 같은 장 번호를 다시 엶. */
const saveCrossLayoutAndRebuild = async (next: CrossLayout, sheet: number) => {
if (!projectId || !mayDiscardEdits()) return;
showLoadingOverlay();
try {
await saveCrossLayout(projectId, next);
reopenCrossSheet = sheet || undefined;
reopenDrawingId = sheet ? undefined : currentDrawing?.id;
cad.destroy();
void renderB07DesignDetail(root);
} catch (error) {
showToast(error instanceof Error ? error.message : "배치를 저장하지 못했습니다.", "error");
} finally {
hideLoadingOverlay();
}
};
/**
* CAD에 저장하지 않은 편집이 있는가. CAD가 편집마다 알려 주고, 도면을 싣거나
* 저장하면 내려간다. 이 값이 참인 채로 도면을 바꾸면 그은 선이 소리 없이 사라진다
* (2026-09-01 실측) — 그래서 바꾸기 전에 묻는다.
*/
let cadDirty = false;
const findButton = (drawingId: string) =>
drawingListEl?.querySelector<HTMLButtonElement>(
`.b07-drawing-button[data-drawing-id="${drawingId}"]`,
) ?? undefined;
const highlightActive = (drawingId: string) => {
drawingListEl?.querySelectorAll<HTMLButtonElement>(".b07-drawing-button").forEach((item) => {
item.dataset.active = String(item.dataset.drawingId === drawingId);
});
};
const buildMeta = (drawing: DesignDrawingItem, response: DesignDrawingResponse): DesignMeta => ({
kind: drawing.kind,
drawingId: drawing.id,
title: drawing.label,
info: drawing.kind === "cross" ? drawing.label : "",
confirmed: false, // 잠금 없음(요구 281)
quantityTable: response.quantity_table ?? null,
});
/** 저장하지 않은 편집이 있으면 묻는다. 버리기로 해야 이동한다. */
const mayDiscardEdits = (): boolean => {
if (!cadDirty && !tabs.anyDirty()) return true;
return window.confirm(
"저장하지 않은 편집이 있습니다.\n" +
"지금 도면을 바꾸면 편집이 사라집니다. 버리고 이동할까요?\n\n" +
"남기려면 [취소] — 고친 도면은 페이지를 옮길 때([<] [>]) 저장됩니다.",
);
};
/**
* 고른 도면을 받는 중(508-3b) — 받는 동안 다른 도면 · 페이지로 옮길 수 있음 · 늦게 온 응답은
* 그 도면이 아직 고른 것일 때만 실음. 못 받으면 그대로 남김(CAD 엔 앞 도면 · 고침 안 묻힘).
*/
let pending: { id: string; stash?: CadDrawing } | undefined;
/** `stash` = 탭에 받아 둔 저장 안 한 편집본(82-4) — 있으면 그것을 싣고 미저장 그대로 */
const loadDrawing = async (drawing: DesignDrawingItem, index: number, stash?: CadDrawing) => {
if (!projectId) return;
const mine = { id: drawing.id, stash };
pending = mine;
currentDrawing = undefined; // 저장 · 다시 그리기가 CAD 의 앞 도면을 이 장으로 안 씀
highlightActive(drawing.id);
const button = findButton(drawing.id);
if (button) button.dataset.loading = "true";
const ready = fetchState(drawing.id) === "ready";
cad.beginLoading(ready); // 받는 중엔 시계 없음 — 서버가 오래 걸려도 CAD 탓으로 안 끊음
if (!ready) showToast("도면을 받는 중입니다 — 다 받으면 바로 보여 줍니다.", "info");
try {
const response = await openDrawing(drawing);
if (pending !== mine) return; // 그 사이 다른 도면을 고름
pending = undefined;
if (!ready) cad.beginLoading(); // 이제 CAD 가 여는 몫 — 시계를 검
currentDrawing = drawing;
currentIndex = index;
cadDirty = !!stash; // 새 도면을 실었다 — 미저장은 받아 둔 편집본일 때만
updateInfoPanel(drawing, response);
// 가이드 = 작도 영역 점선 · 넘친 칸 붉은 틀(169-1 · 169-2 · 보기만)
const guide = response.guide ?? null;
const meta = buildMeta(drawing, response);
const recoveryScope = recoveryScopeOf(projectId, drawing.id);
cad.load(stash ?? response.drawing, meta, false, {}, { guide, recoveryScope });
if (guideShown() && guide?.marks?.length) {
showToast(`${guide.marks.length} 칸 넘침 — 열 · 행을 줄일 것`, "warning");
}
if (response.drawing.stale_layout) {
showToast(
"옛 배치로 저장된 장입니다. ⚙ [원본에서 다시 그리기]로 표를 칸 아래 가운데에 맞춘 새 배치로 다시 만듭니다.",
"info",
);
} else if (response.drawing.stale_scale) {
showToast(
"옛 표 배율로 저장된 장입니다. ⚙ [원본에서 다시 그리기]로 지금 배율로 다시 만듭니다.",
"info",
);
} else if (response.drawing.stale_layers) {
showToast(
"측점 도면층 앞에 저장된 장입니다. ⚙ [원본에서 다시 그리기]로 측점마다 층(횡단 No.)으로 다시 만듭니다.",
"info",
);
}
} catch (error) {
if (pending !== mine) return;
const reason = error instanceof Error ? error.message : "CAD 도면을 불러오지 못했습니다.";
cad.fail(`${reason} — 목록의 「다시 받기」를 누르면 다시 받습니다.`);
} finally {
if (button) button.dataset.loading = "false";
}
};
/**
* 목록에서 연 도면 = 탭(82-4) — 탭을 바꿀 때 지금 도면을 받아 두고(저장 안 한 편집 그대로) 다음 장을 싣는다.
* 새 장 · 지우기는 없음(서버 길 없음) · 닫기는 미저장 탭만 물음 · 마지막 한 탭은 못 닫음.
*/
const tabs = createCadTabs<CadDrawing>({
features: { close: true },
// 받는 중이면 CAD 엔 앞 도면 — 고른 탭엔 제 편집본(없으면 빈 장 · 미저장 아님)을 둠
capture: async () =>
pending
? { drawing: pending.stash ?? { entities: [], layers: [] }, dirty: false }
: { drawing: (await requestCadDrawing()).drawing, dirty: cadDirty },
canLeave: () => {
if (!frameEditor.isEditing()) return true;
showToast("도각 편집을 마친 뒤 다른 도면으로 옮기세요.", "warning");
return false;
},
show: async (tab: CadTab<CadDrawing>) => {
const index = drawings.findIndex((item) => item.id === tab.id);
// 기다리지 않음 — 받는 동안 다른 탭을 고를 수 있게(탭 줄은 show 동안 잠김)
if (index >= 0) void loadDrawing(drawings[index], index, tab.dirty ? tab.drawing : undefined);
},
});
const openTab = (drawing: DesignDrawingItem): Promise<boolean> =>
tabs.open({ id: drawing.id, name: drawing.label, title: drawing.label });
const selectDrawing = (drawing: DesignDrawingItem) => {
if (drawing === currentDrawing) return;
// 지금 탭이 못 받은 도면 = 「다시 받기」(탭은 그대로라 탭 열기로는 안 다시 받음)
if (pending?.id === drawing.id && fetchState(drawing.id) === "error") {
void loadDrawing(drawing, drawings.indexOf(drawing), pending.stash);
return;
}
void openTab(drawing);
};
const requestCadDrawing = (): Promise<SaveResult> => cad.requestSave();
/** 저장 · 원본 되돌림 묶음(`_UI_Save`)이 쓰는 상태 길. */
const store = {
projectId: projectId as string,
drawings,
tabs,
current: () => currentDrawing,
cadDirty: () => cadDirty,
setCadClean: () => void (cadDirty = false),
requestCad: requestCadDrawing,
isFrameEditing: () => frameEditor.isEditing(),
forget: forgetDrawing,
reload: (drawing: DesignDrawingItem) => loadDrawing(drawing, drawings.indexOf(drawing)),
};
/**
* 지금 보고 있는 도면을 DXF·DWG 파일로 내려받는다 (2026-09-06 사용자 지시).
* 파일 만들기는 서버가 한다 — 캐드는 도면만 넘긴다.
*/
async function exportDrawingFile(fileFormat: "dxf" | "dwg"): Promise<void> {
showLoadingOverlay();
try {
const { drawing } = await requestCadDrawing();
const name = currentDrawing?.label ?? "도면";
const result = await exportDrawing(projectId as string, drawing, fileFormat, name);
const link = document.createElement("a");
link.href = URL.createObjectURL(result.blob);
link.download = `${name}.${fileFormat}`;
link.click();
URL.revokeObjectURL(link.href);
showToast(
result.skipped > 0
? `${fileFormat.toUpperCase()} 로 내보냈습니다. 그림 ${result.skipped}개는 담기지 않았습니다.`
: `${fileFormat.toUpperCase()} 로 내보냈습니다.`,
"success",
);
} catch (error) {
showToast(error instanceof Error ? error.message : "도면을 내보내지 못했습니다.", "error");
} finally {
hideLoadingOverlay();
}
}
const frameEditor = createFrameTemplateEditor<CadDrawing>({
api: {
fetch: () => fetchFrameTemplate(projectId as string),
save: (drawing) => saveFrameTemplate(projectId as string, drawing),
importFile: (file) => importFrameTemplate(projectId as string, file),
reset: () => resetFrameTemplate(projectId as string),
},
sendLoad: (drawing, meta, frameEdit, frameFields, extra) =>
cad.load(drawing, meta, frameEdit, frameFields, {
...extra,
recoveryScope: `b07-frame:${projectId}`,
}),
setGuideArea: (area) => cad.setGuideArea(area),
requestCadDrawing: async () => (await requestCadDrawing()).drawing,
restoreDrawing: () => {
if (currentDrawing) void loadDrawing(currentDrawing, currentIndex);
},
onSaved: forgetAll,
currentDrawingInfo: () =>
currentDrawing ? { label: currentDrawing.label, number: String(currentIndex + 1) } : null,
});
/** [평면도 관리] 모달을 닫음 — 고쳤으면 목록 · 도면을 서버에서 다시 받아 화면을 다시 세운다. */
const onPlanClosed = (changed: boolean, openId?: string): void => {
if (!changed) {
const target = drawings.find((item) => item.id === openId);
if (target) selectDrawing(target);
return;
}
if (!mayDiscardEdits()) return;
reopenDrawingId = openId ?? currentDrawing?.id;
cad.destroy();
void renderB07DesignDetail(root);
};
// 묶음 ⚙(280-5) — 평면도 = 평면도 관리 · 횡단도 = 장 배치 · 안내선 · 머리 [원본에서 다시 그리기]
const openSettings = (kind: DesignDrawingItem["kind"]): void =>
openDrawingSettings(kind, {
drawings,
current: () => currentDrawing,
canRedraw: () => hasRedrawable(store),
redraw: () => redrawFromSource(store),
plan: projectId ? { projectId, onClosed: onPlanClosed } : undefined,
cross: {
layout: crossLayout,
save: (next, sheet) => void saveCrossLayoutAndRebuild(next, sheet),
guideToggle,
},
});
const drawingPanel = buildDrawingSidePanel(
drawings,
selectDrawing,
drawingError,
devBypass,
projectId ? openSettings : undefined,
folded,
);
drawingListEl = drawingPanel;
// 미리받기 상태(508-3b) — 못 받은 장 = 「다시 받기」 표시(누르면 다시 받음)
const markFetch = (id: string, state: string): void => {
const button = findButton(id);
if (button) button.dataset.fetch = state;
};
drawings.forEach((item) => markFetch(item.id, fetchState(item.id)));
onFetchState(markFetch);
const actions = document.createElement("div");
// 하단 고정은 공용 ui-sidebar-actions로 통일 — 사이드 본문이 [스크롤 영역][액션 줄]로
// 쪼개지고 액션 줄은 스크롤 밖에 남는다(2026-08-18 사용자 지시, B04~B07 공통).
actions.className = "b07-drawing-actions ui-sidebar-actions";
// 액션 칸은 세 줄이다 — 1행 도각 편집 띠(평소 숨김), 2행 [도각 편집]
// (2026-09-02 사용자 지시 — CAD 리본과 겹치던 떠 있는 띠를 여기로 옮김 · 확정 단추는 281 에 지움),
// 3행 [?] [<] [>] [저장] [초기화](298-2 · B05 · B06 과 같은 차례 · [?] [<] [>] 는 공용이 앞에 붙임).
const frameRow = document.createElement("div");
frameRow.className = "b07-drawing-actions__row";
frameRow.append(frameEditor.button);
const actionRow = document.createElement("div");
actionRow.className = "b07-drawing-actions__row";
actionRow.dataset.helpRow = "";
const refreshStages = (): void => {
if (!projectId) return;
clearWorkflowStateCache();
void fetchWorkflowState(projectId)
.then((state) => layout.setStages(state.stages))
.catch(() => undefined);
};
const saveButton = createButton({
label: L("Common_Btn_Save"),
variant: "ghost",
onClick: () => void saveDrawingsNow(store, refreshStages),
});
const resetButton = createButton({
label: L("Common_Btn_Reset"),
variant: "danger-ghost",
onClick: () => void resetDrawings(store),
});
saveButton.disabled = resetButton.disabled = !projectId;
actionRow.append(saveButton, resetButton);
actions.append(frameEditor.banner, frameRow, actionRow);
drawingPanel.append(infoPanelHost, actions);
const cadStack = document.createElement("div");
cadStack.className = "b07-cad-stack";
cadStack.append(tabs.element, cad.element);
// 탭 줄 겹판(280-6) — CAD 가 리본 [출력] 오른쪽 빈 자리를 `.cad-host` 에 `--cad-tab-slot-left/right`
// (px · CSS left · right 꼴)로 적으면 그 자리에 겹침 · 값이 없으면 CAD 위 지금 자리.
const placeTabs = (): void => {
const slotted = cad.element.style.getPropertyValue("--cad-tab-slot-left") !== "";
if (slotted === (tabs.element.parentElement === cad.element)) return;
if (slotted) cad.element.append(tabs.element);
else cadStack.prepend(tabs.element);
};
new MutationObserver(placeTabs).observe(cad.element, {
attributes: true,
attributeFilter: ["style"],
});
placeTabs();
/** 페이지 이동(281) — 고친 도면 저장 → B08 · B09 면 단계 4 완료 → 이동 · 실패면 머묾. */
let moving = false;
const moveToStage = async (stepIndex: number): Promise<void> => {
if (!projectId || moving) return;
moving = true;
try {
if (!(await saveEditedDrawings(store))) return;
if (stepIndex > 4 && drawings.length > 0) {
try {
await completeDesignStage(projectId);
} catch (error) {
showToast(
error instanceof Error ? error.message : "상세 설계 단계를 완료하지 못했습니다.",
"error",
);
return;
}
}
goToWorkflowStage(projectId, WORKFLOW_STEP_ROUTES[stepIndex]);
} finally {
moving = false;
}
};
const layout = createWorkflowLayout({
title: L("B07_Design_Title"),
steps: workflowSteps(),
activeStep: 4,
leftPanel: drawingPanel,
mainContent: cadStack,
stages: workflowState?.stages,
// B08 = 옮길 때 단계 4 를 완료하므로 운영에서 막힌 상태여도 켬(281) — 공용 판은 이 단계를 켬
currentStage: 5,
routes: WORKFLOW_STEP_ROUTES,
// ☰ · 진행 단계 판 · [<] [>] 한 길 — 저장 후 이동(옛 「모든 횡단 확정」 잠금 · 자동 B08 이동 없음)
onStepClick: (stepIndex) => void moveToStage(stepIndex),
});
layout.root.classList.add("b07-design-layout");
root.replaceChildren(layout.root);
// 페이지 진입 시 첫 도면(표지 · 311)을 자동 선택 — 빈 CAD 화면 방지.
// CAD가 아직 준비 전이면 cad.load 가 붙들고 있다가 ready 때 보낸다.
// 목록 지문으로 받아 둔 도면을 맞춤 — 바뀐 도면만 버림(다른 페이지에서 돌던 받기는 이어 감)
if (projectId) syncDrawings(projectId, drawings);
if (drawings.length > 0) {
const crossSheets = drawings.filter(isCrossSheet);
const reopenId = reopenCrossSheet
? crossSheets[Math.min(reopenCrossSheet, crossSheets.length) - 1]?.id
: reopenDrawingId;
const reopen = drawings.findIndex((item) => item.id === reopenId);
reopenDrawingId = undefined;
reopenCrossSheet = undefined;
// 311 기억한 장이 없으면 표지
const cover = drawings.findIndex((item) => item.kind === "cover");
const first = reopen >= 0 ? reopen : Math.max(cover, 0);
// 첫 장을 고른 뒤에 나머지를 받는다 — 진입 속도를 뺏지 않는다(고른 장을 받는 동안 쉼).
void openTab(drawings[first]).then(startPrefetch);
}
}