/* ────────────────────────────────────────────────────────────
   slidecanvas.jsx — PPT 중앙 자유배치 오브젝트 캔버스 편집기 (2A: 텍스트·도형)
   슬라이드의 objects 배열({id,type,x,y,w,h(0~1 분수·16:9),z,rotation?,text?,style?})을
   16:9 흰 슬라이드 위에 절대→비례로 렌더하고, 삽입/선택/이동(자석 스냅)/리사이즈/
   z-order/삭제/인라인 텍스트 편집/시드 를 제공한다. 변경은 디바운스로 patchSlide.
   토큰(tokens.css)만·해요체·플랫·React.createElement·window 노출.
   이미지/AI이미지는 2B — 여기선 text/line/rect/ellipse/triangle 만.
   ──────────────────────────────────────────────────────────── */

const ch = React.createElement;

/* 16:9 기준 — 분수 좌표를 px로 환산할 때 쓰는 캔버스 종횡비 */
const SC_RATIO = 9 / 16;

/* 토큰 스와치(채움/선/글자색 픽커) — 미리 정의된 토큰색 + 기본 */
const SC_SWATCHES = [
  { key: "primary", label: "파랑", value: "#0066ff" },
  { key: "violet", label: "보라", value: "#9747ff" },
  { key: "cyan", label: "청록", value: "#0098b2" },
  { key: "positive", label: "초록", value: "#00bf40" },
  { key: "caution", label: "주황", value: "#ff9b00" },
  { key: "negative", label: "빨강", value: "#ff4242" },
  { key: "ink", label: "검정", value: "#171719" },
  { key: "gray", label: "회색", value: "#70737c" },
  { key: "white", label: "흰색", value: "#ffffff" },
  { key: "none", label: "없음", value: "transparent" },
];

/* 기본 삽입 크기(분수) — 캔버스 중앙 배치용 */
const SC_DEFAULTS = {
  text: { w: 0.4, h: 0.12 },
  rect: { w: 0.26, h: 0.18 },
  ellipse: { w: 0.22, h: 0.22 },
  triangle: { w: 0.24, h: 0.2 },
  line: { w: 0.3, h: 0.001 },
  image: { w: 0.4, h: 0.3 },
};

/* 이미지 기본 삽입 폭(분수) — 원본 비율로 높이 산출(캔버스 16:9 기준) */
const SC_IMG_W = 0.42;

/* 최소 크기(분수) */
const SC_MIN_W = 0.03;
const SC_MIN_H = 0.02;

/* 스냅 임계값(분수) — 이 거리 안이면 가이드선에 붙음 */
const SC_SNAP = 0.012;

/* 고유 id 생성 */
function scNewId() {
  return "o" + Date.now().toString(36) + Math.random().toString(36).slice(2, 6);
}

/* 안전한 objects 배열 */
function scList(objects) {
  return Array.isArray(objects) ? objects : [];
}

/* z 오름차순 정렬(렌더 순서) */
function scSorted(objects) {
  return scList(objects).slice().sort((a, b) => (a.z || 0) - (b.z || 0));
}

/* clamp */
function scClamp(v, lo, hi) {
  return Math.max(lo, Math.min(hi, v));
}

/* fontSize(분수 0~1 또는 px) → px (캔버스 픽셀 높이 기준) */
function scFontPx(style, canvasH) {
  const fs = style && style.fontSize;
  if (typeof fs === "number") {
    return fs <= 1 ? Math.max(8, fs * canvasH) : fs; // 0~1 분수면 높이 비례, 아니면 px
  }
  return Math.max(11, 0.05 * canvasH); // 기본 ≈ 5% 높이
}

/* ── 단일 오브젝트 렌더 ──
   캔버스 px 박스(left/top/width/height)로 절대배치. text=편집가능 박스, 도형=svg/div. */
