feat(#90): IMP-56 u1-u19 catch-up before final close (post-u20 push fix)
Some checks failed
Multi-MDX Regression (IMP-91) / multi-mdx-regression (push) Failing after 20s
Some checks failed
Multi-MDX Regression (IMP-91) / multi-mdx-regression (push) Failing after 20s
u1: text_overrides axis in user_overrides_io u2: structure_overrides axis in user_overrides_io u3: vite allowlist for new endpoints u4: text_override_resolver u5: Step 12 text_overrides apply in phase_z2_pipeline u6: structure_override_resolver u7: text_path_stamper u8: SlideCanvas text-edit capture u9: SlideCanvas structure-edit overlay u10: userOverridesApi service extension u11: designAgent types extension u12: slidePlanUtils restore u13: user_overrides endpoint tests u14: user_overrides restore tests u15: pipeline fallback tests u16: edit-mode state + gating tests u17: slide_base print mode CSS u18: /api/connect endpoint (vite) u19: /api/export endpoint (vite) Recovery scope: 29 files (12 modified + 17 new). u20 already pushed in 9439575; this commit lands u1-u19 that were authored but not committed before #90 was externally closed. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -28,7 +28,12 @@ import {
|
||||
crossedDragThreshold,
|
||||
type ImageDragDirection,
|
||||
} from "./slideCanvasDragMath";
|
||||
import type { ImageOverridesOverride } from "../services/userOverridesApi";
|
||||
import type {
|
||||
ImageOverridesOverride,
|
||||
StructureOverridesOverride,
|
||||
StructureOverridePerZone,
|
||||
} from "../services/userOverridesApi";
|
||||
import StructureEditOverlay from "./StructureEditOverlay";
|
||||
|
||||
interface SlideCanvasProps {
|
||||
slidePlan: SlidePlan | null;
|
||||
@@ -73,11 +78,112 @@ interface SlideCanvasProps {
|
||||
imageId: string,
|
||||
geometry: { x: number; y: number; w: number; h: number }
|
||||
) => void;
|
||||
/** IMP-90 (#90) u13 — focusout-emitted capture; u15 debounces + PUTs. */
|
||||
onTextEdit?: (capture: TextEditCapture) => void;
|
||||
/** IMP-90 (#90) u14 — persisted structure overrides per zone
|
||||
* (slot_order + hidden_slots). When `editMode === "structure"` the
|
||||
* StructureEditOverlay reads from this to render the current state. */
|
||||
structureOverrides?: StructureOverridesOverride;
|
||||
/** IMP-90 (#90) u14 — emitted whenever the user reorders or hides a
|
||||
* slot in structure-mode. u15 will debounce + PUT to /api/user-
|
||||
* overrides; u14 only exposes the capture. SCOPE LOCK: inner shape is
|
||||
* `{slot_order, hidden_slots}` only (frame swap stays on `frames` axis). */
|
||||
onStructureEdit?: (zoneId: string, capture: StructureOverridePerZone) => void;
|
||||
}
|
||||
|
||||
const SLIDE_W = 1280;
|
||||
const SLIDE_H = 720;
|
||||
|
||||
// IMP-90 (#90) u11 — discriminated edit mode. Replaces the prior single
|
||||
// `isEditMode` boolean. u11 introduces the enum + the toolbar UI surface;
|
||||
// gesture gating (text contentEditable vs structure reorder vs image-zone
|
||||
// drag/resize) stays unified behind `isEditMode = editMode !== 'off'` so
|
||||
// existing behavior is preserved byte-identical. u12 will discriminate the
|
||||
// gestures per mode (mutually exclusive). The 'off' state is the no-edit
|
||||
// baseline; 'image-zone' bundles image edit (#79) + zone resize (#81)
|
||||
// because both are pointer-driven canvas gestures on slide geometry.
|
||||
export type EditMode = "off" | "text" | "structure" | "image-zone";
|
||||
export const EDIT_MODES: ReadonlyArray<EditMode> = ["text", "structure", "image-zone"];
|
||||
/** Pure helper — given the current edit mode and the user's requested mode,
|
||||
* return the next mode. Clicking the active mode toggles back to 'off';
|
||||
* clicking a different mode switches; explicit 'off' always exits. */
|
||||
export function nextEditMode(current: EditMode, requested: EditMode): EditMode {
|
||||
if (requested === "off") return "off";
|
||||
return current === requested ? "off" : requested;
|
||||
}
|
||||
|
||||
// IMP-90 (#90) u12 — per-mode gesture gating. Pure helper deriving the
|
||||
// boolean gates that drive SlideCanvas's useEffect branches (designMode
|
||||
// + iframe-side image click listener) and JSX conditionals (iframe
|
||||
// pointer-events, zone resize/drag affordances, image overlay). The
|
||||
// mapping enforces the mutually-exclusive contract from the issue body:
|
||||
// text -> contentEditable + iframe pointer-events:auto only.
|
||||
// structure -> nothing here; u14 will plant the structure overlay.
|
||||
// image-zone -> zone resize/drag + image overlay; iframe pe:auto so
|
||||
// in-iframe user-content images can be click-selected.
|
||||
// off -> every gate false (baseline).
|
||||
// pendingLayout fully suppresses every gate — mirrors the existing
|
||||
// useEffect (line ~248) that forces editMode='off' on pendingLayout
|
||||
// entry. The helper still defensively returns all-false so a stray
|
||||
// pendingLayout=true with a non-'off' editMode never leaks gestures.
|
||||
export interface EditModeGates {
|
||||
textEditing: boolean;
|
||||
imageSelection: boolean;
|
||||
iframePointerAuto: boolean;
|
||||
zoneGestures: boolean;
|
||||
imageOverlay: boolean;
|
||||
}
|
||||
export function computeEditModeGates(
|
||||
editMode: EditMode,
|
||||
isPendingLayout: boolean
|
||||
): EditModeGates {
|
||||
if (isPendingLayout) {
|
||||
return {
|
||||
textEditing: false,
|
||||
imageSelection: false,
|
||||
iframePointerAuto: false,
|
||||
zoneGestures: false,
|
||||
imageOverlay: false,
|
||||
};
|
||||
}
|
||||
return {
|
||||
textEditing: editMode === "text",
|
||||
imageSelection: editMode === "image-zone",
|
||||
iframePointerAuto: editMode === "text" || editMode === "image-zone",
|
||||
zoneGestures: editMode === "image-zone",
|
||||
imageOverlay: editMode === "image-zone",
|
||||
};
|
||||
}
|
||||
|
||||
// IMP-90 (#90) u13 — pure helper resolving a contentEditable focusout
|
||||
// target into (zoneId, textPath, value). data-text-path stamped by u8 at
|
||||
// Step 13; .zone[data-zone-position] from Phase Z slide-base. Non-stamped
|
||||
// targets return null so capture silently skips. u15 will debounce + PUT.
|
||||
export interface TextEditCaptureTarget {
|
||||
closest(selector: string): TextEditCaptureTarget | null;
|
||||
getAttribute(name: string): string | null;
|
||||
textContent: string | null;
|
||||
}
|
||||
export interface TextEditCapture {
|
||||
zoneId: string;
|
||||
textPath: string;
|
||||
value: string;
|
||||
}
|
||||
export function deriveTextEditCapture(
|
||||
target: TextEditCaptureTarget | null
|
||||
): TextEditCapture | null {
|
||||
if (!target) return null;
|
||||
const lineEl = target.closest("[data-text-path]");
|
||||
if (!lineEl) return null;
|
||||
const textPath = lineEl.getAttribute("data-text-path");
|
||||
if (!textPath) return null;
|
||||
const zoneEl = lineEl.closest(".zone[data-zone-position]");
|
||||
if (!zoneEl) return null;
|
||||
const zoneId = zoneEl.getAttribute("data-zone-position");
|
||||
if (!zoneId) return null;
|
||||
return { zoneId, textPath, value: (lineEl.textContent ?? "").trim() };
|
||||
}
|
||||
|
||||
export default function SlideCanvas({
|
||||
slidePlan,
|
||||
userSelection,
|
||||
@@ -93,6 +199,9 @@ export default function SlideCanvas({
|
||||
onZoneResize,
|
||||
imageOverrides,
|
||||
onImageResize,
|
||||
onTextEdit,
|
||||
structureOverrides,
|
||||
onStructureEdit,
|
||||
}: SlideCanvasProps) {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const [scale, setScale] = useState(1);
|
||||
@@ -135,7 +244,15 @@ export default function SlideCanvas({
|
||||
// HTML 편집 모드 — 글벗 패턴 (designMode + contentEditable + outline CSS) 차용.
|
||||
// 활성 시 iframe 안 텍스트 element 직접 클릭하여 수정 가능. backend 반영은 별 작업.
|
||||
// pendingLayout 과 배타적 (충돌 방지).
|
||||
const [isEditMode, setIsEditMode] = useState(false);
|
||||
// IMP-90 (#90) u11 — `editMode` enum replaces the prior boolean. The
|
||||
// `isEditMode` shim is kept ONLY for the pendingLayout coupling +
|
||||
// zone-wrapper visual cues (border / hover / selected styling) that
|
||||
// fire whenever any edit mode is active. u12 routes gesture-activating
|
||||
// gates through `editGates` so text / structure / image-zone gestures
|
||||
// are mutually exclusive.
|
||||
const [editMode, setEditMode] = useState<EditMode>("off");
|
||||
const isEditMode = editMode !== "off";
|
||||
const editGates = computeEditModeGates(editMode, !!isPendingLayout);
|
||||
const iframeRef = useRef<HTMLIFrameElement>(null);
|
||||
|
||||
// 편집 모드 toggle 시 iframe contentDocument 에 글벗 패턴 적용 / 해제.
|
||||
@@ -159,11 +276,22 @@ export default function SlideCanvas({
|
||||
|
||||
const editableTags = ["DIV", "P", "H1", "H2", "H3", "H4", "SPAN", "LI", "TD", "TH", "FIGCAPTION"];
|
||||
let inputHandler: ((e: Event) => void) | null = null;
|
||||
// IMP-90 (#90) u13 — focusout (= bubbling blur) emits one capture per
|
||||
// finished line edit; u15 will debounce + PUT.
|
||||
let textEditCaptureHandler: ((e: Event) => void) | null = null;
|
||||
// IMP-51 (#79) u8 — user-content image click listeners installed
|
||||
// inside the iframe contentDocument. Tracked here so the cleanup
|
||||
// callback can remove them when edit mode exits (or iframe reloads).
|
||||
const imageClickBindings: Array<{ el: HTMLImageElement; handler: (e: Event) => void; prevCursor: string; prevOutline: string }> = [];
|
||||
if (isEditMode) {
|
||||
|
||||
// IMP-90 (#90) u12 — text-editing gate: only the 'text' editMode
|
||||
// turns designMode on + makes the editable tags contentEditable.
|
||||
// The else branch tears the prior state down so leaving text mode
|
||||
// (to structure / image-zone / off) immediately disables in-place
|
||||
// text editing — required for mutual exclusivity vs the image-zone
|
||||
// overlay's drag/resize gestures (a contentEditable cursor would
|
||||
// otherwise be placed by every image click).
|
||||
if (editGates.textEditing) {
|
||||
doc.designMode = "on";
|
||||
doc.querySelectorAll(".slide *").forEach((el) => {
|
||||
if (editableTags.includes((el as HTMLElement).tagName)) {
|
||||
@@ -176,11 +304,28 @@ export default function SlideCanvas({
|
||||
};
|
||||
doc.addEventListener("input", inputHandler);
|
||||
|
||||
// IMP-51 (#79) u8 — wire click → selectedImageId on every stamped
|
||||
// user-content image. Selector mirrors USER_CONTENT_IMAGE_SELECTOR
|
||||
// in src/image_id_stamper.py (+ requires data-image-id which the
|
||||
// stamper always emits). Decorative / frame imgs lacking the role
|
||||
// attribute are intentionally NOT clickable here.
|
||||
textEditCaptureHandler = (ev: Event) => {
|
||||
const cap = deriveTextEditCapture(
|
||||
ev.target as unknown as TextEditCaptureTarget | null
|
||||
);
|
||||
if (cap) onTextEdit?.(cap);
|
||||
};
|
||||
doc.addEventListener("focusout", textEditCaptureHandler);
|
||||
} else {
|
||||
doc.designMode = "off";
|
||||
doc.querySelectorAll("[contenteditable]").forEach((el) => {
|
||||
(el as HTMLElement).removeAttribute("contenteditable");
|
||||
});
|
||||
}
|
||||
|
||||
// IMP-90 (#90) u12 — image-selection gate: only the 'image-zone'
|
||||
// editMode wires the in-iframe user-content image click → selection.
|
||||
// Selector mirrors USER_CONTENT_IMAGE_SELECTOR in image_id_stamper.py
|
||||
// (requires data-image-id which the stamper always emits). Decorative
|
||||
// / frame imgs lacking the role attribute are NOT clickable. The
|
||||
// else branch clears `selectedImageId` so the React-side overlay
|
||||
// never lingers on a non-image-zone edit mode.
|
||||
if (editGates.imageSelection) {
|
||||
const imgEls = doc.querySelectorAll<HTMLImageElement>(
|
||||
'.slide img[data-image-role="user-content"][data-image-id]'
|
||||
);
|
||||
@@ -200,12 +345,6 @@ export default function SlideCanvas({
|
||||
imageClickBindings.push({ el: imgEl, handler, prevCursor, prevOutline });
|
||||
});
|
||||
} else {
|
||||
doc.designMode = "off";
|
||||
doc.querySelectorAll("[contenteditable]").forEach((el) => {
|
||||
(el as HTMLElement).removeAttribute("contenteditable");
|
||||
});
|
||||
// edit-mode exit also clears stale image selection so the handle
|
||||
// overlay never lingers on a non-editable iframe.
|
||||
setSelectedImageId(null);
|
||||
}
|
||||
|
||||
@@ -213,19 +352,60 @@ export default function SlideCanvas({
|
||||
if (inputHandler && doc) {
|
||||
doc.removeEventListener("input", inputHandler);
|
||||
}
|
||||
if (textEditCaptureHandler && doc) {
|
||||
doc.removeEventListener("focusout", textEditCaptureHandler);
|
||||
}
|
||||
imageClickBindings.forEach(({ el, handler, prevCursor, prevOutline }) => {
|
||||
el.removeEventListener("click", handler);
|
||||
el.style.cursor = prevCursor;
|
||||
el.style.outline = prevOutline;
|
||||
});
|
||||
};
|
||||
}, [isEditMode, finalHtmlUrl, onContentEdit]);
|
||||
}, [editGates.textEditing, editGates.imageSelection, finalHtmlUrl, onContentEdit, onTextEdit]);
|
||||
|
||||
// pendingLayout 진입 시 편집 모드 자동 OFF (충돌 방지).
|
||||
useEffect(() => {
|
||||
if (isPendingLayout && isEditMode) setIsEditMode(false);
|
||||
if (isPendingLayout && isEditMode) setEditMode("off");
|
||||
}, [isPendingLayout, isEditMode]);
|
||||
|
||||
// IMP-90 (#90) u14 — discover slot keys per zone for the structure
|
||||
// overlay. Source = iframe DOM `data-text-path="{slot_key}.{line_index}"`
|
||||
// attributes stamped by u8 (`src/text_path_stamper.py`). Unique slot_key
|
||||
// prefixes per `.zone[data-zone-position]` form the overlay's slot list.
|
||||
// Discovery runs only when entering structure mode (and resets on exit
|
||||
// or iframe reload) so off / text / image-zone modes never pay this
|
||||
// traversal cost.
|
||||
const [slotKeysByZone, setSlotKeysByZone] = useState<
|
||||
Record<string, string[]>
|
||||
>({});
|
||||
useEffect(() => {
|
||||
if (editMode !== "structure" || isPendingLayout) {
|
||||
setSlotKeysByZone({});
|
||||
return;
|
||||
}
|
||||
const doc = iframeRef.current?.contentDocument;
|
||||
if (!doc) return;
|
||||
const next: Record<string, string[]> = {};
|
||||
doc.querySelectorAll(".zone[data-zone-position]").forEach((zEl) => {
|
||||
const zoneId = (zEl as HTMLElement).getAttribute("data-zone-position");
|
||||
if (!zoneId) return;
|
||||
const seen = new Set<string>();
|
||||
const keys: string[] = [];
|
||||
zEl.querySelectorAll("[data-text-path]").forEach((lineEl) => {
|
||||
const path = (lineEl as HTMLElement).getAttribute("data-text-path");
|
||||
if (!path) return;
|
||||
const lastDot = path.lastIndexOf(".");
|
||||
const slotKey = lastDot > 0 ? path.slice(0, lastDot) : path;
|
||||
if (slotKey && !seen.has(slotKey)) {
|
||||
seen.add(slotKey);
|
||||
keys.push(slotKey);
|
||||
}
|
||||
});
|
||||
next[zoneId] = keys;
|
||||
});
|
||||
setSlotKeysByZone(next);
|
||||
}, [editMode, isPendingLayout, finalHtmlUrl]);
|
||||
|
||||
// finalHtmlUrl 이 바뀌면 (= 다른 run / 재실행) stale 측정값 reset.
|
||||
// 새 iframe 의 onLoad 가 발화하면서 measuredZones 다시 채움.
|
||||
useEffect(() => {
|
||||
@@ -332,28 +512,50 @@ export default function SlideCanvas({
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* 편집 모드 toggle 버튼 — normal mode + final.html 있을 때만.
|
||||
글벗 패턴 차용 — designMode + contentEditable. backend 반영은 별 작업. */}
|
||||
{/* IMP-90 (#90) u11 — discriminated edit-mode toolbar.
|
||||
Replaces the prior single ✏ toggle. Three modes (text /
|
||||
structure / image-zone) are mutually exclusive; clicking the
|
||||
active mode toggles back to 'off'. Gesture gating per mode is
|
||||
u12 — u11 only plants the state + UI surface, so all three
|
||||
modes currently share the same `isEditMode` shim behavior. */}
|
||||
{!isPendingLayout && finalHtmlUrl && (
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setIsEditMode((p) => !p);
|
||||
}}
|
||||
className={`absolute top-2 right-2 z-30 text-[10px] font-bold uppercase tracking-tighter px-2.5 py-1 rounded shadow transition ${
|
||||
isEditMode
|
||||
? "bg-emerald-500 text-white hover:bg-emerald-600 ring-2 ring-emerald-200"
|
||||
: "bg-white text-slate-700 hover:bg-slate-100 border border-slate-200"
|
||||
}`}
|
||||
<div
|
||||
data-testid="edit-mode-toolbar"
|
||||
className="absolute top-2 right-2 z-30 flex gap-1"
|
||||
style={{ pointerEvents: "auto" }}
|
||||
title={
|
||||
isEditMode
|
||||
? "편집 모드 — 텍스트 클릭하여 수정. 다시 클릭하여 종료. (변경은 frontend 만, backend 반영 미구현)"
|
||||
: "텍스트 직접 편집 모드 진입"
|
||||
}
|
||||
>
|
||||
{isEditMode ? "✏ 편집 중 (클릭 종료)" : "✏ 편집"}
|
||||
</button>
|
||||
{EDIT_MODES.map((mode) => {
|
||||
const active = editMode === mode;
|
||||
const label =
|
||||
mode === "text" ? "✏ 텍스트" : mode === "structure" ? "▦ 구조" : "🖼 이미지/존";
|
||||
const title =
|
||||
mode === "text"
|
||||
? "텍스트 편집 — 텍스트 클릭하여 직접 수정"
|
||||
: mode === "structure"
|
||||
? "구조 편집 — slot 순서 / 숨김 변경 (u14 펜딩)"
|
||||
: "이미지/존 편집 — 이미지 드래그·리사이즈 + 존 리사이즈";
|
||||
return (
|
||||
<button
|
||||
key={mode}
|
||||
type="button"
|
||||
data-testid={`edit-mode-${mode}`}
|
||||
aria-pressed={active}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setEditMode((prev) => nextEditMode(prev, mode));
|
||||
}}
|
||||
className={`text-[10px] font-bold uppercase tracking-tighter px-2.5 py-1 rounded shadow transition ${
|
||||
active
|
||||
? "bg-emerald-500 text-white hover:bg-emerald-600 ring-2 ring-emerald-200"
|
||||
: "bg-white text-slate-700 hover:bg-slate-100 border border-slate-200"
|
||||
}`}
|
||||
title={title}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div
|
||||
@@ -375,7 +577,13 @@ export default function SlideCanvas({
|
||||
className="w-full h-full border-0 block"
|
||||
scrolling="no"
|
||||
sandbox="allow-same-origin allow-scripts"
|
||||
style={{ pointerEvents: isEditMode ? "auto" : "none" }}
|
||||
// IMP-90 (#90) u12 — iframe pointer-events gate. 'text' needs
|
||||
// pe:auto so the user can click into text fields; 'image-zone'
|
||||
// needs pe:auto so user-content image clicks can reach the
|
||||
// in-iframe click handler that drives `selectedImageId`.
|
||||
// 'structure' and 'off' keep pe:none — structure has no
|
||||
// in-iframe gesture (u14 will overlay React-side controls).
|
||||
style={{ pointerEvents: editGates.iframePointerAuto ? "auto" : "none" }}
|
||||
onLoad={(e) => {
|
||||
// IMP-14 (Step 13 A-4) — embedded vs standalone CSS reset 은 backend
|
||||
// slide_base.html 가 `?embedded=1` query 로 소유. frontend 가 더 이상
|
||||
@@ -564,9 +772,11 @@ export default function SlideCanvas({
|
||||
const makeResizeHandler = (
|
||||
direction: ResizeDir
|
||||
) => (ev: React.MouseEvent<HTMLDivElement>) => {
|
||||
// resize 는 pendingLayout OR 편집 모드 활성. 2026-05-22 demo hot-fix —
|
||||
// frame partial 에 @container aspect-ratio 회전이 들어가서 fixed px 제약 사라짐.
|
||||
if ((!isPendingLayout && !isEditMode) || !onZoneResize) return;
|
||||
// resize 는 pendingLayout OR image-zone 편집 모드 활성. 2026-05-22
|
||||
// demo hot-fix — frame partial 에 @container aspect-ratio 회전이
|
||||
// 들어가서 fixed px 제약 사라짐. IMP-90 u12: text/structure 모드
|
||||
// 에서는 zone resize 비활성 (mutually exclusive per editGates).
|
||||
if ((!isPendingLayout && !editGates.zoneGestures) || !onZoneResize) return;
|
||||
if (!measuredSlideBody) return;
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
@@ -637,7 +847,10 @@ export default function SlideCanvas({
|
||||
ev: React.MouseEvent<HTMLDivElement>
|
||||
) => {
|
||||
ev.stopPropagation();
|
||||
const canDrag = !!((isPendingLayout || isEditMode) && measuredSlideBody && onZoneResize);
|
||||
// IMP-90 u12: zone drag is image-zone-mode-only (text /
|
||||
// structure suppress canDrag; non-zoneGestures click still
|
||||
// triggers onZoneClick via the !dragged branch on mouse-up).
|
||||
const canDrag = !!((isPendingLayout || editGates.zoneGestures) && measuredSlideBody && onZoneResize);
|
||||
const startMouseX = ev.clientX;
|
||||
const startMouseY = ev.clientY;
|
||||
const startGeom = { ...localGeom };
|
||||
@@ -856,12 +1069,14 @@ export default function SlideCanvas({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Step C : zone resize handles — 8 방향. pendingLayout OR 편집 모드 활성.
|
||||
2026-05-22 demo hot-fix — frame partial 에 @container aspect-ratio 회전
|
||||
들어간 후 fixed px 제약 사라져 편집 모드 resize 도 의미 있음.
|
||||
{/* Step C : zone resize handles — 8 방향. pendingLayout OR image-zone
|
||||
편집 모드 활성. 2026-05-22 demo hot-fix — frame partial 에 @container
|
||||
aspect-ratio 회전 들어간 후 fixed px 제약 사라져 image-zone 모드 resize
|
||||
도 의미 있음. IMP-90 u12: text / structure 모드에서는 zone resize
|
||||
affordance 미노출 (editGates.zoneGestures = image-zone only).
|
||||
edge handle (top/bottom/left/right) : 한 boundary 이동
|
||||
corner handle (nw/ne/sw/se) : 두 boundary 동시. */}
|
||||
{(isPendingLayout || isEditMode) && onZoneResize && (
|
||||
{(isPendingLayout || editGates.zoneGestures) && onZoneResize && (
|
||||
<>
|
||||
{/* top edge */}
|
||||
<div
|
||||
@@ -941,8 +1156,10 @@ export default function SlideCanvas({
|
||||
strips intercept the perimeter while the un-covered iframe
|
||||
interior keeps text-edit reachability intact.
|
||||
pendingLayout mode already has wrapper pointerEvents:auto,
|
||||
so these surfaces are only needed in edit mode. */}
|
||||
{isEditMode && !isPendingLayout && onZoneResize && (
|
||||
so these surfaces are only needed in edit mode.
|
||||
IMP-90 u12: image-zone-mode-only — text / structure 모드는
|
||||
zone drag 안 함 (editGates.zoneGestures = false 두 모드 모두). */}
|
||||
{editGates.zoneGestures && !isPendingLayout && onZoneResize && (
|
||||
<>
|
||||
<div
|
||||
onMouseDown={handleZoneMouseDown}
|
||||
@@ -987,6 +1204,39 @@ export default function SlideCanvas({
|
||||
);
|
||||
})}
|
||||
|
||||
{/* IMP-90 (#90) u14 — structure edit overlay (slot reorder +
|
||||
hide). Renders only in `editMode === "structure"` over each
|
||||
measured zone, positioned at the zone's top-right inside the
|
||||
slide-absolute coord space. Slot keys come from u14 iframe
|
||||
traversal (`slotKeysByZone`). Mutations emit through
|
||||
onStructureEdit; u15 will debounce + PUT. */}
|
||||
{!isPendingLayout && editMode === "structure" && finalHtmlUrl &&
|
||||
slidePlan?.zones.map((zone) => {
|
||||
const m = measuredZones[zone.zone_id];
|
||||
if (!m) return null;
|
||||
const slotKeys = slotKeysByZone[zone.zone_id] ?? [];
|
||||
const current = structureOverrides?.[zone.zone_id];
|
||||
return (
|
||||
<div
|
||||
key={`struct-${zone.id}`}
|
||||
className="absolute z-30"
|
||||
style={{
|
||||
left: m.x * W_SCALED,
|
||||
top: m.y * H_SCALED,
|
||||
width: m.w * W_SCALED,
|
||||
pointerEvents: "none",
|
||||
}}
|
||||
>
|
||||
<StructureEditOverlay
|
||||
zoneId={zone.zone_id}
|
||||
slotKeys={slotKeys}
|
||||
current={current}
|
||||
onChange={onStructureEdit}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* ── IMP-51 (#79) u8 — user-content image edit overlay ──
|
||||
Activates only in edit mode when an image_id appears in either
|
||||
`imageOverrides` (u11-fed persisted axis) or `measuredImages`
|
||||
@@ -1014,7 +1264,11 @@ export default function SlideCanvas({
|
||||
`slideCanvasDragMath.ts` so the boundary contract Codex #16
|
||||
verified is exercised directly by vitest (mirror of how IMP-54
|
||||
u3 split the zone math out of SlideCanvas). */}
|
||||
{!isPendingLayout && isEditMode && finalHtmlUrl && onImageResize &&
|
||||
{/* IMP-90 u12: image overlay is image-zone-mode-only. text /
|
||||
structure 모드에서는 image drag/resize affordance 미노출
|
||||
(editGates.imageOverlay = false). pendingLayout 도 동일하게
|
||||
suppress (computeEditModeGates 가 모두 false 반환). */}
|
||||
{!isPendingLayout && editGates.imageOverlay && finalHtmlUrl && onImageResize &&
|
||||
Object.entries({ ...measuredImages, ...(imageOverrides ?? {}) }).map(
|
||||
([imageId]) => {
|
||||
const persisted = imageOverrides?.[imageId];
|
||||
|
||||
165
Front/client/src/components/StructureEditOverlay.tsx
Normal file
165
Front/client/src/components/StructureEditOverlay.tsx
Normal file
@@ -0,0 +1,165 @@
|
||||
/**
|
||||
* IMP-90 (#90) u14 — Structure edit overlay.
|
||||
*
|
||||
* React component + pure helpers that present a per-zone slot list with
|
||||
* reorder (↑ / ↓) and hide (👁 / 🚫) affordances. Mounted by SlideCanvas
|
||||
* when `editMode === "structure"`. Emits a `StructureOverridePerZone`
|
||||
* tuple `{slot_order, hidden_slots}` through `onChange`; u15 will debounce
|
||||
* + PUT this to `/api/user-overrides` (NOT u14 scope), and u16 reads the
|
||||
* persisted axis at the next CLI generate run.
|
||||
*
|
||||
* SCOPE LOCK (binding contract):
|
||||
* - inner shape = `{slot_order, hidden_slots}` ONLY.
|
||||
* - frame swap stays on the existing `frames` axis (u6 backend resolver
|
||||
* rejects frame-swap-shaped inner keys).
|
||||
* - per-slot text content NEVER mutated here — `text_overrides` axis
|
||||
* (u4/u5/u13) handles that exclusively.
|
||||
*
|
||||
* The exported pure helpers (`resolveEffectiveSlotOrder`, `moveItem`) are
|
||||
* the unit's vitest surface; React rendering is NOT tested because the
|
||||
* Front package devDependencies do not include jsdom / @testing-library
|
||||
* (verified by u11/u12/u13 test pattern).
|
||||
*/
|
||||
import type {
|
||||
StructureOverridePerZone,
|
||||
} from "../services/userOverridesApi";
|
||||
|
||||
export interface StructureEditOverlayProps {
|
||||
zoneId: string;
|
||||
/** Discovered slot keys for this zone (e.g. from iframe DOM
|
||||
* `data-text-path` prefixes). Order = backend default. */
|
||||
slotKeys: ReadonlyArray<string>;
|
||||
/** Current persisted override (or undefined). `slot_order` reorders the
|
||||
* discovered keys; missing keys keep backend order at the tail. */
|
||||
current?: StructureOverridePerZone;
|
||||
/** Emitted on every user mutation. u15 wires this to autosave. */
|
||||
onChange?: (zoneId: string, next: StructureOverridePerZone) => void;
|
||||
}
|
||||
|
||||
/** Apply `slot_order` override to the discovered slot list. Unknown
|
||||
* override entries are dropped; missing discovered keys are appended in
|
||||
* backend order so the user never loses a slot by partial-override. */
|
||||
export function resolveEffectiveSlotOrder(
|
||||
slotKeys: ReadonlyArray<string>,
|
||||
slotOrder?: ReadonlyArray<string> | null,
|
||||
): string[] {
|
||||
if (!slotOrder || slotOrder.length === 0) return [...slotKeys];
|
||||
const allowed = new Set(slotKeys);
|
||||
const seen = new Set<string>();
|
||||
const ordered: string[] = [];
|
||||
for (const k of slotOrder) {
|
||||
if (typeof k === "string" && allowed.has(k) && !seen.has(k)) {
|
||||
ordered.push(k);
|
||||
seen.add(k);
|
||||
}
|
||||
}
|
||||
for (const k of slotKeys) {
|
||||
if (!seen.has(k)) ordered.push(k);
|
||||
}
|
||||
return ordered;
|
||||
}
|
||||
|
||||
/** Move `arr[index]` by `delta` positions. Out-of-range returns a fresh
|
||||
* copy of the input (defensive: caller can always treat the result as a
|
||||
* new reference). */
|
||||
export function moveItem<T>(
|
||||
arr: ReadonlyArray<T>,
|
||||
index: number,
|
||||
delta: number,
|
||||
): T[] {
|
||||
const next = arr.slice();
|
||||
const target = index + delta;
|
||||
if (
|
||||
index < 0 ||
|
||||
index >= next.length ||
|
||||
target < 0 ||
|
||||
target >= next.length
|
||||
) {
|
||||
return next;
|
||||
}
|
||||
const tmp = next[index];
|
||||
next[index] = next[target];
|
||||
next[target] = tmp;
|
||||
return next;
|
||||
}
|
||||
|
||||
export default function StructureEditOverlay({
|
||||
zoneId,
|
||||
slotKeys,
|
||||
current,
|
||||
onChange,
|
||||
}: StructureEditOverlayProps) {
|
||||
const effective = resolveEffectiveSlotOrder(slotKeys, current?.slot_order);
|
||||
const hidden = new Set(current?.hidden_slots ?? []);
|
||||
const emit = (nextOrder: string[], nextHidden: Set<string>) => {
|
||||
onChange?.(zoneId, {
|
||||
slot_order: nextOrder,
|
||||
hidden_slots: Array.from(nextHidden),
|
||||
});
|
||||
};
|
||||
return (
|
||||
<div
|
||||
data-testid={`structure-overlay-${zoneId}`}
|
||||
className="bg-white/95 border border-emerald-300 rounded shadow p-2 flex flex-col gap-1 text-[10px]"
|
||||
style={{ pointerEvents: "auto" }}
|
||||
>
|
||||
<div className="font-bold uppercase tracking-wider text-emerald-700 mb-1">
|
||||
▦ {zoneId}
|
||||
</div>
|
||||
{effective.length === 0 ? (
|
||||
<div className="text-slate-400 italic">slot 없음</div>
|
||||
) : (
|
||||
effective.map((key, i) => (
|
||||
<div
|
||||
key={key}
|
||||
data-testid={`slot-${zoneId}-${key}`}
|
||||
className="flex items-center gap-1"
|
||||
>
|
||||
<span
|
||||
className={`flex-1 truncate ${
|
||||
hidden.has(key) ? "text-slate-400 line-through" : "text-slate-700"
|
||||
}`}
|
||||
>
|
||||
{key}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
data-testid={`slot-up-${zoneId}-${key}`}
|
||||
disabled={i === 0}
|
||||
onClick={() => emit(moveItem(effective, i, -1), hidden)}
|
||||
className="px-1 rounded border border-slate-200 disabled:opacity-30 hover:bg-slate-100"
|
||||
title="위로"
|
||||
>
|
||||
↑
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
data-testid={`slot-down-${zoneId}-${key}`}
|
||||
disabled={i === effective.length - 1}
|
||||
onClick={() => emit(moveItem(effective, i, 1), hidden)}
|
||||
className="px-1 rounded border border-slate-200 disabled:opacity-30 hover:bg-slate-100"
|
||||
title="아래로"
|
||||
>
|
||||
↓
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
data-testid={`slot-hide-${zoneId}-${key}`}
|
||||
aria-pressed={hidden.has(key)}
|
||||
onClick={() => {
|
||||
const nh = new Set(hidden);
|
||||
if (nh.has(key)) nh.delete(key);
|
||||
else nh.add(key);
|
||||
emit(effective, nh);
|
||||
}}
|
||||
className="px-1 rounded border border-slate-200 hover:bg-slate-100"
|
||||
title={hidden.has(key) ? "표시" : "숨김"}
|
||||
>
|
||||
{hidden.has(key) ? "🚫" : "👁"}
|
||||
</button>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -75,6 +75,37 @@ export type ImageOverridesOverride = Record<string, ImageOverride>;
|
||||
*/
|
||||
export type ManualSectionAssignmentOverride = boolean;
|
||||
|
||||
/**
|
||||
* IMP-56 #90 u10 — Step-22 text-edit persist axis. Keyed by `zone_id`; the
|
||||
* inner mapping is `text_path` (= `{slot_key}.{line_index}`) → line value.
|
||||
* The `text_path` stamp is emitted by `src/text_path_stamper.py` (u8) and
|
||||
* applied at Step 13 (u9); the value is consumed by `text_override_resolver`
|
||||
* (u4) and applied at Step 12 (u5). Stale paths (frame swap / layout
|
||||
* regression between sessions) are tolerated by the backend resolver as
|
||||
* `skipped`, NOT raised — so the on-disk axis is forward-compat with layout
|
||||
* and frame churn. Mirrors Python `KNOWN_AXES` entry (u1) and Vite
|
||||
* `KNOWN_USER_OVERRIDES_AXES` allowlist entry (u3).
|
||||
*/
|
||||
export type TextOverridesPerZone = Record<string, string>;
|
||||
export type TextOverridesOverride = Record<string, TextOverridesPerZone>;
|
||||
|
||||
/**
|
||||
* IMP-56 #90 u10 — Step-22 structure-edit persist axis. Keyed by `zone_id`;
|
||||
* the inner mapping is SCOPE-LOCKED to `{slot_order, hidden_slots}` — slot
|
||||
* reorder + slot hide only. Frame swap stays on the existing `frames` axis;
|
||||
* the `structure_override_resolver` (u6) rejects frame-swap-shaped inner
|
||||
* keys at the validate gate so Phase Z's no-AI-HTML-structure invariant
|
||||
* holds across this persisted axis too. Per-slot `list[str]` line content
|
||||
* is NEVER mutated by the u7 Step-12 apply — that is the `text_overrides`
|
||||
* axis above. Mirrors Python `KNOWN_AXES` entry (u2) and Vite
|
||||
* `KNOWN_USER_OVERRIDES_AXES` allowlist entry (u3).
|
||||
*/
|
||||
export type StructureOverridePerZone = {
|
||||
slot_order?: string[];
|
||||
hidden_slots?: string[];
|
||||
};
|
||||
export type StructureOverridesOverride = Record<string, StructureOverridePerZone>;
|
||||
|
||||
/** Full on-disk schema. All axes optional — file may carry any subset. */
|
||||
export interface UserOverrides {
|
||||
layout: string;
|
||||
@@ -83,6 +114,8 @@ export interface UserOverrides {
|
||||
zone_sections: ZoneSectionsOverride;
|
||||
image_overrides: ImageOverridesOverride;
|
||||
manual_section_assignment: ManualSectionAssignmentOverride;
|
||||
text_overrides: TextOverridesOverride;
|
||||
structure_overrides: StructureOverridesOverride;
|
||||
}
|
||||
|
||||
/** Partial-mutation payload. `null` is the explicit clear sentinel (mirrors u4). */
|
||||
|
||||
@@ -227,6 +227,15 @@ export interface UserSelection {
|
||||
// (Front/vite.config.ts), and `ManualSectionAssignmentOverride`
|
||||
// (services/userOverridesApi.ts).
|
||||
manual_section_assignment: boolean;
|
||||
// IMP-56 #90 u10/u15 — Step-22 text + structure persist axes. Mirrors
|
||||
// services/userOverridesApi.ts (`TextOverridesOverride` /
|
||||
// `StructureOverridesOverride`). `text_overrides[zoneId][textPath] = value`
|
||||
// is fed by SlideCanvas u13 focusout capture + Home u15 autosave;
|
||||
// `structure_overrides[zoneId] = {slot_order, hidden_slots}` is fed by
|
||||
// u14 overlay + u15 autosave. Both seeded `{}` in createInitialUserSelection
|
||||
// and restored on reopen via applyPersistedNonFrameOverrides.
|
||||
text_overrides: Record<string, Record<string, string>>;
|
||||
structure_overrides: Record<string, { slot_order?: string[]; hidden_slots?: string[] }>;
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
import type { UserSelection, SlidePlan, Zone, InternalRegion, LayoutPresetId } from "../types/designAgent";
|
||||
import type { UserOverrides } from "../services/userOverridesApi";
|
||||
import type {
|
||||
StructureOverridePerZone,
|
||||
StructureOverridesOverride,
|
||||
TextOverridesOverride,
|
||||
TextOverridesPerZone,
|
||||
UserOverrides,
|
||||
} from "../services/userOverridesApi";
|
||||
import { computeZonePositions } from "../services/designAgentApi";
|
||||
|
||||
// ─── IMP-52 u6 — restore-on-reopen helpers (pure, exported for testing) ────
|
||||
@@ -99,9 +105,74 @@ export function applyPersistedNonFrameOverrides(
|
||||
if (typeof persisted.manual_section_assignment === "boolean") {
|
||||
next.manual_section_assignment = persisted.manual_section_assignment;
|
||||
}
|
||||
// IMP-56 (#90) u15 — layer the two Step-22 persist axes through the
|
||||
// u10 extract helpers; their `_isPlainObject` + dedupe gates already
|
||||
// sanitize foreign / hand-edited payloads, so reopen never poisons
|
||||
// memory with non-string values or non-list slot_order entries.
|
||||
next.text_overrides = extractPersistedTextOverrides(persisted);
|
||||
next.structure_overrides = extractPersistedStructureOverrides(persisted);
|
||||
return { ...selection, overrides: next };
|
||||
}
|
||||
|
||||
// ─── IMP-56 #90 u10 — typed extract helpers for the two new persist axes ───
|
||||
// Pure helpers that defensively sanitize Step-22 text_overrides and
|
||||
// structure_overrides payloads off a `Partial<UserOverrides>` (typed by u10's
|
||||
// userOverridesApi extension). They mirror the backend validation gates
|
||||
// (`text_override_resolver` u4 / `structure_override_resolver` u6) on the
|
||||
// frontend so a hand-edited or schema-drift payload cannot poison memory.
|
||||
// Layering onto `UserSelection.overrides` arrives in u14~u16; until then
|
||||
// capture / autosave / restore wiring units consume these as typed.
|
||||
|
||||
function _isPlainObject(x: unknown): x is Record<string, unknown> {
|
||||
return !!x && typeof x === "object" && !Array.isArray(x);
|
||||
}
|
||||
|
||||
function _dedupeStringList(arr: unknown): string[] {
|
||||
if (!Array.isArray(arr)) return [];
|
||||
const seen = new Set<string>();
|
||||
const out: string[] = [];
|
||||
for (const k of arr) {
|
||||
if (typeof k === "string" && k.length > 0 && !seen.has(k)) {
|
||||
seen.add(k);
|
||||
out.push(k);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export function extractPersistedTextOverrides(
|
||||
persisted: Partial<UserOverrides> | null | undefined,
|
||||
): TextOverridesOverride {
|
||||
const raw = persisted?.text_overrides;
|
||||
if (!_isPlainObject(raw)) return {};
|
||||
const out: TextOverridesOverride = {};
|
||||
for (const [zoneId, perZone] of Object.entries(raw)) {
|
||||
if (!zoneId || !_isPlainObject(perZone)) continue;
|
||||
const safe: TextOverridesPerZone = {};
|
||||
for (const [textPath, value] of Object.entries(perZone)) {
|
||||
if (textPath && typeof value === "string") safe[textPath] = value;
|
||||
}
|
||||
out[zoneId] = safe;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export function extractPersistedStructureOverrides(
|
||||
persisted: Partial<UserOverrides> | null | undefined,
|
||||
): StructureOverridesOverride {
|
||||
const raw = persisted?.structure_overrides;
|
||||
if (!_isPlainObject(raw)) return {};
|
||||
const out: StructureOverridesOverride = {};
|
||||
for (const [zoneId, perZone] of Object.entries(raw)) {
|
||||
if (!zoneId || !_isPlainObject(perZone)) continue;
|
||||
const safe: StructureOverridePerZone = {};
|
||||
if (Array.isArray(perZone.slot_order)) safe.slot_order = _dedupeStringList(perZone.slot_order);
|
||||
if (Array.isArray(perZone.hidden_slots)) safe.hidden_slots = _dedupeStringList(perZone.hidden_slots);
|
||||
out[zoneId] = safe;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Remap persisted frames (`unit_id` → template_id) to the in-memory
|
||||
* `zone_frames` (region.id → template_id) using the freshly built
|
||||
@@ -174,6 +245,12 @@ export function createInitialUserSelection(slidePlan?: SlidePlan | null): UserSe
|
||||
// here via `saveImageOverride` (SlideCanvas drag/resize handler) and
|
||||
// are seeded on reopen via `applyPersistedNonFrameOverrides`.
|
||||
image_overrides: {},
|
||||
// IMP-56 (#90) u15 — Step-22 axes seeded empty. Entries land here
|
||||
// via `saveTextOverride` (u13 focusout capture) and
|
||||
// `saveStructureOverride` (u14 overlay) and are restored on reopen
|
||||
// via `applyPersistedNonFrameOverrides`.
|
||||
text_overrides: {},
|
||||
structure_overrides: {},
|
||||
// IMP-55 (#93) u3 — bool intent marker seeded `false` so a fresh
|
||||
// MDX open (no persisted file, or persisted file with axis absent)
|
||||
// never forwards `overrides.zoneSections` to the backend. The marker
|
||||
@@ -229,6 +306,60 @@ export function saveImageOverride(
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* IMP-56 (#90) u15 — record a single text-line capture (zone_id, text_path,
|
||||
* value) onto the in-memory selection's `text_overrides` axis. Mirrors
|
||||
* `saveImageOverride` (pure / immutable). u13's focusout capture emits one
|
||||
* entry per finished edit; Home u15's handler funnels each emit through this
|
||||
* helper before scheduling the debounced PUT (`saveUserOverrides` 300ms).
|
||||
*/
|
||||
export function saveTextOverride(
|
||||
selection: UserSelection,
|
||||
zoneId: string,
|
||||
textPath: string,
|
||||
value: string,
|
||||
): UserSelection {
|
||||
const prevZone = selection.overrides.text_overrides[zoneId] ?? {};
|
||||
return {
|
||||
...selection,
|
||||
overrides: {
|
||||
...selection.overrides,
|
||||
text_overrides: {
|
||||
...selection.overrides.text_overrides,
|
||||
[zoneId]: { ...prevZone, [textPath]: value },
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* IMP-56 (#90) u15 — record a single structure capture (zone_id ↦
|
||||
* {slot_order, hidden_slots}) onto the in-memory selection's
|
||||
* `structure_overrides` axis. Scope-locked to slot reorder + hide (frame
|
||||
* swap stays on the `frames` axis). u14's overlay emits one entry per
|
||||
* user mutation; Home u15's handler funnels each emit through this
|
||||
* helper before scheduling the debounced PUT.
|
||||
*/
|
||||
export function saveStructureOverride(
|
||||
selection: UserSelection,
|
||||
zoneId: string,
|
||||
perZone: StructureOverridePerZone,
|
||||
): UserSelection {
|
||||
return {
|
||||
...selection,
|
||||
overrides: {
|
||||
...selection.overrides,
|
||||
structure_overrides: {
|
||||
...selection.overrides.structure_overrides,
|
||||
[zoneId]: {
|
||||
...(perZone.slot_order !== undefined && { slot_order: [...perZone.slot_order] }),
|
||||
...(perZone.hidden_slots !== undefined && { hidden_slots: [...perZone.hidden_slots] }),
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function saveZoneSizes(selection: UserSelection, groupId: string, sizes: number[]): UserSelection {
|
||||
return {
|
||||
...selection,
|
||||
|
||||
Reference in New Issue
Block a user