@roottale/cms-media 0.25.0 → 0.26.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -20,7 +20,9 @@ import { CfImage } from "@roottale/cms-media/react";
20
20
  ```
21
21
 
22
22
  - CF imagedelivery URL이면 sm/md/lg srcset 자동, 기본 src `/md`. 비-CF(로컬 SVG·외부)면 변형 없이 그대로.
23
- - `placeholder`: `'blur'`(기본, CF thumbnail 흐림) | `'skeleton'`(shimmer) | `'none'`. 로딩 중 표시 후 `onLoad` 페이드인.
23
+ - `placeholder`(로더): `'blur'`(기본, CF thumbnail 흐림) | `'skeleton'`(shimmer) | `'image'` | `'none'`. 로딩 중 표시 후 `onLoad` 페이드인.
24
+ - `'image'` + `loaderImage="<url>"`: 로딩 중 가운데 커스텀 이미지(브랜드 스피너/로고 등).
25
+ - `loader={<MySpinner/>}`: 임의 ReactNode를 로더로 직접 주입(최우선). `loaderBackground`로 배경색 지정.
24
26
  - `priority`로 LCP 즉시 로드. `fill`/`sizes`/`width`/`height`/`loading`/`onError`/`style` 등 next/image·`<img>` 호환.
25
27
  - `"use client"` — 서버 컴포넌트에서도 직접 렌더 가능(자체 클라이언트 경계).
26
28
 
package/dist/react.d.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import * as react_jsx_runtime from 'react/jsx-runtime';
2
- import { ImgHTMLAttributes, CSSProperties } from 'react';
2
+ import { ImgHTMLAttributes, CSSProperties, ReactNode } from 'react';
3
3
 
4
4
  /**
5
5
  * RootTale 공유 CF Images 렌더 컴포넌트 — next/image 대체. (`@roottale/cms-media/react`)
@@ -9,12 +9,14 @@ import { ImgHTMLAttributes, CSSProperties } from 'react';
9
9
  * srcset으로 브라우저가 직접 받게 한다 → Vercel 컴퓨트 0. (ADR-0034 / docs/image-standard.md)
10
10
  *
11
11
  * - CF imagedelivery URL: srcset(sm/md/lg) + 기본 src=md. 비-CF URL(로컬 SVG·외부)은 변형 없이 그대로.
12
- * - placeholder: 'blur'(CF thumbnail 저해상도 흐림) | 'skeleton'(shimmer) | 'none'. 기본 'blur'.
13
- * - loader: 원본 onLoad 시 opacity 0→1 페이드인.
12
+ * - placeholder(로더): 'blur'(CF thumbnail 흐림) | 'skeleton'(shimmer) | 'image'(loaderImage) | 'none'. 기본 'blur'.
13
+ * · loaderImage: placeholder='image'일 때 로딩 중 가운데 표시할 커스텀 이미지 URL(브랜드 스피너/로고 등).
14
+ * · loader: 임의 ReactNode를 로더로 직접 주입(최우선). 무엇이든 커스텀 가능.
15
+ * - 원본 onLoad 시 opacity 0→1 페이드인.
14
16
  *
15
17
  * next/image의 흔한 사용(fill, sizes, priority, width/height, style, loading, onError)과 호환.
16
18
  */
17
- type CfImagePlaceholder = "blur" | "skeleton" | "none";
19
+ type CfImagePlaceholder = "blur" | "skeleton" | "image" | "none";
18
20
  interface CfImageProps extends Omit<ImgHTMLAttributes<HTMLImageElement>, "src" | "placeholder"> {
19
21
  src: string;
20
22
  alt: string;
@@ -24,13 +26,19 @@ interface CfImageProps extends Omit<ImgHTMLAttributes<HTMLImageElement>, "src" |
24
26
  priority?: boolean;
25
27
  /** fill일 때 object-fit (기본 cover). */
26
28
  objectFit?: CSSProperties["objectFit"];
27
- /** 로딩 중 표시. 기본 'blur'(CF가 아니면 skeleton로 폴백). */
29
+ /** 로딩 중 표시(로더). 기본 'blur'(CF가 아니면 skeleton로 폴백). 'image'면 loaderImage 사용. */
28
30
  placeholder?: CfImagePlaceholder;
31
+ /** placeholder='image'일 때 로딩 중 가운데 표시할 커스텀 이미지 URL. */
32
+ loaderImage?: string;
33
+ /** 임의 커스텀 로더(스피너 컴포넌트 등). 지정 시 placeholder/loaderImage보다 우선. */
34
+ loader?: ReactNode;
35
+ /** 로더 배경색(image/custom/skeleton 컨테이너). 기본 연한 중립. */
36
+ loaderBackground?: string;
29
37
  /** 래퍼 클래스/스타일 (fill 레이아웃 제어용). */
30
38
  wrapperClassName?: string;
31
39
  wrapperStyle?: CSSProperties;
32
40
  }
33
- declare function CfImage({ src, alt, fill, priority, objectFit, placeholder, sizes, width, height, loading, style, className, wrapperClassName, wrapperStyle, onLoad, onError, ...rest }: CfImageProps): react_jsx_runtime.JSX.Element;
41
+ declare function CfImage({ src, alt, fill, priority, objectFit, placeholder, loaderImage, loader, loaderBackground, sizes, width, height, loading, style, className, wrapperClassName, wrapperStyle, onLoad, onError, ...rest }: CfImageProps): react_jsx_runtime.JSX.Element;
34
42
 
35
43
  type ImageVariant = "thumbnail" | "sm" | "md" | "lg";
36
44
  /** imagedelivery delivery URL 여부. */
package/dist/react.js CHANGED
@@ -47,6 +47,9 @@ function CfImage({
47
47
  priority,
48
48
  objectFit = "cover",
49
49
  placeholder = "blur",
50
+ loaderImage,
51
+ loader,
52
+ loaderBackground = "#eceff1",
50
53
  sizes,
51
54
  width,
52
55
  height,
@@ -65,13 +68,20 @@ function CfImage({
65
68
  if (imgRef.current?.complete) setLoaded(true);
66
69
  }, []);
67
70
  const cf = isCfImageUrl(src);
68
- const effPlaceholder = placeholder === "blur" && !cf ? "skeleton" : placeholder;
71
+ const hasCustomLoader = loader != null;
72
+ let mode;
73
+ if (hasCustomLoader) mode = "custom";
74
+ else if (placeholder === "image" && loaderImage) mode = "image";
75
+ else if (placeholder === "image") mode = cf ? "blur" : "skeleton";
76
+ else if (placeholder === "blur") mode = cf ? "blur" : "skeleton";
77
+ else if (placeholder === "skeleton") mode = "skeleton";
78
+ else mode = "none";
69
79
  useEffect(() => {
70
- if (effPlaceholder === "skeleton") ensureShimmerKeyframes();
71
- }, [effPlaceholder]);
80
+ if (mode === "skeleton") ensureShimmerKeyframes();
81
+ }, [mode]);
72
82
  const baseSrc = cf ? cfImageVariant(src, "md") : src;
73
83
  const srcSet = cf ? cfImageSrcSet(src) : void 0;
74
- const showPlaceholder = effPlaceholder !== "none" && !loaded && !priority;
84
+ const showLoader = mode !== "none" && !loaded && !priority;
75
85
  const wrapper = fill ? { position: "absolute", inset: 0, overflow: "hidden", ...wrapperStyle } : {
76
86
  position: "relative",
77
87
  display: "inline-block",
@@ -84,28 +94,51 @@ function CfImage({
84
94
  transition: "opacity 0.4s ease",
85
95
  ...style
86
96
  };
87
- const placeholderBase = {
97
+ const overlayBase = {
88
98
  position: "absolute",
89
99
  inset: 0,
90
100
  pointerEvents: "none",
91
- opacity: showPlaceholder ? 1 : 0,
101
+ opacity: showLoader ? 1 : 0,
92
102
  transition: "opacity 0.4s ease"
93
103
  };
94
- const placeholderStyle = effPlaceholder === "blur" ? {
95
- ...placeholderBase,
96
- backgroundImage: `url(${cfImageVariant(src, "thumbnail")})`,
97
- backgroundSize: "cover",
98
- backgroundPosition: "center",
99
- filter: "blur(16px)",
100
- transform: "scale(1.1)"
101
- } : {
102
- ...placeholderBase,
103
- background: "linear-gradient(100deg,#eceff1 30%,#f5f7f8 50%,#eceff1 70%)",
104
- backgroundSize: "200% 100%",
105
- animation: "rtCfImgShimmer 1.4s ease-in-out infinite"
106
- };
104
+ let overlayStyle = overlayBase;
105
+ let overlayChildren = null;
106
+ if (mode === "blur") {
107
+ overlayStyle = {
108
+ ...overlayBase,
109
+ backgroundImage: `url(${cfImageVariant(src, "thumbnail")})`,
110
+ backgroundSize: "cover",
111
+ backgroundPosition: "center",
112
+ filter: "blur(16px)",
113
+ transform: "scale(1.1)"
114
+ };
115
+ } else if (mode === "skeleton") {
116
+ overlayStyle = {
117
+ ...overlayBase,
118
+ background: `linear-gradient(100deg,${loaderBackground} 30%,#f5f7f8 50%,${loaderBackground} 70%)`,
119
+ backgroundSize: "200% 100%",
120
+ animation: "rtCfImgShimmer 1.4s ease-in-out infinite"
121
+ };
122
+ } else if (mode === "image" || mode === "custom") {
123
+ overlayStyle = {
124
+ ...overlayBase,
125
+ display: "flex",
126
+ alignItems: "center",
127
+ justifyContent: "center",
128
+ background: loaderBackground
129
+ };
130
+ overlayChildren = mode === "image" ? /* @__PURE__ */ jsx(
131
+ "img",
132
+ {
133
+ src: loaderImage,
134
+ alt: "",
135
+ "aria-hidden": true,
136
+ style: { maxWidth: "44%", maxHeight: "44%", objectFit: "contain" }
137
+ }
138
+ ) : loader;
139
+ }
107
140
  return /* @__PURE__ */ jsxs("span", { className: wrapperClassName, style: wrapper, children: [
108
- effPlaceholder !== "none" && /* @__PURE__ */ jsx("span", { "aria-hidden": true, style: placeholderStyle }),
141
+ mode !== "none" && /* @__PURE__ */ jsx("span", { "aria-hidden": true, style: overlayStyle, children: overlayChildren }),
109
142
  /* @__PURE__ */ jsx(
110
143
  "img",
111
144
  {
package/dist/react.js.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"sources":["../src/react/CfImage.tsx","../src/variants.ts"],"sourcesContent":["\"use client\";\n\nimport {\n useEffect,\n useRef,\n useState,\n type CSSProperties,\n type ImgHTMLAttributes,\n} from \"react\";\nimport { cfImageVariant, cfImageSrcSet, isCfImageUrl } from \"../variants.js\";\n\n/**\n * RootTale 공유 CF Images 렌더 컴포넌트 — next/image 대체. (`@roottale/cms-media/react`)\n *\n * 왜: next/image는 Vercel 이미지 최적화 함수(/_next/image, Fluid Compute)를 태워 비용·지연 발생.\n * 콘텐츠 이미지는 Cloudflare Images(imagedelivery.net)에 두고 CF variant(sm/md/lg, 자동 webp/avif)\n * srcset으로 브라우저가 직접 받게 한다 → Vercel 컴퓨트 0. (ADR-0034 / docs/image-standard.md)\n *\n * - CF imagedelivery URL: srcset(sm/md/lg) + 기본 src=md. 비-CF URL(로컬 SVG·외부)은 변형 없이 그대로.\n * - placeholder: 'blur'(CF thumbnail 저해상도 흐림) | 'skeleton'(shimmer) | 'none'. 기본 'blur'.\n * - loader: 원본 onLoad 시 opacity 0→1 페이드인.\n *\n * next/image의 흔한 사용(fill, sizes, priority, width/height, style, loading, onError)과 호환.\n */\n\nexport type CfImagePlaceholder = \"blur\" | \"skeleton\" | \"none\";\n\nexport interface CfImageProps\n extends Omit<ImgHTMLAttributes<HTMLImageElement>, \"src\" | \"placeholder\"> {\n src: string;\n alt: string;\n /** next/image의 fill 처럼 부모(position:relative)를 채운다. */\n fill?: boolean;\n /** LCP 요소면 true → eager + fetchPriority high + 페이드 생략(즉시). */\n priority?: boolean;\n /** fill일 때 object-fit (기본 cover). */\n objectFit?: CSSProperties[\"objectFit\"];\n /** 로딩 중 표시. 기본 'blur'(CF가 아니면 skeleton로 폴백). */\n placeholder?: CfImagePlaceholder;\n /** 래퍼 클래스/스타일 (fill 레이아웃 제어용). */\n wrapperClassName?: string;\n wrapperStyle?: CSSProperties;\n}\n\nconst SHIMMER_STYLE_ID = \"roottale-cf-image-shimmer\";\nconst SHIMMER_KEYFRAMES = `@keyframes rtCfImgShimmer{0%{background-position:-200% 0}100%{background-position:200% 0}}`;\n\nfunction ensureShimmerKeyframes() {\n if (typeof document === \"undefined\") return;\n if (document.getElementById(SHIMMER_STYLE_ID)) return;\n const el = document.createElement(\"style\");\n el.id = SHIMMER_STYLE_ID;\n el.textContent = SHIMMER_KEYFRAMES;\n document.head.appendChild(el);\n}\n\nexport function CfImage({\n src,\n alt,\n fill,\n priority,\n objectFit = \"cover\",\n placeholder = \"blur\",\n sizes,\n width,\n height,\n loading,\n style,\n className,\n wrapperClassName,\n wrapperStyle,\n onLoad,\n onError,\n ...rest\n}: CfImageProps) {\n const [loaded, setLoaded] = useState(false);\n const imgRef = useRef<HTMLImageElement>(null);\n\n // 캐시 히트 시 onLoad가 안 올 수 있어 마운트 시 complete 체크.\n useEffect(() => {\n if (imgRef.current?.complete) setLoaded(true);\n }, []);\n\n const cf = isCfImageUrl(src);\n const effPlaceholder: CfImagePlaceholder =\n placeholder === \"blur\" && !cf ? \"skeleton\" : placeholder;\n useEffect(() => {\n if (effPlaceholder === \"skeleton\") ensureShimmerKeyframes();\n }, [effPlaceholder]);\n\n const baseSrc = cf ? cfImageVariant(src, \"md\") : src;\n const srcSet = cf ? cfImageSrcSet(src) : undefined;\n const showPlaceholder = effPlaceholder !== \"none\" && !loaded && !priority;\n\n const wrapper: CSSProperties = fill\n ? { position: \"absolute\", inset: 0, overflow: \"hidden\", ...wrapperStyle }\n : {\n position: \"relative\",\n display: \"inline-block\",\n overflow: \"hidden\",\n ...wrapperStyle,\n };\n\n const imgStyle: CSSProperties = {\n ...(fill\n ? { position: \"absolute\", inset: 0, width: \"100%\", height: \"100%\", objectFit }\n : { display: \"block\", width: width ? \"100%\" : undefined, height: \"auto\", objectFit }),\n opacity: loaded || priority ? 1 : 0,\n transition: \"opacity 0.4s ease\",\n ...style,\n };\n\n const placeholderBase: CSSProperties = {\n position: \"absolute\",\n inset: 0,\n pointerEvents: \"none\",\n opacity: showPlaceholder ? 1 : 0,\n transition: \"opacity 0.4s ease\",\n };\n\n const placeholderStyle: CSSProperties =\n effPlaceholder === \"blur\"\n ? {\n ...placeholderBase,\n backgroundImage: `url(${cfImageVariant(src, \"thumbnail\")})`,\n backgroundSize: \"cover\",\n backgroundPosition: \"center\",\n filter: \"blur(16px)\",\n transform: \"scale(1.1)\",\n }\n : {\n ...placeholderBase,\n background:\n \"linear-gradient(100deg,#eceff1 30%,#f5f7f8 50%,#eceff1 70%)\",\n backgroundSize: \"200% 100%\",\n animation: \"rtCfImgShimmer 1.4s ease-in-out infinite\",\n };\n\n return (\n <span className={wrapperClassName} style={wrapper}>\n {effPlaceholder !== \"none\" && <span aria-hidden style={placeholderStyle} />}\n <img\n {...rest}\n ref={imgRef}\n src={baseSrc}\n srcSet={srcSet}\n sizes={sizes}\n alt={alt}\n width={fill ? undefined : width}\n height={fill ? undefined : height}\n loading={loading ?? (priority ? \"eager\" : \"lazy\")}\n fetchPriority={priority ? \"high\" : undefined}\n decoding=\"async\"\n className={className}\n style={imgStyle}\n onLoad={(e) => {\n setLoaded(true);\n onLoad?.(e);\n }}\n onError={onError}\n />\n </span>\n );\n}\n\nexport default CfImage;\n","// ADR-0034 §7.1.1 test #4 — CF Images variant 4종 URL 생성기.\n//\n// Phase 1 = thumbnail/sm/md/lg. CF Images delivery URL pattern:\n// https://imagedelivery.net/<account-hash>/<image-id>/<variant>\n\nexport type ImageVariant = \"thumbnail\" | \"sm\" | \"md\" | \"lg\";\n\nexport const VARIANT_NAMES: readonly ImageVariant[] = [\n \"thumbnail\",\n \"sm\",\n \"md\",\n \"lg\",\n] as const;\n\nexport interface VariantSizes {\n thumbnail: { width: 96; height: 96 };\n sm: { width: 320; height: 320 };\n md: { width: 768; height: 768 };\n lg: { width: 1600; height: 1600 };\n}\n\nexport const VARIANT_SIZES: VariantSizes = {\n thumbnail: { width: 96, height: 96 },\n sm: { width: 320, height: 320 },\n md: { width: 768, height: 768 },\n lg: { width: 1600, height: 1600 },\n};\n\nexport interface CfImagesConfig {\n accountHash: string;\n}\n\n/** CF Images delivery URL 생성. */\nexport function variantUrl(\n config: CfImagesConfig,\n imageId: string,\n variant: ImageVariant,\n): string {\n return `https://imagedelivery.net/${config.accountHash}/${imageId}/${variant}`;\n}\n\n/** 모든 variant 의 URL map. */\nexport function allVariantUrls(\n config: CfImagesConfig,\n imageId: string,\n): Record<ImageVariant, string> {\n return Object.fromEntries(\n VARIANT_NAMES.map((v) => [v, variantUrl(config, imageId, v)]),\n ) as Record<ImageVariant, string>;\n}\n\n// ── delivery URL 의 variant 세그먼트 교체 헬퍼 (account-hash/image-id 를 몰라도\n// 이미 받은 full delivery URL 만으로 variant 를 바꾼다. CfImage 렌더용.) ──\n\nconst CF_HOST = \"imagedelivery.net\";\nconst KNOWN_VARIANT_SUFFIX =\n /\\/(public|lg|md|sm|thumbnail|jhwebp|jhfull|jhthumb|jhmobile)$/;\n\n/** imagedelivery delivery URL 여부. */\nexport function isCfImageUrl(url: string): boolean {\n return typeof url === \"string\" && url.includes(CF_HOST);\n}\n\n/**\n * delivery URL 의 끝 variant 세그먼트를 교체. 세그먼트가 없으면 덧붙인다.\n * CF URL 이 아니면 원본 그대로 반환(외부/로컬 URL 안전 통과).\n */\nexport function cfImageVariant(\n url: string,\n variant: ImageVariant | \"public\",\n): string {\n if (!isCfImageUrl(url)) return url;\n return KNOWN_VARIANT_SUFFIX.test(url)\n ? url.replace(KNOWN_VARIANT_SUFFIX, `/${variant}`)\n : `${url}/${variant}`;\n}\n\n/** sm/md/lg srcset 문자열(CF URL 일 때만, 아니면 undefined). 폭은 VARIANT_SIZES 기준. */\nexport function cfImageSrcSet(\n url: string,\n variants: readonly ImageVariant[] = [\"sm\", \"md\", \"lg\"],\n): string | undefined {\n if (!isCfImageUrl(url)) return undefined;\n return variants\n .map((v) => `${cfImageVariant(url, v)} ${VARIANT_SIZES[v].width}w`)\n .join(\", \");\n}\n"],"mappings":"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","names":[]}
1
+ {"version":3,"sources":["../src/react/CfImage.tsx","../src/variants.ts"],"sourcesContent":["\"use client\";\n\nimport {\n useEffect,\n useRef,\n useState,\n type CSSProperties,\n type ImgHTMLAttributes,\n type ReactNode,\n} from \"react\";\nimport { cfImageVariant, cfImageSrcSet, isCfImageUrl } from \"../variants.js\";\n\n/**\n * RootTale 공유 CF Images 렌더 컴포넌트 — next/image 대체. (`@roottale/cms-media/react`)\n *\n * 왜: next/image는 Vercel 이미지 최적화 함수(/_next/image, Fluid Compute)를 태워 비용·지연 발생.\n * 콘텐츠 이미지는 Cloudflare Images(imagedelivery.net)에 두고 CF variant(sm/md/lg, 자동 webp/avif)\n * srcset으로 브라우저가 직접 받게 한다 → Vercel 컴퓨트 0. (ADR-0034 / docs/image-standard.md)\n *\n * - CF imagedelivery URL: srcset(sm/md/lg) + 기본 src=md. 비-CF URL(로컬 SVG·외부)은 변형 없이 그대로.\n * - placeholder(로더): 'blur'(CF thumbnail 흐림) | 'skeleton'(shimmer) | 'image'(loaderImage) | 'none'. 기본 'blur'.\n * · loaderImage: placeholder='image'일 때 로딩 중 가운데 표시할 커스텀 이미지 URL(브랜드 스피너/로고 등).\n * · loader: 임의 ReactNode를 로더로 직접 주입(최우선). 무엇이든 커스텀 가능.\n * - 원본 onLoad 시 opacity 0→1 페이드인.\n *\n * next/image의 흔한 사용(fill, sizes, priority, width/height, style, loading, onError)과 호환.\n */\n\nexport type CfImagePlaceholder = \"blur\" | \"skeleton\" | \"image\" | \"none\";\n\nexport interface CfImageProps\n extends Omit<ImgHTMLAttributes<HTMLImageElement>, \"src\" | \"placeholder\"> {\n src: string;\n alt: string;\n /** next/image의 fill 처럼 부모(position:relative)를 채운다. */\n fill?: boolean;\n /** LCP 요소면 true → eager + fetchPriority high + 페이드 생략(즉시). */\n priority?: boolean;\n /** fill일 때 object-fit (기본 cover). */\n objectFit?: CSSProperties[\"objectFit\"];\n /** 로딩 중 표시(로더). 기본 'blur'(CF가 아니면 skeleton로 폴백). 'image'면 loaderImage 사용. */\n placeholder?: CfImagePlaceholder;\n /** placeholder='image'일 때 로딩 중 가운데 표시할 커스텀 이미지 URL. */\n loaderImage?: string;\n /** 임의 커스텀 로더(스피너 컴포넌트 등). 지정 시 placeholder/loaderImage보다 우선. */\n loader?: ReactNode;\n /** 로더 배경색(image/custom/skeleton 컨테이너). 기본 연한 중립. */\n loaderBackground?: string;\n /** 래퍼 클래스/스타일 (fill 레이아웃 제어용). */\n wrapperClassName?: string;\n wrapperStyle?: CSSProperties;\n}\n\nconst SHIMMER_STYLE_ID = \"roottale-cf-image-shimmer\";\nconst SHIMMER_KEYFRAMES = `@keyframes rtCfImgShimmer{0%{background-position:-200% 0}100%{background-position:200% 0}}`;\n\nfunction ensureShimmerKeyframes() {\n if (typeof document === \"undefined\") return;\n if (document.getElementById(SHIMMER_STYLE_ID)) return;\n const el = document.createElement(\"style\");\n el.id = SHIMMER_STYLE_ID;\n el.textContent = SHIMMER_KEYFRAMES;\n document.head.appendChild(el);\n}\n\nexport function CfImage({\n src,\n alt,\n fill,\n priority,\n objectFit = \"cover\",\n placeholder = \"blur\",\n loaderImage,\n loader,\n loaderBackground = \"#eceff1\",\n sizes,\n width,\n height,\n loading,\n style,\n className,\n wrapperClassName,\n wrapperStyle,\n onLoad,\n onError,\n ...rest\n}: CfImageProps) {\n const [loaded, setLoaded] = useState(false);\n const imgRef = useRef<HTMLImageElement>(null);\n\n // 캐시 히트 시 onLoad가 안 올 수 있어 마운트 시 complete 체크.\n useEffect(() => {\n if (imgRef.current?.complete) setLoaded(true);\n }, []);\n\n const cf = isCfImageUrl(src);\n\n // 로더 모드 결정. 우선순위: 커스텀 loader 노드 > placeholder.\n const hasCustomLoader = loader != null;\n let mode: \"custom\" | \"image\" | \"blur\" | \"skeleton\" | \"none\";\n if (hasCustomLoader) mode = \"custom\";\n else if (placeholder === \"image\" && loaderImage) mode = \"image\";\n else if (placeholder === \"image\") mode = cf ? \"blur\" : \"skeleton\"; // 이미지 미지정 폴백\n else if (placeholder === \"blur\") mode = cf ? \"blur\" : \"skeleton\";\n else if (placeholder === \"skeleton\") mode = \"skeleton\";\n else mode = \"none\";\n\n useEffect(() => {\n if (mode === \"skeleton\") ensureShimmerKeyframes();\n }, [mode]);\n\n const baseSrc = cf ? cfImageVariant(src, \"md\") : src;\n const srcSet = cf ? cfImageSrcSet(src) : undefined;\n const showLoader = mode !== \"none\" && !loaded && !priority;\n\n const wrapper: CSSProperties = fill\n ? { position: \"absolute\", inset: 0, overflow: \"hidden\", ...wrapperStyle }\n : {\n position: \"relative\",\n display: \"inline-block\",\n overflow: \"hidden\",\n ...wrapperStyle,\n };\n\n const imgStyle: CSSProperties = {\n ...(fill\n ? { position: \"absolute\", inset: 0, width: \"100%\", height: \"100%\", objectFit }\n : { display: \"block\", width: width ? \"100%\" : undefined, height: \"auto\", objectFit }),\n opacity: loaded || priority ? 1 : 0,\n transition: \"opacity 0.4s ease\",\n ...style,\n };\n\n const overlayBase: CSSProperties = {\n position: \"absolute\",\n inset: 0,\n pointerEvents: \"none\",\n opacity: showLoader ? 1 : 0,\n transition: \"opacity 0.4s ease\",\n };\n\n let overlayStyle: CSSProperties = overlayBase;\n let overlayChildren: ReactNode = null;\n\n if (mode === \"blur\") {\n overlayStyle = {\n ...overlayBase,\n backgroundImage: `url(${cfImageVariant(src, \"thumbnail\")})`,\n backgroundSize: \"cover\",\n backgroundPosition: \"center\",\n filter: \"blur(16px)\",\n transform: \"scale(1.1)\",\n };\n } else if (mode === \"skeleton\") {\n overlayStyle = {\n ...overlayBase,\n background: `linear-gradient(100deg,${loaderBackground} 30%,#f5f7f8 50%,${loaderBackground} 70%)`,\n backgroundSize: \"200% 100%\",\n animation: \"rtCfImgShimmer 1.4s ease-in-out infinite\",\n };\n } else if (mode === \"image\" || mode === \"custom\") {\n overlayStyle = {\n ...overlayBase,\n display: \"flex\",\n alignItems: \"center\",\n justifyContent: \"center\",\n background: loaderBackground,\n };\n overlayChildren =\n mode === \"image\" ? (\n <img\n src={loaderImage}\n alt=\"\"\n aria-hidden\n style={{ maxWidth: \"44%\", maxHeight: \"44%\", objectFit: \"contain\" }}\n />\n ) : (\n loader\n );\n }\n\n return (\n <span className={wrapperClassName} style={wrapper}>\n {mode !== \"none\" && (\n <span aria-hidden style={overlayStyle}>\n {overlayChildren}\n </span>\n )}\n <img\n {...rest}\n ref={imgRef}\n src={baseSrc}\n srcSet={srcSet}\n sizes={sizes}\n alt={alt}\n width={fill ? undefined : width}\n height={fill ? undefined : height}\n loading={loading ?? (priority ? \"eager\" : \"lazy\")}\n fetchPriority={priority ? \"high\" : undefined}\n decoding=\"async\"\n className={className}\n style={imgStyle}\n onLoad={(e) => {\n setLoaded(true);\n onLoad?.(e);\n }}\n onError={onError}\n />\n </span>\n );\n}\n\nexport default CfImage;\n","// ADR-0034 §7.1.1 test #4 — CF Images variant 4종 URL 생성기.\n//\n// Phase 1 = thumbnail/sm/md/lg. CF Images delivery URL pattern:\n// https://imagedelivery.net/<account-hash>/<image-id>/<variant>\n\nexport type ImageVariant = \"thumbnail\" | \"sm\" | \"md\" | \"lg\";\n\nexport const VARIANT_NAMES: readonly ImageVariant[] = [\n \"thumbnail\",\n \"sm\",\n \"md\",\n \"lg\",\n] as const;\n\nexport interface VariantSizes {\n thumbnail: { width: 96; height: 96 };\n sm: { width: 320; height: 320 };\n md: { width: 768; height: 768 };\n lg: { width: 1600; height: 1600 };\n}\n\nexport const VARIANT_SIZES: VariantSizes = {\n thumbnail: { width: 96, height: 96 },\n sm: { width: 320, height: 320 },\n md: { width: 768, height: 768 },\n lg: { width: 1600, height: 1600 },\n};\n\nexport interface CfImagesConfig {\n accountHash: string;\n}\n\n/** CF Images delivery URL 생성. */\nexport function variantUrl(\n config: CfImagesConfig,\n imageId: string,\n variant: ImageVariant,\n): string {\n return `https://imagedelivery.net/${config.accountHash}/${imageId}/${variant}`;\n}\n\n/** 모든 variant 의 URL map. */\nexport function allVariantUrls(\n config: CfImagesConfig,\n imageId: string,\n): Record<ImageVariant, string> {\n return Object.fromEntries(\n VARIANT_NAMES.map((v) => [v, variantUrl(config, imageId, v)]),\n ) as Record<ImageVariant, string>;\n}\n\n// ── delivery URL 의 variant 세그먼트 교체 헬퍼 (account-hash/image-id 를 몰라도\n// 이미 받은 full delivery URL 만으로 variant 를 바꾼다. CfImage 렌더용.) ──\n\nconst CF_HOST = \"imagedelivery.net\";\nconst KNOWN_VARIANT_SUFFIX =\n /\\/(public|lg|md|sm|thumbnail|jhwebp|jhfull|jhthumb|jhmobile)$/;\n\n/** imagedelivery delivery URL 여부. */\nexport function isCfImageUrl(url: string): boolean {\n return typeof url === \"string\" && url.includes(CF_HOST);\n}\n\n/**\n * delivery URL 의 끝 variant 세그먼트를 교체. 세그먼트가 없으면 덧붙인다.\n * CF URL 이 아니면 원본 그대로 반환(외부/로컬 URL 안전 통과).\n */\nexport function cfImageVariant(\n url: string,\n variant: ImageVariant | \"public\",\n): string {\n if (!isCfImageUrl(url)) return url;\n return KNOWN_VARIANT_SUFFIX.test(url)\n ? url.replace(KNOWN_VARIANT_SUFFIX, `/${variant}`)\n : `${url}/${variant}`;\n}\n\n/** sm/md/lg srcset 문자열(CF URL 일 때만, 아니면 undefined). 폭은 VARIANT_SIZES 기준. */\nexport function cfImageSrcSet(\n url: string,\n variants: readonly ImageVariant[] = [\"sm\", \"md\", \"lg\"],\n): string | undefined {\n if (!isCfImageUrl(url)) return undefined;\n return variants\n .map((v) => `${cfImageVariant(url, v)} ${VARIANT_SIZES[v].width}w`)\n .join(\", \");\n}\n"],"mappings":"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","names":[]}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@roottale/cms-media",
3
- "version": "0.25.0",
3
+ "version": "0.26.0",
4
4
  "type": "module",
5
5
  "description": "RootTale 미디어 — R2 원본 보관 + CF Images variants + 한글 파일명 정규화 + CfImage 렌더 컴포넌트(@roottale/cms-media/react). ADR-0034 §3 / docs/image-standard.md.",
6
6
  "main": "./dist/index.js",