function ScObject({ obj, px, selected, editing, onStartDrag, onSelect, onStartEdit, onCommitText, canvasH }) {
  const style = obj.style || {};
  const box = {
    position: "absolute",
    left: px.left, top: px.top, width: px.width, height: px.height,
    boxSizing: "border-box",
    cursor: editing ? "text" : "move",
    transform: obj.rotation ? `rotate(${obj.rotation}deg)` : undefined,
    userSelect: editing ? "text" : "none",
  };

  const onDown = (e) => {
    if (editing) return;
    e.stopPropagation();
    onSelect(obj.id);
    onStartDrag(e, obj);
  };

  if (obj.type === "text") {
    const color = style.color || "var(--label-normal)";
    const fontPx = scFontPx(style, canvasH);
    const align = style.align || "left";
    const inner = {
      width: "100%", height: "100%", display: "flex",
      alignItems: style.valign === "top" ? "flex-start" : (style.valign === "bottom" ? "flex-end" : "center"),
      justifyContent: align === "center" ? "center" : (align === "right" ? "flex-end" : "flex-start"),
      padding: "2px 4px", overflow: "hidden",
    };
    const textStyle = {
      color, fontSize: fontPx, fontWeight: style.bold ? 800 : (style.weight || 600),
      fontStyle: style.italic ? "italic" : "normal", textAlign: align,
      lineHeight: 1.25, width: "100%", whiteSpace: "pre-wrap", wordBreak: "break-word",
      fontFamily: "var(--font)", background: style.fill && style.fill !== "transparent" ? style.fill : "transparent",
    };
    return ch("div", {
      "data-objid": obj.id, style: box, onMouseDown: onDown,
      onDoubleClick: (e) => { e.stopPropagation(); onStartEdit(obj.id); },
    },
      editing
        ? ch("textarea", {
            autoFocus: true,
            defaultValue: obj.text || "",
            onMouseDown: (e) => e.stopPropagation(),
            onBlur: (e) => onCommitText(obj.id, e.target.value),
            onKeyDown: (e) => {
              if (e.key === "Escape") { e.preventDefault(); e.target.blur(); }
            },
            style: {
              width: "100%", height: "100%", resize: "none", border: "none", outline: "none",
              background: "rgba(255,255,255,0.85)", padding: "2px 4px", boxSizing: "border-box",
              color, fontSize: fontPx, fontWeight: style.bold ? 800 : 600,
              fontStyle: style.italic ? "italic" : "normal", textAlign: align,
              lineHeight: 1.25, fontFamily: "var(--font)",
            },
          })
        : ch("div", { style: inner },
            ch("span", { style: textStyle }, (obj.text && obj.text.length) ? obj.text : ch("span", { style: { color: "var(--label-assist)" } }, "텍스트"))));
  }

  // 이미지 — <img> 로 배치. object-fit(contain/cover), 깨진 src는 onError 플레이스홀더.
  if (obj.type === "image") {
    const fit = style.fit === "cover" ? "cover" : "contain";
    const radius = typeof style.radius === "number" ? style.radius : 0;
    return ch("div", { "data-objid": obj.id, style: box, onMouseDown: onDown },
      obj.src
        ? ch("img", {
            src: obj.src, alt: "", draggable: false,
            onError: (e) => {
              // 깨진 이미지 — 플레이스홀더로 대체(백지/깨짐 아이콘 방어)
              const el = e.currentTarget;
              if (el && el.dataset.fallback !== "1") {
                el.dataset.fallback = "1";
                el.style.display = "none";
                const ph = el.parentNode && el.parentNode.querySelector("[data-imgph]");
                if (ph) ph.style.display = "flex";
              }
            },
            style: {
              width: "100%", height: "100%", objectFit: fit, display: "block",
              borderRadius: radius, pointerEvents: "none", userSelect: "none", background: "var(--bg-neutral)",
            },
          })
        : null,
      // 플레이스홀더(빈 src 또는 onError 시 표시)
      ch("div", {
        "data-imgph": "1",
        style: {
          position: "absolute", inset: 0, display: obj.src ? "none" : "flex",
          flexDirection: "column", alignItems: "center", justifyContent: "center", gap: 6,
          background: "var(--bg-neutral)", border: "1px dashed var(--border)", borderRadius: radius,
          color: "var(--label-assist)", pointerEvents: "none",
        },
      },
        ch("svg", { width: 26, height: 26, viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", strokeWidth: 1.6, strokeLinecap: "round", strokeLinejoin: "round" },
          ch("rect", { x: 3, y: 3, width: 18, height: 18, rx: 2 }), ch("circle", { cx: 8.5, cy: 8.5, r: 1.6 }), ch("path", { d: "M21 15l-5-5L5 21" })),
        ch("span", { style: { fontSize: 11, fontWeight: 600 } }, "이미지를 불러올 수 없어요")));
  }

  // 도형 — SVG 로 그려 fill/stroke/strokeWidth/radius 반영
  const fill = style.fill !== undefined ? style.fill : "var(--primary-light)";
  const stroke = style.stroke !== undefined ? style.stroke : "var(--primary)";
  const sw = typeof style.strokeWidth === "number" ? style.strokeWidth : 2;

  let shape = null;
  if (obj.type === "rect") {
    const r = typeof style.radius === "number" ? style.radius : 0;
    shape = ch("rect", { x: sw / 2, y: sw / 2, width: Math.max(0, px.width - sw), height: Math.max(0, px.height - sw), rx: r, ry: r, fill, stroke, strokeWidth: sw });
  } else if (obj.type === "ellipse") {
    shape = ch("ellipse", { cx: px.width / 2, cy: px.height / 2, rx: Math.max(0, (px.width - sw) / 2), ry: Math.max(0, (px.height - sw) / 2), fill, stroke, strokeWidth: sw });
  } else if (obj.type === "triangle") {
    const pts = `${px.width / 2},${sw / 2} ${px.width - sw / 2},${px.height - sw / 2} ${sw / 2},${px.height - sw / 2}`;
    shape = ch("polygon", { points: pts, fill, stroke, strokeWidth: sw, strokeLinejoin: "round" });
  } else if (obj.type === "line") {
    const lineColor = style.stroke !== undefined ? style.stroke : "var(--label-normal)";
    shape = ch("line", { x1: 0, y1: px.height / 2, x2: px.width, y2: px.height / 2, stroke: lineColor, strokeWidth: Math.max(1, sw), strokeLinecap: "round" });
  }

  return ch("div", { "data-objid": obj.id, style: box, onMouseDown: onDown },
    ch("svg", { width: "100%", height: "100%", style: { display: "block", overflow: "visible" }, preserveAspectRatio: "none" }, shape));
}

/* ── 선택 오버레이(테두리 + 8핸들) ── */
const SC_HANDLES = [
  { k: "nw", x: 0, y: 0, cur: "nwse-resize" }, { k: "n", x: 0.5, y: 0, cur: "ns-resize" }, { k: "ne", x: 1, y: 0, cur: "nesw-resize" },
  { k: "w", x: 0, y: 0.5, cur: "ew-resize" }, { k: "e", x: 1, y: 0.5, cur: "ew-resize" },
  { k: "sw", x: 0, y: 1, cur: "nesw-resize" }, { k: "s", x: 0.5, y: 1, cur: "ns-resize" }, { k: "se", x: 1, y: 1, cur: "nwse-resize" },
];

function ScSelection({ px, onStartResize }) {
  return ch("div", {
    style: { position: "absolute", left: px.left, top: px.top, width: px.width, height: px.height, border: "1.5px solid var(--primary)", boxSizing: "border-box", pointerEvents: "none", zIndex: 9000 },
  },
    SC_HANDLES.map((hd) => ch("div", {
      key: hd.k,
      onMouseDown: (e) => { e.stopPropagation(); e.preventDefault(); onStartResize(e, hd.k); },
      style: {
        position: "absolute", width: 10, height: 10, borderRadius: 2, background: "#fff",
        border: "1.5px solid var(--primary)", boxSizing: "border-box",
        left: `calc(${hd.x * 100}% - 5px)`, top: `calc(${hd.y * 100}% - 5px)`,
        cursor: hd.cur, pointerEvents: "auto", boxShadow: "var(--sh-1)",
      },
    })));
}

/* ── 정렬 가이드선(스냅 시) ── */
function ScGuides({ guides, w, h }) {
  return ch(React.Fragment, null,
    guides.map((g, i) => g.axis === "x"
      ? ch("div", { key: "gx" + i, style: { position: "absolute", left: g.pos * w, top: 0, width: 1, height: h, background: "var(--negative)", pointerEvents: "none", zIndex: 9500, opacity: 0.85 } })
      : ch("div", { key: "gy" + i, style: { position: "absolute", top: g.pos * h, left: 0, height: 1, width: w, background: "var(--negative)", pointerEvents: "none", zIndex: 9500, opacity: 0.85 } })));
}

/* 툴바 버튼 공용 스타일/hover */
const scToolBtnStyle = {
  display: "inline-flex", alignItems: "center", gap: 6, height: 32, padding: "0 12px",
  borderRadius: "var(--r-sm)", border: "1px solid var(--border)", background: "var(--bg-normal)",
  color: "var(--label-neutral)", fontSize: 12.5, fontWeight: 700, cursor: "pointer", transition: "all var(--t-fast)",
};
function scToolHoverOn(e) { e.currentTarget.style.borderColor = "var(--primary)"; e.currentTarget.style.color = "var(--primary)"; }
function scToolHoverOff(e) { e.currentTarget.style.borderColor = "var(--border)"; e.currentTarget.style.color = "var(--label-neutral)"; }

/* ── 삽입 툴바 ──
   기본 도형/텍스트는 onAdd(type). 이미지/AI이미지는 별도 흐름:
   - onPickImage(): 숨김 file input 클릭 → 업로드(상위에서 처리)
   - onAiImage(): AI 이미지 프롬프트 모달 열기(상위)
   - imgBusy: 이미지 업로드 중(스피너·비활성) */
function ScToolbar({ onAdd, onPickImage, onAiImage, imgBusy }) {
  const tools = [
    { type: "text", label: "텍스트", icon: ch("path", { d: "M4 6h16M4 6v-1M12 6v13M9 19h6" }) },
    { type: "line", label: "라인", icon: ch("path", { d: "M4 18 20 6" }) },
    { type: "rect", label: "사각형", icon: ch("rect", { x: 4, y: 6, width: 16, height: 12, rx: 1 }) },
    { type: "ellipse", label: "원형", icon: ch("ellipse", { cx: 12, cy: 12, rx: 8, ry: 6 }) },
    { type: "triangle", label: "세모", icon: ch("path", { d: "M12 5 20 19H4z" }) },
  ];
  return ch("div", { style: { display: "flex", alignItems: "center", gap: 6, flexWrap: "wrap" } },
    tools.map((t) => ch("button", {
      key: t.type,
      onClick: () => onAdd(t.type),
      style: scToolBtnStyle,
      onMouseEnter: scToolHoverOn,
      onMouseLeave: scToolHoverOff,
    },
      ch("svg", { width: 15, height: 15, viewBox: "0 0 24 24", fill: t.type === "text" || t.type === "line" ? "none" : "currentColor", stroke: "currentColor", strokeWidth: t.type === "text" || t.type === "line" ? 2 : 1.4, strokeLinecap: "round", strokeLinejoin: "round", style: { opacity: t.type === "text" || t.type === "line" ? 1 : 0.85 } }, t.icon),
      t.label)),

    /* 구분선 */
    ch("span", { style: { width: 1, height: 18, background: "var(--line-alt)", margin: "0 2px" } }),

    /* 이미지 업로드 */
    ch("button", {
      onClick: () => { if (!imgBusy && onPickImage) onPickImage(); },
      disabled: imgBusy,
      title: "이미지 파일을 올려요",
      style: { ...scToolBtnStyle, opacity: imgBusy ? 0.6 : 1, cursor: imgBusy ? "default" : "pointer" },
      onMouseEnter: imgBusy ? undefined : scToolHoverOn,
      onMouseLeave: imgBusy ? undefined : scToolHoverOff,
    },
      imgBusy
        ? ch(Spinner, { s: 14 })
        : ch("svg", { width: 15, height: 15, viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", strokeWidth: 1.6, strokeLinecap: "round", strokeLinejoin: "round" },
            ch("rect", { x: 3, y: 3, width: 18, height: 18, rx: 2 }), ch("circle", { cx: 8.5, cy: 8.5, r: 1.6 }), ch("path", { d: "M21 15l-5-5L5 21" })),
      imgBusy ? "올리는 중" : "이미지"),

    /* AI 이미지 */
    ch("button", {
      onClick: () => { if (onAiImage) onAiImage(); },
      title: "AI로 이미지를 만들어요",
      style: scToolBtnStyle,
      onMouseEnter: scToolHoverOn,
      onMouseLeave: scToolHoverOff,
    },
      ch("svg", { width: 15, height: 15, viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", strokeWidth: 2, strokeLinecap: "round", strokeLinejoin: "round" },
        ch("path", { d: "M12 3v3M12 18v3M5.6 5.6l2.1 2.1M16.3 16.3l2.1 2.1M3 12h3M18 12h3M5.6 18.4l2.1-2.1M16.3 7.7l2.1-2.1" })),
      "AI이미지"));
}

/* ── AI 이미지 프롬프트 모달 ──
   props: onClose, onGenerate(prompt)=>Promise. 생성 중 스피너·버튼 비활성·닫기 잠금.
   실패 시 백엔드 해요체 error 를 그대로 노출(백지 금지). ModalShell(window) 재사용. */
function ScAiImageModal({ onClose, onGenerate }) {
  const [prompt, setPrompt] = useState("");
  const [busy, setBusy] = useState(false);
  const [err, setErr] = useState("");

  async function submit() {
    const p = prompt.trim();
    if (!p || busy) return;
    setBusy(true); setErr("");
    try {
      await onGenerate(p);   // 성공 시 상위에서 모달 닫음
    } catch (ex) {
      setErr((ex && ex.message) || "이미지를 만들지 못했어요. 잠시 후 다시 시도해 주세요.");
      setBusy(false);
    }
  }

  return ch(ModalShell, {
    title: "AI 이미지 만들기",
    desc: "그리고 싶은 이미지를 글로 설명하면 AI가 만들어요. 캔버스 중앙에 추가돼요.",
    onClose: busy ? undefined : onClose, busy, maxWidth: 480,
  },
    ch("div", { style: { display: "flex", flexDirection: "column", gap: 7 } },
      ch("span", { className: "t-label-2", style: { color: "var(--label-alt)", fontWeight: 700 } }, "이미지 설명"),
      ch("textarea", {
        value: prompt,
        autoFocus: true,
        disabled: busy,
        placeholder: "그리고 싶은 이미지를 설명해 주세요",
        onChange: (e) => setPrompt(e.target.value),
        rows: 4,
        style: { width: "100%", minHeight: 92, resize: "vertical", padding: "10px 12px", borderRadius: "var(--r-sm)", border: "1px solid var(--border)", background: "var(--bg-normal)", fontSize: 13.5, lineHeight: 1.55, color: "var(--label-normal)", fontFamily: "var(--font)", outline: "none" },
      })),

    busy
      ? ch("div", { className: "t-caption-1", style: { color: "var(--label-alt)", background: "var(--bg-neutral)", border: "1px solid var(--line-alt)", borderRadius: "var(--r-sm)", padding: "10px 12px", display: "flex", alignItems: "center", gap: 8 } },
          ch(Spinner, { s: 14 }), "이미지를 만드는 중이에요. 수십 초 걸릴 수 있어요.")
      : null,

    err
      ? ch("div", { style: { display: "flex", gap: 7, alignItems: "flex-start", padding: "10px 12px", borderRadius: "var(--r-sm)", background: "var(--negative-bg)", border: "1px solid var(--negative)" } },
          ch("svg", { width: 15, height: 15, viewBox: "0 0 24 24", fill: "none", stroke: "var(--negative)", strokeWidth: 2.2, strokeLinecap: "round", strokeLinejoin: "round", style: { flexShrink: 0, marginTop: 1 } }, ch("circle", { cx: 12, cy: 12, r: 10 }), ch("path", { d: "M12 8v4M12 16h.01" })),
          ch("p", { className: "t-label-2", style: { color: "var(--label-neutral)", margin: 0, lineHeight: 1.6, whiteSpace: "pre-wrap" } }, err))
      : null,

    ch("div", { style: { display: "flex", gap: 8, justifyContent: "flex-end" } },
      ch(Button, { kind: "ghost", size: "md", onClick: onClose, disabled: busy }, "닫기"),
      ch(Button, { kind: "primary", size: "md", onClick: submit, disabled: busy || !prompt.trim() },
        busy
          ? ch(React.Fragment, null, ch(Spinner, { s: 16, color: "#fff" }), "만드는 중…")
          : "이미지 생성")));
}

/* ── z-order 버튼 묶음 ── */
function ScZBar({ onZ }) {
  const items = [
    { k: "front", label: "맨앞으로" },
    { k: "up", label: "앞으로" },
    { k: "down", label: "뒤로" },
    { k: "back", label: "맨뒤로" },
  ];
  return ch("div", { style: { display: "inline-flex", borderRadius: "var(--r-sm)", border: "1px solid var(--border)", overflow: "hidden" } },
    items.map((it, i) => ch("button", {
      key: it.k, onClick: () => onZ(it.k),
      style: {
        height: 30, padding: "0 10px", border: "none", borderLeft: i ? "1px solid var(--border)" : "none",
        background: "var(--bg-normal)", color: "var(--label-neutral)", fontSize: 11.5, fontWeight: 700, cursor: "pointer",
      },
      onMouseEnter: (e) => { e.currentTarget.style.background = "var(--bg-alt)"; },
      onMouseLeave: (e) => { e.currentTarget.style.background = "var(--bg-normal)"; },
    }, it.label)));
}

/* ── 레이아웃 인지 시드 ──
   슬라이드의 layout_suggestion 에 맞춰 오브젝트(텍스트·도형)를 0~1 좌표로 배치한다.
   slidepreview.jsx 의 14종 시각 구조를 캔버스 좌표로 옮긴 것이라 레이아웃이 드러난다.
   생성된 오브젝트는 2A 로직대로 이후 자유 이동/편집 가능. (픽셀 완벽 불필요)

   도메인 고정색(slidepreview SP_CASE/SP_COMPARE/SP_DIAG 차용 — 캔버스는 토큰 변수가
   아닌 실제 hex 가 필요해서 동일 hex 사용). */
const SEED_COLORS = {
  ink: "#171719", sub: "#37383c", mute: "#70737c",
  primary: "#0066ff", primaryLt: "#e8f0ff",
  violet: "#9747ff", violetLt: "#f3e9ff",
  cyan: "#0098b2", cyanLt: "#e2f5f8",
  positive: "#00bf40", positiveLt: "#e4f8ec",
  caution: "#ff9b00", cautionLt: "#fff3e0",
  negative: "#ff4242", negativeLt: "#ffe8e8",
  case: ["#f59e0b", "#ef4444", "#10b981"],   // 장면/원인/예방
  compare: ["#0066ff", "#f59e0b"],           // 좌/우
  panel: "#f4f5f7", line: "#dbdcdf", white: "#ffffff",
};

/* content 안전 배열(빈 항목 제거) */
function seedContent(slide) {
  return (slide && Array.isArray(slide.content))
    ? slide.content.filter((x) => x != null && String(x).trim() !== "").map(String)
    : [];
}

/* 텍스트 오브젝트 헬퍼 */
function tObj(z, x, y, w, h, text, style) {
  return { id: scNewId(), type: "text", x, y, w, h, z, text: text == null ? "" : String(text), style: style || {} };
}
/* 도형 오브젝트 헬퍼(rect/ellipse/triangle/line) */
function sObj(z, type, x, y, w, h, style) {
  return { id: scNewId(), type, x, y, w, h, z, style: style || {} };
}

/* 상단 제목 텍스트(대부분 레이아웃 공통) */
function seedTitleBar(z, title, color) {
  return [
    sObj(z, "rect", 0.06, 0.07, 0.008, 0.1, { fill: color || SEED_COLORS.primary, stroke: "transparent", strokeWidth: 0 }),
    tObj(z + 1, 0.085, 0.06, 0.84, 0.12, title || "제목", { fontSize: 0.07, bold: true, align: "left", color: SEED_COLORS.ink }),
  ];
}

/* ── 레이아웃별 시드 빌더 ── (각자 objects 배열 반환, z 는 0부터 누적) */
const SEED_BUILDERS = {
  TITLE(slide) {
    const sub = seedContent(slide);
    const objs = [
      tObj(0, 0.1, 0.34, 0.8, 0.2, slide.title || "제목", { fontSize: 0.14, bold: true, align: "center", color: SEED_COLORS.ink, valign: "center" }),
    ];
    if (sub.length) {
      objs.push(tObj(1, 0.18, 0.58, 0.64, 0.1, sub.slice(0, 3).join("  ·  "), { fontSize: 0.05, align: "center", color: SEED_COLORS.primary, valign: "center" }));
    }
    return objs;
  },

  EMPHASIS(slide) {
    const list = seedContent(slide);
    const headline = list[0] || slide.title || "강조 문장";
    const badges = list.slice(1, 4);
    const objs = [
      sObj(0, "rect", 0.08, 0.28, 0.84, 0.34, { fill: SEED_COLORS.primaryLt, stroke: SEED_COLORS.primary, strokeWidth: 2, radius: 14 }),
      tObj(1, 0.12, 0.32, 0.76, 0.26, headline, { fontSize: 0.085, bold: true, align: "center", color: SEED_COLORS.primary, valign: "center" }),
    ];
    let z = 2;
    const bw = 0.2, gap = 0.02;
    const totalW = badges.length * bw + (badges.length - 1) * gap;
    let bx = 0.5 - totalW / 2;
    badges.forEach((b) => {
      objs.push(sObj(z++, "rect", bx, 0.7, bw, 0.1, { fill: SEED_COLORS.primary, stroke: "transparent", strokeWidth: 0, radius: 6 }));
      objs.push(tObj(z++, bx, 0.7, bw, 0.1, b, { fontSize: 0.04, bold: true, align: "center", color: SEED_COLORS.white, valign: "center" }));
      bx += bw + gap;
    });
    return objs;
  },

  CONCEPT(slide) {
    const list = seedContent(slide);
    const def = list[0] || "개념 정의";
    const examples = list.slice(1, 4);
    const objs = seedTitleBar(0, slide.title, SEED_COLORS.primary);
    let z = 2;
    objs.push(sObj(z++, "rect", 0.06, 0.24, 0.88, 0.26, { fill: SEED_COLORS.ink, stroke: "transparent", strokeWidth: 0, radius: 10 }));
    objs.push(tObj(z++, 0.09, 0.255, 0.2, 0.06, "정의", { fontSize: 0.035, bold: true, align: "left", color: "#aeb0b6" }));
    objs.push(tObj(z++, 0.09, 0.31, 0.82, 0.17, def, { fontSize: 0.05, bold: true, align: "left", color: SEED_COLORS.white, valign: "top" }));
    if (examples.length) {
      objs.push(sObj(z++, "rect", 0.06, 0.53, 0.88, 0.4, { fill: SEED_COLORS.panel, stroke: SEED_COLORS.line, strokeWidth: 1, radius: 10 }));
      objs.push(tObj(z++, 0.09, 0.545, 0.3, 0.06, "예시", { fontSize: 0.035, bold: true, align: "left", color: "#70737c" }));
      examples.forEach((e, i) => {
        objs.push(tObj(z++, 0.09, 0.6 + i * 0.09, 0.82, 0.08, "· " + e, { fontSize: 0.042, align: "left", color: SEED_COLORS.sub, valign: "center" }));
      });
    }
    return objs;
  },

  STEPS(slide) {
    const steps = seedContent(slide).slice(0, 5);
    const numbered = (slide.steps_style || "arrow") === "numbered";
    const objs = seedTitleBar(0, slide.title, SEED_COLORS.primary);
    let z = 2;
    const n = Math.max(1, steps.length);
    const gap = 0.02;
    const bw = (0.88 - gap * (n - 1)) / n;
    let bx = 0.06;
    (steps.length ? steps : ["단계"]).forEach((st, i) => {
      // 단계 박스
      objs.push(sObj(z++, "rect", bx, 0.32, bw, 0.46, { fill: i === 0 ? SEED_COLORS.primary : SEED_COLORS.primaryLt, stroke: SEED_COLORS.primary, strokeWidth: 1.5, radius: 8 }));
      // 번호 원
      const cd = Math.min(0.12, bw * 0.5);
      objs.push(sObj(z++, "ellipse", bx + bw / 2 - cd / 2, 0.36, cd, cd, { fill: i === 0 ? SEED_COLORS.white : SEED_COLORS.primary, stroke: "transparent", strokeWidth: 0 }));
      objs.push(tObj(z++, bx + bw / 2 - cd / 2, 0.36, cd, cd, String(i + 1), { fontSize: 0.05, bold: true, align: "center", color: i === 0 ? SEED_COLORS.primary : SEED_COLORS.white, valign: "center" }));
      // 단계 텍스트
      objs.push(tObj(z++, bx + 0.01, 0.52, bw - 0.02, 0.24, st, { fontSize: 0.035, bold: true, align: "center", color: i === 0 ? SEED_COLORS.white : SEED_COLORS.sub, valign: "top" }));
      // 화살표(번호형이 아니고 마지막이 아닐 때)
      if (!numbered && i < n - 1) {
        objs.push(tObj(z++, bx + bw - gap, 0.46, gap * 1.6, 0.1, "→", { fontSize: 0.05, bold: true, align: "center", color: SEED_COLORS.mute, valign: "center" }));
      }
      bx += bw + gap;
    });
    return objs;
  },

  COMPARE(slide) {
    const list = seedContent(slide);
    let left, right;
    if (list.length <= 1) { left = list; right = []; }
    else { const mid = Math.ceil(list.length / 2); left = list.slice(0, mid); right = list.slice(mid); }
    const objs = seedTitleBar(0, slide.title, SEED_COLORS.violet);
    let z = 2;
    const cols = [
      { items: left, c: SEED_COLORS.compare[0], x: 0.06 },
      { items: right, c: SEED_COLORS.compare[1], x: 0.52 },
    ];
    cols.forEach((col) => {
      objs.push(sObj(z++, "rect", col.x, 0.26, 0.42, 0.66, { fill: col.c, stroke: "transparent", strokeWidth: 0, radius: 10 }));
      col.items.slice(0, 5).forEach((it, i) => {
        objs.push(tObj(z++, col.x + 0.025, 0.29 + i * 0.11, 0.37, 0.1, (i === 0 ? "" : "· ") + it, { fontSize: i === 0 ? 0.045 : 0.038, bold: i === 0, align: "left", color: SEED_COLORS.white, valign: "center" }));
      });
    });
    return objs;
  },

  CASE_STUDY(slide) {
    const list = seedContent(slide);
    const labels = ["장면", "원인", "예방"];
    const buckets = [[], [], []];
    list.forEach((item, i) => buckets[Math.min(i, 2)].push(item));
    const objs = seedTitleBar(0, slide.title, SEED_COLORS.negative);
    let z = 2;
    const bw = 0.286, gap = 0.02;
    let bx = 0.06;
    for (let i = 0; i < 3; i++) {
      objs.push(sObj(z++, "rect", bx, 0.26, bw, 0.66, { fill: SEED_COLORS.case[i], stroke: "transparent", strokeWidth: 0, radius: 10 }));
      objs.push(tObj(z++, bx + 0.02, 0.28, bw - 0.04, 0.08, labels[i], { fontSize: 0.045, bold: true, align: "left", color: SEED_COLORS.white, valign: "center" }));
      buckets[i].slice(0, 4).forEach((it, j) => {
        objs.push(tObj(z++, bx + 0.02, 0.38 + j * 0.12, bw - 0.04, 0.11, "· " + it, { fontSize: 0.034, align: "left", color: SEED_COLORS.white, valign: "top" }));
      });
      bx += bw + gap;
    }
    return objs;
  },

  CHECKLIST(slide) {
    const items = seedContent(slide).slice(0, 6);
    const objs = seedTitleBar(0, slide.title, SEED_COLORS.positive);
    let z = 2;
    (items.length ? items : ["점검 항목"]).forEach((it, i) => {
      const y = 0.26 + i * 0.115;
      objs.push(sObj(z++, "rect", 0.07, y, 0.05, 0.08, { fill: SEED_COLORS.white, stroke: SEED_COLORS.positive, strokeWidth: 2.5, radius: 3 }));
      objs.push(tObj(z++, 0.14, y, 0.78, 0.08, it, { fontSize: 0.044, bold: true, align: "left", color: SEED_COLORS.ink, valign: "center" }));
    });
    return objs;
  },

  SUMMARY(slide) {
    const items = seedContent(slide).slice(0, 6);
    const objs = seedTitleBar(0, slide.title, SEED_COLORS.primary);
    let z = 2;
    (items.length ? items : ["요약 항목"]).forEach((it, i) => {
      const y = 0.26 + i * 0.115;
      objs.push(sObj(z++, "rect", 0.06, y, 0.88, 0.1, { fill: SEED_COLORS.panel, stroke: "transparent", strokeWidth: 0, radius: 6 }));
      const cd = 0.07;
      objs.push(sObj(z++, "ellipse", 0.08, y + 0.015, cd, cd, { fill: SEED_COLORS.primary, stroke: "transparent", strokeWidth: 0 }));
      objs.push(tObj(z++, 0.08, y + 0.015, cd, cd, String(i + 1), { fontSize: 0.04, bold: true, align: "center", color: SEED_COLORS.white, valign: "center" }));
      objs.push(tObj(z++, 0.17, y, 0.75, 0.1, it, { fontSize: 0.042, bold: true, align: "left", color: SEED_COLORS.ink, valign: "center" }));
    });
    return objs;
  },

  TEXT_ONLY(slide) {
    const items = seedContent(slide).slice(0, 6);
    const objs = seedTitleBar(0, slide.title, SEED_COLORS.mute);
    let z = 2;
    if (items.length === 0) {
      objs.push(tObj(z++, 0.08, 0.3, 0.84, 0.1, "내용을 추가해요.", { fontSize: 0.045, align: "left", color: SEED_COLORS.mute }));
      return objs;
    }
    items.forEach((it, i) => {
      const y = 0.26 + i * 0.115;
      objs.push(sObj(z++, "ellipse", 0.07, y + 0.025, 0.018, 0.018 * (16 / 9), { fill: SEED_COLORS.primary, stroke: "transparent", strokeWidth: 0 }));
      objs.push(tObj(z++, 0.11, y, 0.81, 0.1, it, { fontSize: 0.046, align: "left", color: SEED_COLORS.sub, valign: "center" }));
    });
    return objs;
  },

  TEXT_IMAGE(slide) {
    const items = seedContent(slide).slice(0, 5);
    const objs = seedTitleBar(0, slide.title, SEED_COLORS.cyan);
    let z = 2;
    // 좌 텍스트
    (items.length ? items : ["키워드"]).forEach((it, i) => {
      objs.push(tObj(z++, 0.06, 0.28 + i * 0.115, 0.44, 0.1, "•  " + it, { fontSize: 0.042, align: "left", color: SEED_COLORS.sub, valign: "center" }));
    });
    // 우 이미지 플레이스홀더
    objs.push(sObj(z++, "rect", 0.54, 0.26, 0.4, 0.66, { fill: SEED_COLORS.panel, stroke: SEED_COLORS.line, strokeWidth: 1.5, radius: 10 }));
    objs.push(tObj(z++, 0.54, 0.26, 0.4, 0.66, "이미지 자리", { fontSize: 0.04, bold: true, align: "center", color: SEED_COLORS.mute, valign: "center" }));
    return objs;
  },

  TABLE(slide) {
    const rows = seedContent(slide).map((item) => {
      const txt = String(item);
      const idx = txt.indexOf(":");
      if (idx > 0 && idx <= 20) return [txt.slice(0, idx).trim(), txt.slice(idx + 1).trim()];
      return [txt, ""];
    }).slice(0, 6);
    const objs = seedTitleBar(0, slide.title, SEED_COLORS.ink);
    let z = 2;
    const list = rows.length ? rows : [["항목", "값"]];
    const tableY = 0.26, rowH = Math.min(0.11, (0.66) / list.length);
    const tableH = rowH * list.length;
    const c0 = 0.38;   // 첫 열 비율
    // 표 외곽
    objs.push(sObj(z++, "rect", 0.06, tableY, 0.88, tableH, { fill: SEED_COLORS.white, stroke: SEED_COLORS.line, strokeWidth: 1.5, radius: 6 }));
    list.forEach((r, i) => {
      const y = tableY + i * rowH;
      if (i % 2 === 1) objs.push(sObj(z++, "rect", 0.06, y, 0.88, rowH, { fill: SEED_COLORS.panel, stroke: "transparent", strokeWidth: 0 }));
      // 행 구분선
      if (i > 0) objs.push(sObj(z++, "line", 0.06, y, 0.88, 0.001, { stroke: SEED_COLORS.line, strokeWidth: 1 }));
      objs.push(tObj(z++, 0.08, y, 0.88 * c0 - 0.02, rowH, r[0], { fontSize: 0.036, bold: true, align: "left", color: SEED_COLORS.ink, valign: "center" }));
      objs.push(tObj(z++, 0.06 + 0.88 * c0 + 0.02, y, 0.88 * (1 - c0) - 0.04, rowH, r[1], { fontSize: 0.036, align: "left", color: SEED_COLORS.sub, valign: "center" }));
    });
    // 열 구분선
    objs.push(sObj(z++, "line", 0.06 + 0.88 * c0, tableY, 0.001, tableH, { stroke: SEED_COLORS.line, strokeWidth: 1 }));
    return objs;
  },

  CARD(slide) {
    const list = seedContent(slide);
    let count = (typeof slide.card_count === "number" && slide.card_count > 0) ? slide.card_count : list.length;
    if (count <= 0) count = 3;
    count = Math.min(count, 6);
    const cols = count <= 1 ? 1 : (count <= 4 ? 2 : 3);
    const rows = Math.ceil(count / cols);
    const objs = seedTitleBar(0, slide.title, SEED_COLORS.violet);
    let z = 2;
    const areaX = 0.06, areaY = 0.26, areaW = 0.88, areaH = 0.66;
    const gx = 0.02, gy = 0.025;
    const cw = (areaW - gx * (cols - 1)) / cols;
    const chh = (areaH - gy * (rows - 1)) / rows;
    for (let i = 0; i < count; i++) {
      const r = Math.floor(i / cols), c = i % cols;
      const x = areaX + c * (cw + gx), y = areaY + r * (chh + gy);
      objs.push(sObj(z++, "rect", x, y, cw, chh, { fill: SEED_COLORS.panel, stroke: SEED_COLORS.line, strokeWidth: 1, radius: 8 }));
      objs.push(sObj(z++, "rect", x, y, cw, 0.025, { fill: SEED_COLORS.violet, stroke: "transparent", strokeWidth: 0 }));
      objs.push(tObj(z++, x + 0.015, y + 0.04, cw - 0.03, chh - 0.05, list[i] != null ? list[i] : `카드 ${i + 1}`, { fontSize: 0.036, bold: true, align: "left", color: SEED_COLORS.sub, valign: "top" }));
    }
    return objs;
  },

  HUB(slide) {
    const nodes = seedContent(slide);
    const center = nodes[0] || (slide.title || "중심");
    const sats = nodes.slice(1, 7);
    const objs = seedTitleBar(0, slide.title, SEED_COLORS.cyan);
    let z = 2;
    const cx = 0.5, cy = 0.58, rx = 0.3, ry = 0.26;
    const cw = 0.18, chh = 0.13, sw = 0.16, shh = 0.1;
    const k = Math.max(1, sats.length);
    const pts = sats.map((_, i) => {
      const a = -Math.PI / 2 + (2 * Math.PI * i) / k;
      return [cx + rx * Math.cos(a), cy + ry * Math.sin(a)];
    });
    // 위성 노드(중앙 둘레로 배치 — 방사형 구조가 위치로 드러남)
    pts.forEach((p, i) => {
      objs.push(sObj(z++, "ellipse", p[0] - sw / 2, p[1] - shh / 2, sw, shh, { fill: SEED_COLORS.cyanLt, stroke: SEED_COLORS.cyan, strokeWidth: 1.5 }));
      objs.push(tObj(z++, p[0] - sw / 2, p[1] - shh / 2, sw, shh, sats[i], { fontSize: 0.03, bold: true, align: "center", color: SEED_COLORS.cyan, valign: "center" }));
    });
    // 중앙 노드(맨 위)
    objs.push(sObj(z++, "ellipse", cx - cw / 2, cy - chh / 2, cw, chh, { fill: SEED_COLORS.cyan, stroke: "transparent", strokeWidth: 0 }));
    objs.push(tObj(z++, cx - cw / 2, cy - chh / 2, cw, chh, center, { fontSize: 0.034, bold: true, align: "center", color: SEED_COLORS.white, valign: "center" }));
    return objs;
  },

  QUIZ(slide) {
    const list = seedContent(slide);
    let question, options, answer;
    if (list.length) { question = list[0]; const rest = list.slice(1); answer = rest.length ? rest[rest.length - 1] : null; options = rest.length ? rest.slice(0, -1) : []; }
    else { question = slide.title || "문제"; options = []; answer = null; }
    const marks = "①②③④⑤⑥";
    const objs = [];
    let z = 0;
    // 문제 박스
    objs.push(sObj(z++, "rect", 0.06, 0.07, 0.88, 0.18, { fill: SEED_COLORS.ink, stroke: "transparent", strokeWidth: 0, radius: 10 }));
    objs.push(tObj(z++, 0.09, 0.07, 0.82, 0.18, "Q. " + question, { fontSize: 0.05, bold: true, align: "left", color: SEED_COLORS.white, valign: "center" }));
    // 보기
    const opts = options.slice(0, 4);
    (opts.length ? opts : ["보기 1", "보기 2"]).forEach((o, i) => {
      const y = 0.3 + i * 0.12;
      objs.push(sObj(z++, "rect", 0.06, y, 0.88, 0.1, { fill: SEED_COLORS.panel, stroke: SEED_COLORS.line, strokeWidth: 1, radius: 6 }));
      objs.push(tObj(z++, 0.09, y, 0.82, 0.1, (marks[i] || (i + 1) + ")") + " " + o, { fontSize: 0.042, bold: true, align: "left", color: SEED_COLORS.ink, valign: "center" }));
    });
    // 정답 박스
    if (answer != null) {
      objs.push(sObj(z++, "rect", 0.06, 0.81, 0.88, 0.1, { fill: SEED_COLORS.positive, stroke: "transparent", strokeWidth: 0, radius: 6 }));
      objs.push(tObj(z++, 0.09, 0.81, 0.82, 0.1, "정답: " + answer, { fontSize: 0.044, bold: true, align: "left", color: SEED_COLORS.white, valign: "center" }));
    }
    return objs;
  },
};

/* ── 시드 진입점: layout_suggestion 으로 빌더 선택(미지정/누락 → TEXT_ONLY) ── */
function scSeedFromSlide(slide) {
  const s = slide || {};
  const layout = (s.layout_suggestion && SEED_BUILDERS[s.layout_suggestion]) ? s.layout_suggestion : "TEXT_ONLY";
  let objs;
  try {
    objs = SEED_BUILDERS[layout](s);
  } catch (e) {
    objs = null;
  }
  if (!Array.isArray(objs) || objs.length === 0) {
    // 폴백 — 단순 제목 + 안내(백지 방지)
    objs = [
      tObj(0, 0.08, 0.08, 0.84, 0.14, s.title || "제목", { fontSize: 0.08, bold: true, align: "left", color: SEED_COLORS.ink }),
      tObj(1, 0.08, 0.3, 0.84, 0.1, "내용을 추가해요.", { fontSize: 0.045, align: "left", color: SEED_COLORS.mute }),
    ];
  }
  // 좌표 안전 클램프(캔버스 밖 방지) + _diag 같은 내부 메타 제거
  return objs.map((o) => {
    const x = scClamp(o.x, 0, 0.99), y = scClamp(o.y, 0, 0.99);
    const w = scClamp(o.w, SC_MIN_W, 1 - x), hh = scClamp(o.h, 0.001, 1 - y);
    const { _diag, ...rest } = o;
    return { ...rest, x, y, w, h: hh };
  });
}

/* ── SlideCanvas — PPT 중앙 자유배치 캔버스 편집기 ──
   props:
     slide        : 정규화 슬라이드(objects 포함)
     onObjects(arr): 변경된 objects 배열 저장(상위에서 디바운스 patchSlide)
     selectedObjId / onSelectObj : 선택 오브젝트 상태(상위와 공유 → 우측 패널 분기) */
function SlideCanvas({ slide, project, onObjects, selectedObjId, onSelectObj }) {
  const wrapRef = useRef(null);
  const canvasRef = useRef(null);
  const fileRef = useRef(null);                        // 숨김 이미지 file input
  const [size, setSize] = useState({ w: 0, h: 0 });   // 캔버스 px
  const [editingId, setEditingId] = useState(null);    // 인라인 텍스트 편집 중
  const [guides, setGuides] = useState([]);            // 스냅 가이드선
  const [dragView, setDragView] = useState(null);      // {id, box} 드래그/리사이즈 중 미리보기(rAF)
  const [imgBusy, setImgBusy] = useState(false);       // 이미지 업로드 중
  const [imgErr, setImgErr] = useState("");            // 업로드 실패 안내(해요체)
  const [aiOpen, setAiOpen] = useState(false);         // AI 이미지 모달 열림

  const projectId = project && project.id;

  const objects = scList(slide && slide.objects);
  const selId = selectedObjId;

  // 드래그/리사이즈 가변 상태(ref — 리렌더 없이 추적)
  const drag = useRef(null);
  const raf = useRef(null);

  /* 캔버스 크기 측정(폭 기준 16:9) */
  const measure = useCallback(() => {
    const el = wrapRef.current;
    if (!el) return;
    const w = el.clientWidth;
    const h = w * SC_RATIO;
    setSize({ w, h });
  }, []);
  useEffect(() => {
    measure();
    const ro = (typeof ResizeObserver !== "undefined") ? new ResizeObserver(measure) : null;
    if (ro && wrapRef.current) ro.observe(wrapRef.current);
    window.addEventListener("resize", measure);
    return () => { if (ro) ro.disconnect(); window.removeEventListener("resize", measure); };
  }, [measure]);

  /* 슬라이드 전환 시 편집/선택 상태 정리 */
  useEffect(() => { setEditingId(null); }, [slide && (slide.slideId || slide.id)]);

  const W = size.w, H = size.h;

  /* 분수 → px 박스 */
  const toPx = useCallback((o) => ({
    left: o.x * W, top: o.y * H, width: o.w * W, height: o.h * H,
  }), [W, H]);

  /* objects 갱신 헬퍼 */
  const commit = useCallback((next) => { if (onObjects) onObjects(next); }, [onObjects]);

  const updateObj = useCallback((id, patch) => {
    commit(objects.map((o) => (o.id === id ? { ...o, ...patch } : o)));
  }, [objects, commit]);

  /* ── 삽입 ── */
  const addObject = useCallback((type) => {
    const d = SC_DEFAULTS[type] || { w: 0.2, h: 0.15 };
    const maxZ = objects.reduce((m, o) => Math.max(m, o.z || 0), -1);
    const base = {
      id: scNewId(), type,
      x: scClamp(0.5 - d.w / 2, 0, 1 - d.w),
      y: scClamp(0.5 - d.h / 2, 0, 1 - d.h),
      w: d.w, h: d.h, z: maxZ + 1,
    };
    if (type === "text") { base.text = "텍스트"; base.style = { fontSize: 0.06, align: "left", color: "#171719" }; }
    else if (type === "line") { base.style = { stroke: "#171719", strokeWidth: 3 }; }
    else { base.style = { fill: "var(--primary-light)", stroke: "var(--primary)", strokeWidth: 2 }; }
    commit([...objects, base]);
    onSelectObj && onSelectObj(base.id);
    setEditingId(null);
  }, [objects, commit, onSelectObj]);

  /* ── 이미지 오브젝트 추가 ──
     src 와 (선택) 원본 픽셀 비율로 기본 크기 산출 → 캔버스 중앙 배치 + 선택.
     다른 오브젝트와 동일하게 objects 에 {type:'image',src,...} 로 저장돼요. */
  const addImageObject = useCallback((src, natW, natH) => {
    if (!src) return;
    // 기본 폭(분수) — 원본 비율로 높이 산출(캔버스 16:9). 너무 크면 캔버스에 맞춰 축소.
    let w = SC_IMG_W;
    const ratio = (natW && natH && natH > 0) ? (natW / natH) : (4 / 3);
    // 캔버스 좌표계에서 높이(분수) = (픽셀높이/픽셀폭) = w*W / ratio / H = w*(16/9)/ratio
    let h = (w * (16 / 9)) / ratio;
    if (h > 0.86) { h = 0.86; w = (h * ratio) / (16 / 9); }
    w = scClamp(w, SC_MIN_W, 0.96);
    h = scClamp(h, SC_MIN_H, 0.96);
    const maxZ = objects.reduce((m, o) => Math.max(m, o.z || 0), -1);
    const base = {
      id: scNewId(), type: "image", src,
      x: scClamp(0.5 - w / 2, 0, 1 - w),
      y: scClamp(0.5 - h / 2, 0, 1 - h),
      w, h, z: maxZ + 1,
      style: { fit: "contain", radius: 0 },
    };
    commit([...objects, base]);
    onSelectObj && onSelectObj(base.id);
    setEditingId(null);
  }, [objects, commit, onSelectObj]);

  /* src 로드 후 원본 비율을 얻어 추가(이미지가 로드되면 비율 반영, 실패해도 기본 비율로 추가) */
  const addImageBySrc = useCallback((src) => {
    if (!src) return;
    try {
      const probe = new Image();
      probe.onload = () => addImageObject(src, probe.naturalWidth, probe.naturalHeight);
      probe.onerror = () => addImageObject(src, 0, 0);
      probe.src = src;
    } catch (e) {
      addImageObject(src, 0, 0);
    }
  }, [addImageObject]);

  /* ── 이미지 업로드 — POST /projects/:id/images (multipart file) ── */
  const uploadImage = useCallback(async (file) => {
    if (!file) return;
    if (!projectId) { setImgErr("프로젝트 정보를 찾지 못했어요. 새로고침 후 다시 시도해 주세요."); return; }
    if (!/^image\//i.test(file.type || "")) { setImgErr("이미지 파일만 올릴 수 있어요."); return; }
    if (file.size > 10 * 1024 * 1024) { setImgErr("이미지는 10MB 이하만 올릴 수 있어요."); return; }
    setImgErr(""); setImgBusy(true);
    try {
      const fd = new FormData();
      fd.append("file", file);                       // 계약: field = file
      const data = await api(`/projects/${projectId}/images`, { method: "POST", body: fd });
      const url = data && data.url;
      if (!url) throw new Error("이미지를 올리지 못했어요. 잠시 후 다시 시도해 주세요.");
      addImageBySrc(url);
    } catch (ex) {
      setImgErr((ex && ex.message) || "이미지를 올리지 못했어요. 잠시 후 다시 시도해 주세요.");
    } finally {
      setImgBusy(false);
      if (fileRef.current) fileRef.current.value = "";
    }
  }, [projectId, addImageBySrc]);

  const pickImage = useCallback(() => { setImgErr(""); if (fileRef.current) fileRef.current.click(); }, []);

  /* ── AI 이미지 — POST /projects/:id/ai-image {prompt} (무거움) ──
     실패(키 미등록 등) 시 reject → 모달이 백엔드 해요체 error 를 그대로 노출. */
  const generateAiImage = useCallback(async (prompt) => {
    if (!projectId) throw new Error("프로젝트 정보를 찾지 못했어요. 새로고침 후 다시 시도해 주세요.");
    const data = await api(`/projects/${projectId}/ai-image`, { method: "POST", body: { prompt } });
    const url = data && data.url;
    if (!url) throw new Error("이미지를 만들지 못했어요. 잠시 후 다시 시도해 주세요.");
    addImageBySrc(url);
    setAiOpen(false);                                 // 성공 시 모달 닫기
  }, [projectId, addImageBySrc]);

  /* ── 스냅 계산 ── 드래그 중인 박스(분수)의 left/cx/right · top/cy/bottom 을
     캔버스 기준선(0,1/3,1/2,2/3,1) 및 다른 오브젝트의 모서리/중심에 스냅. */
  const computeSnap = useCallback((box, selfId) => {
    const others = objects.filter((o) => o.id !== selfId);
    // 후보 라인(x축: 세로선 위치들 / y축: 가로선 위치들)
    const xLines = [0, 1 / 3, 0.5, 2 / 3, 1];
    const yLines = [0, 1 / 3, 0.5, 2 / 3, 1];
    others.forEach((o) => {
      xLines.push(o.x, o.x + o.w / 2, o.x + o.w);
      yLines.push(o.y, o.y + o.h / 2, o.y + o.h);
    });
    let nx = box.x, ny = box.y;
    const gl = [];
    // x축: 박스의 left/center/right 각각을 후보에 맞춰봄
    const xTargets = [{ edge: box.x, off: 0 }, { edge: box.x + box.w / 2, off: box.w / 2 }, { edge: box.x + box.w, off: box.w }];
    let bestX = null;
    xTargets.forEach((t) => {
      xLines.forEach((ln) => {
        const d = Math.abs(t.edge - ln);
        if (d < SC_SNAP && (!bestX || d < bestX.d)) bestX = { d, pos: ln, off: t.off };
      });
    });
    if (bestX) { nx = bestX.pos - bestX.off; gl.push({ axis: "x", pos: bestX.pos }); }
    const yTargets = [{ edge: box.y, off: 0 }, { edge: box.y + box.h / 2, off: box.h / 2 }, { edge: box.y + box.h, off: box.h }];
    let bestY = null;
    yTargets.forEach((t) => {
      yLines.forEach((ln) => {
        const d = Math.abs(t.edge - ln);
        if (d < SC_SNAP && (!bestY || d < bestY.d)) bestY = { d, pos: ln, off: t.off };
      });
    });
    if (bestY) { ny = bestY.pos - bestY.off; gl.push({ axis: "y", pos: bestY.pos }); }
    return { x: nx, y: ny, guides: gl };
  }, [objects]);

  /* rAF 로 드래그뷰 반영(부드럽게) */
  const scheduleView = useCallback((view, gl) => {
    if (raf.current) return;
    raf.current = requestAnimationFrame(() => {
      raf.current = null;
      setDragView(view);
      setGuides(gl || []);
    });
  }, []);

  /* ── 드래그(이동) 시작 ── */
  const startDrag = useCallback((e, obj) => {
    if (W === 0) return;
    const startX = e.clientX, startY = e.clientY;
    const orig = { x: obj.x, y: obj.y, w: obj.w, h: obj.h };
    drag.current = { mode: "move", id: obj.id, startX, startY, orig };
    const onMove = (ev) => {
      const dx = (ev.clientX - startX) / W;
      const dy = (ev.clientY - startY) / H;
      let nx = scClamp(orig.x + dx, 0, 1 - orig.w);
      let ny = scClamp(orig.y + dy, 0, 1 - orig.h);
      const snap = computeSnap({ x: nx, y: ny, w: orig.w, h: orig.h }, obj.id);
      nx = scClamp(snap.x, 0, 1 - orig.w);
      ny = scClamp(snap.y, 0, 1 - orig.h);
      drag.current.next = { x: nx, y: ny, w: orig.w, h: orig.h };
      scheduleView({ id: obj.id, box: { x: nx, y: ny, w: orig.w, h: orig.h } }, snap.guides);
    };
    const onUp = () => {
      window.removeEventListener("mousemove", onMove);
      window.removeEventListener("mouseup", onUp);
      const nx = drag.current && drag.current.next;
      if (nx) updateObj(obj.id, { x: nx.x, y: nx.y });
      drag.current = null; setDragView(null); setGuides([]);
    };
    window.addEventListener("mousemove", onMove);
    window.addEventListener("mouseup", onUp);
  }, [W, H, computeSnap, scheduleView, updateObj]);

  /* ── 리사이즈 시작 ── */
  const startResize = useCallback((e, handle) => {
    const obj = objects.find((o) => o.id === selId);
    if (!obj || W === 0) return;
    const startX = e.clientX, startY = e.clientY;
    const orig = { x: obj.x, y: obj.y, w: obj.w, h: obj.h };
    drag.current = { mode: "resize", id: obj.id, handle, startX, startY, orig };
    const hasN = handle.indexOf("n") >= 0, hasS = handle.indexOf("s") >= 0;
    const hasW = handle.indexOf("w") >= 0, hasE = handle.indexOf("e") >= 0;
    const onMove = (ev) => {
      const dx = (ev.clientX - startX) / W;
      const dy = (ev.clientY - startY) / H;
      let { x, y, w, h } = orig;
      if (hasE) w = orig.w + dx;
      if (hasW) { w = orig.w - dx; x = orig.x + dx; }
      if (hasS) h = orig.h + dy;
      if (hasN) { h = orig.h - dy; y = orig.y + dy; }
      // 최소 크기 보장 + 좌상단 고정 보정
      if (w < SC_MIN_W) { if (hasW) x -= (SC_MIN_W - w); w = SC_MIN_W; }
      if (h < SC_MIN_H) { if (hasN) y -= (SC_MIN_H - h); h = SC_MIN_H; }
      // 캔버스 밖 방지
      x = scClamp(x, 0, 1 - SC_MIN_W); y = scClamp(y, 0, 1 - SC_MIN_H);
      w = scClamp(w, SC_MIN_W, 1 - x); h = scClamp(h, SC_MIN_H, 1 - y);
      drag.current.next = { x, y, w, h };
      scheduleView({ id: obj.id, box: { x, y, w, h } }, []);
    };
    const onUp = () => {
      window.removeEventListener("mousemove", onMove);
      window.removeEventListener("mouseup", onUp);
      const n = drag.current && drag.current.next;
      if (n) updateObj(obj.id, n);
      drag.current = null; setDragView(null); setGuides([]);
    };
    window.addEventListener("mousemove", onMove);
    window.addEventListener("mouseup", onUp);
  }, [objects, selId, W, H, scheduleView, updateObj]);

  /* ── z-order ── */
  const changeZ = useCallback((dir) => {
    if (!selId) return;
    const sorted = scSorted(objects);
    const idx = sorted.findIndex((o) => o.id === selId);
    if (idx < 0) return;
    let order = sorted.map((o) => o.id);
    order.splice(idx, 1);
    if (dir === "front") order.push(selId);
    else if (dir === "back") order.unshift(selId);
    else if (dir === "up") order.splice(Math.min(order.length, idx + 1), 0, selId);
    else if (dir === "down") order.splice(Math.max(0, idx - 1), 0, selId);
    const zmap = {};
    order.forEach((id, i) => { zmap[id] = i; });
    commit(objects.map((o) => ({ ...o, z: zmap[o.id] != null ? zmap[o.id] : o.z })));
  }, [objects, selId, commit]);

  /* ── 삭제 ── */
  const deleteObj = useCallback((id) => {
    const tid = id || selId;
    if (!tid) return;
    commit(objects.filter((o) => o.id !== tid));
    if (onSelectObj) onSelectObj(null);
    setEditingId(null);
  }, [objects, selId, commit, onSelectObj]);

  /* Delete/Backspace 키 삭제(편집 중·입력 포커스 제외) */
  useEffect(() => {
    const onKey = (e) => {
      if (editingId) return;
      const tag = (e.target && e.target.tagName) || "";
      if (tag === "INPUT" || tag === "TEXTAREA") return;
      if ((e.key === "Delete" || e.key === "Backspace") && selId) {
        e.preventDefault();
        deleteObj(selId);
      } else if (e.key === "Escape") {
        if (onSelectObj) onSelectObj(null);
      }
    };
    window.addEventListener("keydown", onKey);
    return () => window.removeEventListener("keydown", onKey);
  }, [editingId, selId, deleteObj, onSelectObj]);

  /* 인라인 텍스트 편집 */
  const startEdit = useCallback((id) => { if (onSelectObj) onSelectObj(id); setEditingId(id); }, [onSelectObj]);
  const commitText = useCallback((id, val) => { updateObj(id, { text: val }); setEditingId(null); }, [updateObj]);

  /* 시드(빈 화면 방지) — objects 비었을 때 자동 1회 */
  const seededRef = useRef(null);
  useEffect(() => {
    const key = slide && (slide.slideId || slide.id);
    if (!slide) return;
    if (objects.length === 0 && seededRef.current !== key) {
      seededRef.current = key;
      commit(scSeedFromSlide(slide));
    }
    if (objects.length > 0) seededRef.current = key;
  }, [slide, objects.length, commit]);

  /* "레이아웃대로 채우기" 재시드(확인 후 기존 대체) */
  const reseed = useCallback(() => {
    if (!slide) return;
    if (objects.length > 0 && !window.confirm("현재 슬라이드의 제목·내용으로 오브젝트를 다시 채울까요? 기존 오브젝트는 대체돼요.")) return;
    commit(scSeedFromSlide(slide));
    if (onSelectObj) onSelectObj(null);
    setEditingId(null);
  }, [slide, objects.length, commit, onSelectObj]);

  /* 렌더 시 드래그뷰가 있으면 그 박스로 덮어씀 */
  const effObj = (o) => (dragView && dragView.id === o.id ? { ...o, ...dragView.box } : o);
  const selObj = objects.find((o) => o.id === selId) || null;
  const selPx = selObj ? toPx(effObj(selObj)) : null;

  return ch("div", { style: { width: "100%", maxWidth: 920, display: "flex", flexDirection: "column", gap: 10 } },
    /* 상단 컨트롤: 삽입 툴바 + 시드 버튼 */
    ch("div", { style: { display: "flex", alignItems: "center", justifyContent: "space-between", gap: 10, flexWrap: "wrap" } },
      ch(ScToolbar, { onAdd: addObject, onPickImage: pickImage, onAiImage: () => { setImgErr(""); setAiOpen(true); }, imgBusy }),
      ch("button", {
        onClick: reseed,
        title: "현재 슬라이드 제목·내용으로 오브젝트 재생성",
        style: { height: 32, padding: "0 12px", borderRadius: "var(--r-sm)", border: "1px solid var(--border)", background: "var(--bg-normal)", color: "var(--label-alt)", fontSize: 12, fontWeight: 700, cursor: "pointer", whiteSpace: "nowrap" },
      }, "레이아웃대로 채우기")),

    /* 숨김 이미지 file input(accept=image/*) */
    ch("input", {
      ref: fileRef, type: "file", accept: "image/*",
      onChange: (e) => uploadImage(e.target.files && e.target.files[0]),
      style: { display: "none" },
    }),

    /* 이미지 업로드 실패 안내(해요체) */
    imgErr
      ? ch("div", { className: "t-label-2", style: { color: "var(--negative)", background: "var(--negative-bg)", borderRadius: "var(--r-sm)", padding: "8px 12px", display: "flex", alignItems: "center", gap: 8 } },
          ch("svg", { width: 14, height: 14, viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", strokeWidth: 2.2, strokeLinecap: "round", strokeLinejoin: "round", style: { flexShrink: 0 } }, ch("circle", { cx: 12, cy: 12, r: 10 }), ch("path", { d: "M12 8v4M12 16h.01" })),
          imgErr)
      : null,

    /* 선택 시 z-order/삭제 바 */
    selObj
      ? ch("div", { style: { display: "flex", alignItems: "center", gap: 8 } },
          ch(ScZBar, { onZ: changeZ }),
          ch("button", {
            onClick: () => deleteObj(selId),
            style: { height: 30, padding: "0 12px", borderRadius: "var(--r-sm)", border: "1px solid var(--negative)", background: "var(--negative-bg)", color: "var(--negative)", fontSize: 11.5, fontWeight: 700, cursor: "pointer" },
          }, "삭제"),
          ch("span", { className: "t-caption-1", style: { color: "var(--label-assist)", marginLeft: "auto" } }, "더블클릭으로 텍스트 편집 · Delete로 삭제"))
      : ch("div", { style: { height: 30, display: "flex", alignItems: "center" } },
          ch("span", { className: "t-caption-1", style: { color: "var(--label-assist)" } }, "오브젝트를 클릭해 선택하거나, 위 버튼으로 추가해요.")),

    /* 16:9 흰 캔버스(그림자) */
    ch("div", {
      ref: wrapRef,
      style: { width: "100%", aspectRatio: "16 / 9", position: "relative", background: "#fff", borderRadius: "var(--r-md)", boxShadow: "var(--sh-2)", overflow: "hidden", border: "1px solid var(--border)" },
      onMouseDown: (e) => {
        // 빈 곳 클릭 → 선택 해제(편집 종료)
        if (e.target === wrapRef.current || e.target === canvasRef.current) {
          if (onSelectObj) onSelectObj(null);
          setEditingId(null);
        }
      },
    },
      ch("div", { ref: canvasRef, style: { position: "absolute", inset: 0 } },
        W > 0
          ? scSorted(objects).map((o) => {
              const eo = effObj(o);
              return ch(ScObject, {
                key: o.id, obj: eo, px: toPx(eo), canvasH: H,
                selected: o.id === selId, editing: editingId === o.id,
                onSelect: (id) => { if (onSelectObj) onSelectObj(id); },
                onStartDrag: startDrag, onStartEdit: startEdit, onCommitText: commitText,
              });
            })
          : null,
        /* 선택 오버레이 */
        (selPx && editingId !== selId) ? ch(ScSelection, { px: selPx, onStartResize: startResize }) : null,
        /* 스냅 가이드 */
        guides.length ? ch(ScGuides, { guides, w: W, h: H }) : null)),

    objects.length === 0
      ? ch("p", { className: "t-caption-1", style: { color: "var(--label-assist)", textAlign: "center", margin: "2px 0 0" } }, "빈 캔버스예요. 위 버튼으로 텍스트·도형을 추가하거나 '레이아웃대로 채우기'를 눌러요.")
      : null,

    /* AI 이미지 프롬프트 모달 */
    (aiOpen && typeof ModalShell === "function")
      ? ch(ScAiImageModal, { onClose: () => setAiOpen(false), onGenerate: generateAiImage })
      : null);
}

/* ── 우측 오브젝트 속성 패널 ──
   선택된 오브젝트의 텍스트/채움·선 색/글자크기/정렬/z-order/삭제 편집.
   props: obj, onChange(patch), onStyle(stylePatch), onZ(dir), onDelete, canvasH(추정) */
function ObjectInspector({ obj, onChange, onStyle, onZ, onDelete }) {
  if (!obj) return null;
  const style = obj.style || {};
  const isText = obj.type === "text";
  const isLine = obj.type === "line";
  const isImage = obj.type === "image";
  const isShape = !isText && !isLine && !isImage;   // rect/ellipse/triangle
  const typeLabel = { text: "텍스트", line: "라인", rect: "사각형", ellipse: "원형", triangle: "세모", image: "이미지" }[obj.type] || obj.type;

  /* 글자크기: 분수(0~1) → 대략 pt 환산값(높이 720px 가정 슬라이드 기준 표기) */
  const fsFrac = typeof style.fontSize === "number" ? style.fontSize : 0.06;
  const fsPct = Math.round(fsFrac <= 1 ? fsFrac * 100 : fsFrac);

  return ch("div", { style: { display: "flex", flexDirection: "column", height: "100%" } },
    /* 헤더 */
    ch("div", { style: { display: "flex", alignItems: "center", gap: 10, padding: "14px 16px", borderBottom: "1px solid var(--line-neutral)" } },
      ch("span", { className: "t-label-1", style: { color: "var(--label-normal)" } }, "오브젝트 편집"),
      ch(Pill, { color: "var(--primary)", bg: "var(--primary-light)", style: { marginLeft: "auto" } }, typeLabel)),

    ch("div", { style: { flex: 1, overflowY: "auto", padding: 16, display: "flex", flexDirection: "column", gap: 20 } },
      /* 텍스트 내용 */
      isText ? ch("div", { style: { display: "flex", flexDirection: "column", gap: 7 } },
        ch(FSec, { label: "텍스트 내용" }),
        ch("textarea", {
          value: obj.text || "",
          placeholder: "텍스트를 입력해요.",
          onChange: (e) => onChange({ text: e.target.value }),
          rows: 3,
          style: { width: "100%", minHeight: 64, resize: "vertical", padding: "9px 12px", borderRadius: "var(--r-sm)", border: "1px solid var(--border)", background: "var(--bg-normal)", fontSize: 13.5, lineHeight: 1.5, color: "var(--label-normal)", fontFamily: "var(--font)", outline: "none" },
        })) : null,

      /* 글자크기 + 정렬 + 굵게/기울임 (텍스트만) */
      isText ? ch("div", { style: { display: "flex", flexDirection: "column", gap: 10 } },
        ch(FSec, { label: "글자" }),
        ch(FRowInline, { label: "크기" },
          ch(FStepper, { value: fsPct, min: 2, max: 30, step: 1, unit: "%", onChange: (v) => onStyle({ fontSize: v / 100 }) })),
        ch(FRowInline, { label: "정렬" },
          ch(FSeg, { value: style.align || "left", options: [["left", "좌"], ["center", "중"], ["right", "우"]], onChange: (v) => onStyle({ align: v }) })),
        ch(FRowInline, { label: "강조" },
          ch("div", { style: { display: "flex", gap: 6 } },
            ch("button", { onClick: () => onStyle({ bold: !style.bold }), style: scToggleStyle(style.bold) }, ch("b", null, "B")),
            ch("button", { onClick: () => onStyle({ italic: !style.italic }), style: scToggleStyle(style.italic) }, ch("i", null, "I"))))) : null,

      /* 글자색(텍스트) / 선색(라인·도형) — 이미지는 제외 */
      (!isImage) ? ch("div", { style: { display: "flex", flexDirection: "column", gap: 8 } },
        ch(FSec, { label: isText ? "글자색" : "선 색" }),
        ch(ScSwatches, { value: isText ? (style.color || "#171719") : (style.stroke || "#171719"), onPick: (v) => onStyle(isText ? { color: v } : { stroke: v }) })) : null,

      /* 채움색(도형만) */
      isShape ? ch("div", { style: { display: "flex", flexDirection: "column", gap: 8 } },
        ch(FSec, { label: "채움 색" }),
        ch(ScSwatches, { value: style.fill || "var(--primary-light)", onPick: (v) => onStyle({ fill: v }) })) : null,

      /* 선 두께(도형/라인) */
      (!isText && !isImage) ? ch(FRowInline, { label: "선 두께" },
        ch(FStepper, { value: typeof style.strokeWidth === "number" ? style.strokeWidth : 2, min: 0, max: 12, step: 1, unit: "", onChange: (v) => onStyle({ strokeWidth: v }) })) : null,

      /* 이미지 — 맞춤(비율 유지/꽉 채우기) */
      isImage ? ch(FRowInline, { label: "맞춤" },
        ch(FSeg, { value: style.fit === "cover" ? "cover" : "contain", options: [["contain", "비율"], ["cover", "채움"]], onChange: (v) => onStyle({ fit: v }) })) : null,

      /* 모서리 둥글기(사각형·이미지) */
      (obj.type === "rect" || isImage) ? ch(FRowInline, { label: "모서리" },
        ch(FStepper, { value: typeof style.radius === "number" ? style.radius : 0, min: 0, max: 40, step: 2, unit: "", onChange: (v) => onStyle({ radius: v }) })) : null,

      /* z-order */
      ch("div", { style: { display: "flex", flexDirection: "column", gap: 8 } },
        ch(FSec, { label: "정렬 순서" }),
        ch(ScZBar, { onZ })),

      /* 삭제 */
      ch("div", { style: { marginTop: 4, paddingTop: 14, borderTop: "1px solid var(--line-neutral)" } },
        ch(Button, { kind: "danger", size: "sm", onClick: onDelete, style: { width: "100%" } }, "이 오브젝트 삭제"))));
}

function scToggleStyle(active) {
  return {
    width: 34, height: 30, borderRadius: "var(--r-sm)", cursor: "pointer", fontSize: 14,
    border: active ? "1px solid var(--primary)" : "1px solid var(--border)",
    background: active ? "var(--primary-light)" : "var(--bg-normal)",
    color: active ? "var(--primary)" : "var(--label-neutral)",
  };
}

/* 토큰 색 스와치 픽커 */
function ScSwatches({ value, onPick }) {
  return ch("div", { style: { display: "flex", flexWrap: "wrap", gap: 6 } },
    SC_SWATCHES.map((sw) => {
      const active = value === sw.value;
      const isNone = sw.value === "transparent";
      return ch("button", {
        key: sw.key, onClick: () => onPick(sw.value), title: sw.label,
        style: {
          width: 26, height: 26, borderRadius: "var(--r-xs)", cursor: "pointer",
          background: isNone ? "var(--bg-normal)" : sw.value,
          border: active ? "2px solid var(--primary)" : "1px solid var(--border)",
          position: "relative", boxShadow: active ? "0 0 0 2px var(--primary-light)" : "none",
        },
      }, isNone ? ch("span", { style: { position: "absolute", inset: 0, display: "flex", alignItems: "center", justifyContent: "center", color: "var(--negative)", fontSize: 14, fontWeight: 700 } }, "/") : null);
    }));
}

Object.assign(window, { SlideCanvas, ObjectInspector, ScAiImageModal, scSeedFromSlide });
