323 lines
11 KiB
TypeScript
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';
|
|
}
|