Files
C.E.L_Slide_test2/Front/client/src/utils/slidePlanUtils.ts
T

323 lines
11 KiB
TypeScript

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<string>([
"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<UserOverrides> | 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<string, string> | null | undefined,
): Record<string, string> {
if (!slidePlan || !framesByUnitId || typeof framesByUnitId !== "object") {
return {};
}
const out: Record<string, string> = {};
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<string, string[]> = {};
const initialFrames: Record<string, string> = {};
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';
}