import type { UserSelection, SlidePlan, Zone, InternalRegion, LayoutPresetId } from "../types/designAgent"; import type { UserOverrides } from "../services/userOverridesApi"; // ─── IMP-52 u6 — restore-on-reopen helpers (pure, exported for testing) ──── // These helpers compose persisted `user_overrides.json` payloads (typed by // the u5 service) onto the in-memory `UserSelection`. They live here rather // than inline in Home.tsx so vitest can drive them in a node environment // without booting React or pulling in the radix-ui / lucide UI deps that // Home.tsx requires. Home.tsx wires these into: // • handleFileUpload (pre-Generate layout / zone_geometries / zone_sections // seed so handleGenerate's CLI-args build picks them up) // • handleGenerate post-loadRun (frame remap unit_id → region.id over the // freshly built slidePlan) // The on-disk schema and clear-sentinel semantics are owned by: // • src/user_overrides_io.py (KNOWN_AXES, u1) // • Front/vite.config.ts mergeUserOverrides (u4) // • Front/client/src/services/userOverridesApi.ts (UserOverrides type, u5) // Any KNOWN_AXES drift must land in those files first. /** * Derive the `/api/user-overrides/:key` MDX-stem key from a filename. * Strips a trailing `.mdx` (case-insensitive). The key matches the Python * `Path(args.mdx_path).stem` derivation used by the backend fallback (u2), * so the same persisted file is read from both ends without translation. */ export function deriveUserOverridesKey(filename: string): string { return filename.replace(/\.mdx$/i, ""); } const LAYOUT_PRESET_IDS = new Set([ "single", "horizontal-2", "vertical-2", "top-1-bottom-2", "top-2-bottom-1", "left-1-right-2", "left-2-right-1", "grid-2x2", ]); /** * Layer the three non-frame axes from a persisted `user_overrides.json` * payload onto an existing `UserSelection`. Foreign / unrecognized payload * shapes are silently ignored — the u5 GET path already returns `{}` on * corrupt files, but we revalidate here so hand-edited files or future * forward-compat axes cannot poison the in-memory state. * * Frames are NOT layered here because the on-disk key (`unit_id` = * section_ids joined by `+`) only resolves after the slidePlan zones are * known. Use `remapPersistedFramesToZoneFrames` in the post-loadRun step. */ export function applyPersistedNonFrameOverrides( selection: UserSelection, persisted: Partial | null | undefined, ): UserSelection { if (!persisted || typeof persisted !== "object") return selection; const next = { ...selection.overrides }; if (typeof persisted.layout === "string" && LAYOUT_PRESET_IDS.has(persisted.layout)) { next.layout_preset = persisted.layout as LayoutPresetId; } if ( persisted.zone_geometries && typeof persisted.zone_geometries === "object" && !Array.isArray(persisted.zone_geometries) ) { next.zone_geometries = { ...persisted.zone_geometries }; } if ( persisted.zone_sections && typeof persisted.zone_sections === "object" && !Array.isArray(persisted.zone_sections) ) { next.zone_sections = { ...persisted.zone_sections }; } // IMP-51 (#79) u11 — layer the 5th persisted axis (`image_overrides`) by // the same array / non-object guard the zone_geometries branch uses. The // u3 typed client (services/userOverridesApi.ts) shape and the on-disk // KNOWN_AXES entry (src/user_overrides_io.py u1) are both flat dicts // (image_id → {x,y,w,h} percent-of-slide), so a shallow copy is enough. if ( persisted.image_overrides && typeof persisted.image_overrides === "object" && !Array.isArray(persisted.image_overrides) ) { next.image_overrides = { ...persisted.image_overrides }; } return { ...selection, overrides: next }; } /** * Remap persisted frames (`unit_id` → template_id) to the in-memory * `zone_frames` (region.id → template_id) using the freshly built * slidePlan zones. `unit_id` follows handleGenerate's convention: * `zone.section_ids.join("+")`. Persisted entries whose unit_id no longer * matches any zone (e.g. user changed zone_sections between sessions) are * silently dropped. */ export function remapPersistedFramesToZoneFrames( slidePlan: SlidePlan | null | undefined, framesByUnitId: Record | null | undefined, ): Record { if (!slidePlan || !framesByUnitId || typeof framesByUnitId !== "object") { return {}; } const out: Record = {}; for (const zone of slidePlan.zones) { const region = zone.internal_regions[0]; if (!region) continue; if (!Array.isArray(zone.section_ids) || zone.section_ids.length === 0) continue; const unitId = zone.section_ids.join("+"); const templateId = framesByUnitId[unitId]; if (typeof templateId === "string" && templateId.length > 0) { out[region.id] = templateId; } } return out; } /** * Phase Z 초기 선택 상태 생성 * SlidePlan의 결과를 초기 값으로 사용 (Step 11까지의 결과 반영) */ export function createInitialUserSelection(slidePlan?: SlidePlan | null): UserSelection { const initialSections: Record = {}; const initialFrames: Record = {}; if (slidePlan) { slidePlan.zones.forEach(zone => { // 1. 모든 섹션을 각자의 지정된 존에 할당 (초안 배치) initialSections[zone.zone_id] = [...zone.section_ids]; // 2. 각 리전의 기본 frame. // 2026-05-14 — backend frame_match_strategy.frame_id 가 있을 때만 init. // null 인 경우 (backend current_default_candidate=None 등) frame_candidates[0] // 로 자동 채우지 않음 → SlideCanvas 의 preview overlay 트리거 조건 // (override !== default) 안 발동. 사용자가 직접 frame 클릭해야 preview 보임. // 배경 : 04-1 같은 case 에서 backend selection_path=rank_1 (env toggle 통과) // 이어도 current_default=None 이면 default 와 override mismatch 로 preview 강제 발동. zone.internal_regions.forEach(region => { const topFrameId = region.frame_match_strategy.frame_id; if (topFrameId) { initialFrames[region.id] = topFrameId; } }); }); } return { selectedSectionId: null, selectedZoneId: slidePlan?.zones[0]?.id || null, selectedRegionId: slidePlan?.zones[0]?.internal_regions[0]?.id || null, overrides: { layout_preset: slidePlan?.layout_preset, zone_frames: initialFrames, zone_sections: initialSections, zone_sizes: {}, zone_geometries: {}, // IMP-51 (#79) u11 — image_overrides axis starts empty; entries land // here via `saveImageOverride` (SlideCanvas drag/resize handler) and // are seeded on reopen via `applyPersistedNonFrameOverrides`. image_overrides: {}, }, }; } export function saveZoneGeometry( selection: UserSelection, zoneId: string, geometry: { x: number; y: number; w: number; h: number } ): UserSelection { return { ...selection, overrides: { ...selection.overrides, zone_geometries: { ...selection.overrides.zone_geometries, [zoneId]: geometry, } } }; } /** * IMP-51 (#79) u11 — record a single `image_id` → slide-absolute percent * geometry on the in-memory selection. Mirrors `saveZoneGeometry` but on * the 5th persisted axis (`image_overrides`); the SlideCanvas drag/resize * handler (u8) emits one entry per pointer move, and u10's Home wiring * funnels each emit through this helper before scheduling the debounced * PUT. Pure / immutable — returns a fresh `UserSelection`; the input is * never mutated. Existing entries for the same `imageId` are replaced. */ export function saveImageOverride( selection: UserSelection, imageId: string, geometry: { x: number; y: number; w: number; h: number }, ): UserSelection { return { ...selection, overrides: { ...selection.overrides, image_overrides: { ...selection.overrides.image_overrides, [imageId]: geometry, }, }, }; } export function saveZoneSizes(selection: UserSelection, groupId: string, sizes: number[]): UserSelection { return { ...selection, overrides: { ...selection.overrides, zone_sizes: { ...selection.overrides.zone_sizes, [groupId]: sizes, } } }; } /** * 특정 섹션을 새로운 존으로 이동 (Drag & Drop) */ export function moveSectionToZone( selection: UserSelection, sectionId: string, targetZoneId: string ): UserSelection { const newZoneSections = { ...selection.overrides.zone_sections }; // 1. 모든 존에서 해당 섹션 제거 (이동 전 위치 클리어) Object.keys(newZoneSections).forEach(zid => { newZoneSections[zid] = newZoneSections[zid].filter(id => id !== sectionId); }); // 2. 타겟 존에 섹션 추가 if (!newZoneSections[targetZoneId]) { newZoneSections[targetZoneId] = []; } if (!newZoneSections[targetZoneId].includes(sectionId)) { newZoneSections[targetZoneId].push(sectionId); } return { ...selection, overrides: { ...selection.overrides, zone_sections: newZoneSections } }; } export function selectZone(selection: UserSelection, zoneId: string | null): UserSelection { return { ...selection, selectedZoneId: zoneId, selectedRegionId: null, // Zone이 바뀌면 Region 선택 해제 }; } export function selectRegion(selection: UserSelection, regionId: string | null): UserSelection { return { ...selection, selectedRegionId: regionId, }; } export function applyLayout(selection: UserSelection, layoutId: LayoutPresetId): UserSelection { return { ...selection, overrides: { ...selection.overrides, layout_preset: layoutId, }, }; } export function applyFrame(selection: UserSelection, regionId: string, frameId: string): UserSelection { return { ...selection, overrides: { ...selection.overrides, zone_frames: { ...selection.overrides.zone_frames, [regionId]: frameId, }, }, }; } /** * 현재 선택된 Zone 객체 반환 */ export function getSelectedZone(slidePlan: SlidePlan | null, selection: UserSelection): Zone | null { if (!slidePlan || !selection.selectedZoneId) return null; // id 또는 zone_id 매칭 return slidePlan.zones.find(z => z.id === selection.selectedZoneId || z.zone_id === selection.selectedZoneId) || null; } /** * 현재 선택된 Region 객체 반환 */ export function getSelectedRegion(zone: Zone | null, selection: UserSelection): InternalRegion | null { if (!zone || !selection.selectedRegionId) return null; return zone.internal_regions.find(r => r.id === selection.selectedRegionId) || null; } /** * 특정 Zone에 할당된 섹션 ID 목록 반환 (오버라이드 우선) */ export function getSectionsForZone(zone: Zone, selection: UserSelection): string[] { return selection.overrides.zone_sections[zone.zone_id] || zone.section_ids; } /** * 최종 유효 레이아웃 ID 반환 */ export function getEffectiveLayoutId(slidePlan: SlidePlan | null, selection: UserSelection): LayoutPresetId { if (selection.overrides.layout_preset) return selection.overrides.layout_preset; return slidePlan?.layout_preset || 'single'; }