@leviosa-ai/detail-page-editor 0.6.3 → 0.7.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.
@@ -9,6 +9,7 @@ import {
9
9
  REFERENCE_ACCEPT_ATTR,
10
10
  estimateBriefCredits,
11
11
  finalizeReferenceDataUri,
12
+ planReferenceTokens,
12
13
  readReferenceFile,
13
14
  referenceOrdinal,
14
15
  type DesignReferenceAspect,
@@ -46,6 +47,9 @@ type Picked = {
46
47
  aspects: DesignReferenceAspect[];
47
48
  /** 이 장의 비전 입력 토큰. 0 은 "아직 못 쟀다" — 값은 비싼 쪽으로 잡힌다. */
48
49
  inputTokens: number;
50
+ /** 이 장의 가로세로. 0 이면 아직 못 쟀다 — 세로로 긴 캡쳐의 값은 이 크기로 정해진다. */
51
+ width: number;
52
+ height: number;
49
53
  };
50
54
 
51
55
  export function DesignReferencePicker({
@@ -70,6 +74,9 @@ export function DesignReferencePicker({
70
74
  aspects: DesignReferenceAspect[];
71
75
  /** 앞 화면에서 이미 잰 크기. 0 이면 여기서도 비싼 쪽으로 잡는다. */
72
76
  inputTokens: number;
77
+ /** 앞 화면에서 잰 가로세로. 옛 임시저장에는 없어서 선택이다. */
78
+ width?: number;
79
+ height?: number;
73
80
  }[];
74
81
  disabled?: boolean;
75
82
  }) {
@@ -82,6 +89,8 @@ export function DesignReferencePicker({
82
89
  uri: entry.uri,
83
90
  aspects: [...entry.aspects],
84
91
  inputTokens: entry.inputTokens,
92
+ width: entry.width ?? 0,
93
+ height: entry.height ?? 0,
85
94
  })),
86
95
  );
87
96
  const [instruction, setInstruction] = useState("");
@@ -106,6 +115,8 @@ export function DesignReferencePicker({
106
115
  uri: await readReferenceFile(file),
107
116
  aspects: [],
108
117
  inputTokens: 0,
118
+ width: 0,
119
+ height: 0,
109
120
  });
110
121
  } catch (err) {
111
122
  setError(err instanceof Error ? err.message : "참고 사진을 붙이지 못했어요.");
@@ -128,7 +139,13 @@ export function DesignReferencePicker({
128
139
  setItems((prev) =>
129
140
  prev.map((entry) =>
130
141
  entry.id === item.id
131
- ? { ...entry, uri: result.uri, inputTokens: result.inputTokens }
142
+ ? {
143
+ ...entry,
144
+ uri: result.uri,
145
+ inputTokens: result.inputTokens,
146
+ width: result.width,
147
+ height: result.height,
148
+ }
132
149
  : entry,
133
150
  ),
134
151
  );
@@ -168,7 +185,10 @@ export function DesignReferencePicker({
168
185
 
169
186
  // 값은 장수가 아니라 **붙인 그림의 크기**로 정해진다. 서버와 같은 공식이라 여기 뜬 수가
170
187
  // 곧 청구될 수다(선차감이라 미리 맞아야 한다).
171
- const credits = estimateBriefCredits(items.map((item) => item.inputTokens));
188
+ //
189
+ // 크기를 그대로 넘기는 이유는 세로로 긴 캡쳐 때문이다 — 서버가 조각내 싣고, 조각 수는
190
+ // **몇 장을 붙였는지**에 따라 달라진다. 장마다 미리 굳혀 둔 토큰 수로는 못 센다.
191
+ const credits = estimateBriefCredits(planReferenceTokens(items));
172
192
 
173
193
  const analyze = useCallback(async () => {
174
194
  if (!items.length || busy) return;
@@ -20,6 +20,7 @@ import {
20
20
  applyTextLineFit,
21
21
  clearPlaceholderImageSrc,
22
22
  } from "../../lib/detail-page-canvas/custom-props-adapter";
23
+ import { TooltipProvider } from "../ui/tooltip";
23
24
  import { CanvasStoreContext } from "./canvas-observer";
24
25
  import { LeviosaCanvasWorkspace } from "./leviosa-canvas-workspace";
25
26
  import { DetailPagePagesTimeline } from "./detail-page-pages-timeline";
@@ -1018,6 +1019,13 @@ export function DetailPageEditor({
1018
1019
  <CanvasStoreContext.Provider value={store}>
1019
1020
  {/* 캔버스 위 띠가 쓰는 값들. props로 내리면 작업 영역이 통째로 다시 만들어진다. */}
1020
1021
  <EditorAiProvider value={aiValue}>
1022
+ {/* 편집기가 자기 툴팁 프로바이더를 깐다.
1023
+ 예전에는 안 깔았다 — 첫 소비자(leviosa-frontend)가 앱 레이아웃에 전역으로 하나
1024
+ 갖고 있어서 우연히 서 있었을 뿐이다. 그것이 없는 소비자에서는 AI 생성 패널이
1025
+ 열리는 순간 `Tooltip must be used within TooltipProvider` 로 화면이 통째로
1026
+ 죽었다. Radix 의 프로바이더는 중첩이 안전하므로(안쪽이 이긴다), 이미 깔아 둔
1027
+ 앱에도 해가 없다. */}
1028
+ <TooltipProvider>
1021
1029
  <div
1022
1030
  data-dpe-root=""
1023
1031
  className="flex h-screen min-h-[640px] flex-col bg-dpe-ink-100"
@@ -1046,6 +1054,7 @@ export function DetailPageEditor({
1046
1054
  <slots.PricingModal open={pricingOpen} onOpenChange={setPricingOpen} />
1047
1055
  ) : null}
1048
1056
  </div>
1057
+ </TooltipProvider>
1049
1058
  </EditorAiProvider>
1050
1059
  </CanvasStoreContext.Provider>
1051
1060
  );
@@ -37,6 +37,10 @@ export function DetailPageFontPicker({
37
37
  const [loading, setLoading] = useState<string | null>(null);
38
38
  const [error, setError] = useState(false);
39
39
  const [tags, setTags] = useState<FontTag[]>([]);
40
+ /** 404 로 떨어진 미리보기. 한 번 떨어지면 다시 안 부른다. */
41
+ const [previewsMissing, setPreviewsMissing] = useState<ReadonlySet<string>>(
42
+ () => new Set(),
43
+ );
40
44
  const selected = getEditorFont(value);
41
45
  const catalogFonts = filterFontsByTags(EDITOR_CATALOG_FONTS, tags);
42
46
  const bundleFonts = filterFontsByTags(EDITOR_BUNDLE_FONTS, tags);
@@ -68,6 +72,14 @@ export function DetailPageFontPicker({
68
72
  }
69
73
  };
70
74
 
75
+ /**
76
+ * 미리보기 WebP 는 소비자 앱의 정적 자산이다(`assets.detailFontPreviews`).
77
+ *
78
+ * 그걸 굽는 스크립트를 아직 안 돌린 소비자에서는 이 그림이 통째로 404 다. 예전에는
79
+ * 그 자리에 깨진 이미지 아이콘과 alt 글자만 남아, 목록이 폰트 이름표만 늘어선 줄이
80
+ * 됐다 — 무엇을 고르는지 볼 수가 없다. 떨어지면 **이름을 그 폰트로** 그린다.
81
+ * 완전한 미리보기는 아니지만(가변 자소 몇 자뿐), 고를 수는 있다.
82
+ */
71
83
  const renderFont = (font: EditorFont) => (
72
84
  <CommandItem
73
85
  key={`${font.source}:${font.id}`}
@@ -77,14 +89,26 @@ export function DetailPageFontPicker({
77
89
  className="group min-h-[56px] gap-2 px-2 py-1"
78
90
  >
79
91
  <span className="flex h-11 min-w-0 flex-1 items-center overflow-hidden">
80
- <Image
81
- src={font.previewSrc}
82
- alt={font.label}
83
- width={560}
84
- height={88}
85
- className="h-auto w-full object-contain object-left"
86
- unoptimized
87
- />
92
+ {previewsMissing.has(font.previewSrc) ? (
93
+ <span
94
+ className="truncate text-lg leading-none"
95
+ style={{ fontFamily: `"${font.family}", sans-serif` }}
96
+ >
97
+ {font.label}
98
+ </span>
99
+ ) : (
100
+ <Image
101
+ src={font.previewSrc}
102
+ alt={font.label}
103
+ width={560}
104
+ height={88}
105
+ className="h-auto w-full object-contain object-left"
106
+ unoptimized
107
+ onError={() =>
108
+ setPreviewsMissing((seen) => new Set(seen).add(font.previewSrc))
109
+ }
110
+ />
111
+ )}
88
112
  </span>
89
113
  {font.latinOnly ? (
90
114
  <span className="shrink-0 rounded border border-dpe-ink-200 px-1 py-0.5 text-[10px] leading-none text-dpe-ink-500">
@@ -91,6 +91,7 @@ import {
91
91
  type RemoveBackgroundFn,
92
92
  } from "./ai-generate-panel";
93
93
  import { toHexColor } from "../../lib/detail-page/css-color";
94
+ import { editorAssetBase } from "../../lib/detail-page/runtime-config";
94
95
  import { setZ as setElementZ, zOrderOf } from "../../lib/detail-page/z-order";
95
96
  import {
96
97
  canDistribute,
@@ -959,7 +960,7 @@ function textGifEffects(t: Translate): GifEffectOption[] {
959
960
  id,
960
961
  label: t(`detailPage.gifEffects.text.${id}.label`),
961
962
  hint: t(`detailPage.gifEffects.text.${id}.hint`),
962
- previewSrc: `/gif-effect-previews/text-${id}.gif`,
963
+ previewSrc: `${editorAssetBase("gifEffectPreviews")}/text-${id}.gif`,
963
964
  }));
964
965
  }
965
966
 
@@ -1033,7 +1034,7 @@ function imageGifEffects(t: Translate, shape: boolean): GifEffectOption[] {
1033
1034
  label: t(`detailPage.gifEffects.image.${effect.id}.label`),
1034
1035
  hint: t(`detailPage.gifEffects.image.${effect.id}.hint`),
1035
1036
  group: shape ? undefined : t(IMAGE_GIF_GROUP_KEY[effect.group]),
1036
- previewSrc: `/gif-effect-previews/image-${effect.id}.gif`,
1037
+ previewSrc: `${editorAssetBase("gifEffectPreviews")}/image-${effect.id}.gif`,
1037
1038
  }));
1038
1039
  }
1039
1040
 
package/i18n/en.json CHANGED
@@ -143,7 +143,29 @@
143
143
  "weight600": "Semi bold",
144
144
  "weight700": "Bold",
145
145
  "weight800": "Extra bold",
146
- "weight900": "Black"
146
+ "weight900": "Black",
147
+ "alignInGroup": "Align within group",
148
+ "alignInSection": "Align within section",
149
+ "cellGridCols": "Columns",
150
+ "cellGridGif": "Cell fill GIF",
151
+ "cellGridGifHint": "Filled cells light up in order, hold 2s, then repeat. Add axis and value labels on the canvas.",
152
+ "cellGridGifMake": "Make chart GIF",
153
+ "cellGridInvalid": "Each row needs at least one cell and cannot exceed the column count.",
154
+ "cellGridRows": "Filled cells per row",
155
+ "cellGridShape": "Shape",
156
+ "countUpGif": "Number to count-up",
157
+ "countUpGifHint": "Counts up from 0 to {{target}}, holds 2s, then repeats. It lands right here.",
158
+ "countUpGifMake": "Make count-up GIF",
159
+ "gifShapeUnreadable": "Could not read the shape.",
160
+ "gifStageDetecting": "Finding the subject…",
161
+ "gifStageEncoding": "Encoding the GIF…",
162
+ "gifStagePreparing": "Preparing…",
163
+ "gifStageRendering": "Rendering",
164
+ "gifStageUploading": "Saving…",
165
+ "imageGif": "Image to GIF",
166
+ "imageGifCancel": "Cancel (the GIF in progress is saved to My images)",
167
+ "imageGifMake": "Make GIF",
168
+ "imageGifMaskFallback": "Couldn't isolate the object, so the effect covers the whole image."
147
169
  },
148
170
  "gifEffects": {
149
171
  "text": {
@@ -229,6 +251,10 @@
229
251
  "label": "Holographic foil (gold)",
230
252
  "hint": "Sweeps a warm gold sheen over it, like gold leaf."
231
253
  }
254
+ },
255
+ "group": {
256
+ "object": "Subject in the image",
257
+ "whole": "Whole image"
232
258
  }
233
259
  },
234
260
  "chart": {
@@ -284,7 +310,25 @@
284
310
  "detachHint": "Breaks it into plain shapes and text. After that, values and bars no longer stay in sync.",
285
311
  "panelTitle": "Charts",
286
312
  "insertHint": "Click to add to the canvas",
287
- "panelHint": "Drop one in, then edit the values on the right — the bars follow. You can paste a table from Excel."
313
+ "panelHint": "Drop one in, then edit the values on the right — the bars follow. You can paste a table from Excel.",
314
+ "presets": {
315
+ "barHCompare": "Horizontal · Compare",
316
+ "barHMinimal": "Minimal bars",
317
+ "barHPercent": "Horizontal · Percent",
318
+ "barHRank": "Horizontal · Ranking",
319
+ "barVAxis": "Bar with axis",
320
+ "barVCompare": "Vertical · Compare",
321
+ "barVPlain": "Vertical · Plain",
322
+ "barVRank": "Ranked columns",
323
+ "donutShare": "Donut · Share",
324
+ "donutSingle": "Single-ratio donut",
325
+ "gaugeRate": "Gauge · Rate",
326
+ "gaugeScore": "Score gauge",
327
+ "lineCompare": "Comparison line",
328
+ "lineTrend": "Line · Trend",
329
+ "stackMix": "Stacked · Mix",
330
+ "stackShare": "Composition stack"
331
+ }
288
332
  },
289
333
  "table": {
290
334
  "typeTable": "Table",
@@ -347,6 +391,19 @@
347
391
  "moveColumn": "Drag to reorder column",
348
392
  "removeColumn": "Delete column {{column}}",
349
393
  "insertColumn": "Insert column at position {{at}}"
394
+ },
395
+ "preset": {
396
+ "gridCheck": "Checkmark comparison",
397
+ "gridCompare": "Comparison table",
398
+ "gridHeader": "Header table",
399
+ "gridPrice": "Price table",
400
+ "gridZebra": "Zebra comparison",
401
+ "specAiry": "Airy spec table",
402
+ "specBoxed": "Boxed spec table",
403
+ "specCelled": "Celled spec table",
404
+ "specHairline": "Hairline spec table",
405
+ "specRight": "Right-aligned values",
406
+ "typeCentered": "By type"
350
407
  }
351
408
  },
352
409
  "brandAssets": {
@@ -381,7 +438,11 @@
381
438
  "notAGif": "Only GIF files can be uploaded.",
382
439
  "upload": "Upload GIF",
383
440
  "empty": "No GIFs yet. Turn a text, image, or shape into one.",
384
- "sectionOther": "Other"
441
+ "sectionOther": "Other",
442
+ "sectionImageEffect": "Image GIFs (effect)",
443
+ "sectionImagePrompt": "Image GIFs (prompt)",
444
+ "sectionShape": "Shape GIFs",
445
+ "sectionText": "Text GIFs"
385
446
  },
386
447
  "text": {
387
448
  "heading": "Heading",
@@ -573,7 +634,9 @@
573
634
  "next": "Next",
574
635
  "replace": "Replace",
575
636
  "replaceAll": "Replace all",
576
- "chartHint": "Text inside charts is redrawn from the data, so it is skipped."
637
+ "chartHint": "Text inside charts is redrawn from the data, so it is skipped.",
638
+ "idle": "Searches the whole document",
639
+ "none": "No matches"
577
640
  },
578
641
  "pageToolbar": {
579
642
  "aiGenerate": "AI generate",
@@ -744,7 +807,15 @@
744
807
  "previewAlt": "Code preview",
745
808
  "previewEmpty": "Enter content to see a preview here.",
746
809
  "insert": "Add to canvas",
747
- "hint": "Codes are generated right in your browser. No credits are used."
810
+ "hint": "Codes are generated right in your browser. No credits are used.",
811
+ "eanInvalid": "Enter 12 digits and we'll add the check digit. With 13 digits, the check digit must match.",
812
+ "eanLabel": "Product barcode number",
813
+ "eanPlaceholder": "12 or 13 digits",
814
+ "kindEan": "Barcode",
815
+ "kindQr": "QR code",
816
+ "qrInvalid": "The content is too long to encode as a QR code.",
817
+ "valueLabel": "Link or text",
818
+ "valuePlaceholder": "https://…"
748
819
  },
749
820
  "bubbleTail": {
750
821
  "handle": "Drag the tail tip to move it (drag inside the body to remove the tail)"
@@ -775,10 +846,18 @@
775
846
  "brandShapes": "Brand shapes",
776
847
  "brandReferences": "My references",
777
848
  "layers": "Layers",
778
- "structure": "Structure"
849
+ "structure": "Structure",
850
+ "charts": "Charts",
851
+ "decorations": "Decor",
852
+ "icons": "Icons",
853
+ "qr": "QR",
854
+ "shapes": "Shapes",
855
+ "tables": "Tables"
779
856
  },
780
857
  "recents": {
781
- "title": "Favorites & recent"
858
+ "title": "Favorites & recent",
859
+ "pin": "Add to favorites",
860
+ "unpin": "Remove from favorites"
782
861
  },
783
862
  "crop": {
784
863
  "title": "Crop",
package/i18n/index.ts CHANGED
@@ -11,6 +11,16 @@ import en from "./en.json";
11
11
  * **번역기(i18next 인스턴스)는 소비자 것이다.** 패키지가 자기 인스턴스를 만들면
12
12
  * 언어 전환·네임스페이스 로딩이 두 벌로 갈라지므로, 여기서는 등록만 한다.
13
13
  */
14
+ /** 언어 → 네임스페이스 → 중첩 문구. 부분만 주면 준 자리만 바뀐다. */
15
+ export type DetailPageEditorTranslationOverrides = Record<
16
+ string,
17
+ Record<string, unknown>
18
+ >;
19
+
20
+ export type RegisterTranslationsOptions = {
21
+ overrides?: DetailPageEditorTranslationOverrides;
22
+ };
23
+
14
24
  export type I18nLike = {
15
25
  addResourceBundle: (
16
26
  lng: string,
@@ -30,8 +40,27 @@ export type I18nLike = {
30
40
  *
31
41
  * 렌더보다 **먼저** 부르면 된다. 늦게 불러도 i18next 가 다시 그리지만, 그 사이 한 번은
32
42
  * 키가 보인다.
43
+ *
44
+ * ## 문구를 갈아 끼우고 싶을 때
45
+ *
46
+ * 소비자가 이미 자기 로케일 파일에 같은 키를 갖고 있으면 위의 `overwrite=false` 덕에
47
+ * 저절로 이긴다. 하지만 i18n 라이브러리를 안 쓰는 앱은 그럴 자리가 없다 — 편집기 하나
48
+ * 때문에 로케일 파일 체계를 새로 세우게 된다. 그래서 여기서 직접 받는다.
49
+ *
50
+ * ```ts
51
+ * registerDetailPageEditorTranslations(i18n, {
52
+ * overrides: {
53
+ * ko: { branding: { detailPage: { sidebar: { photos: "이미지" } } } },
54
+ * },
55
+ * });
56
+ * ```
57
+ *
58
+ * 부분만 준다. 준 자리만 바뀌고 나머지는 우리 번들 그대로다.
33
59
  */
34
- export function registerDetailPageEditorTranslations(i18n: I18nLike): void {
60
+ export function registerDetailPageEditorTranslations(
61
+ i18n: I18nLike,
62
+ options: RegisterTranslationsOptions = {},
63
+ ): void {
35
64
  for (const [lng, bundle] of [
36
65
  ["ko", ko],
37
66
  ["en", en],
@@ -40,6 +69,14 @@ export function registerDetailPageEditorTranslations(i18n: I18nLike): void {
40
69
  i18n.addResourceBundle(lng, ns, resources, true, false);
41
70
  }
42
71
  }
72
+
73
+ // 덮어쓸 것을 **나중에** 얹는다. 위와 달리 `overwrite=true` 다 — 소비자가 일부러
74
+ // 준 문구가 우리 기본값에 지면 준 의미가 없다.
75
+ for (const [lng, bundle] of Object.entries(options.overrides ?? {})) {
76
+ for (const [ns, resources] of Object.entries(bundle ?? {})) {
77
+ i18n.addResourceBundle(lng, ns, resources, true, true);
78
+ }
79
+ }
43
80
  }
44
81
 
45
82
  export { ko as detailPageEditorKo, en as detailPageEditorEn };
package/i18n/ko.json CHANGED
@@ -143,7 +143,29 @@
143
143
  "weight600": "약간 굵게",
144
144
  "weight700": "굵게",
145
145
  "weight800": "아주 굵게",
146
- "weight900": "가장 굵게"
146
+ "weight900": "가장 굵게",
147
+ "alignInGroup": "그룹 내 정렬",
148
+ "alignInSection": "섹션 정렬",
149
+ "cellGridCols": "열 수",
150
+ "cellGridGif": "셀 차오름 GIF",
151
+ "cellGridGifHint": "채운 칸이 순서대로 켜진 뒤 2초 멈췄다 반복해요. 축·수치 글자는 캔버스에서 위에 얹으세요.",
152
+ "cellGridGifMake": "차트 GIF 만들기",
153
+ "cellGridInvalid": "행별 칸 수는 1개 이상이고 열 수를 넘을 수 없어요.",
154
+ "cellGridRows": "행별 채울 칸",
155
+ "cellGridShape": "모양",
156
+ "countUpGif": "숫자를 카운트업으로",
157
+ "countUpGifHint": "0에서 {{target}}까지 오른 뒤 2초 멈췄다 반복해요. 이 자리에 그대로 들어갑니다.",
158
+ "countUpGifMake": "카운트업 GIF 만들기",
159
+ "gifShapeUnreadable": "도형을 읽지 못했어요.",
160
+ "gifStageDetecting": "물체를 찾는 중…",
161
+ "gifStageEncoding": "GIF로 굽는 중…",
162
+ "gifStagePreparing": "준비 중…",
163
+ "gifStageRendering": "그리는 중",
164
+ "gifStageUploading": "저장 중…",
165
+ "imageGif": "이미지를 GIF로",
166
+ "imageGifCancel": "취소 (만들던 GIF는 '내 이미지'에 저장돼요)",
167
+ "imageGifMake": "GIF 만들기",
168
+ "imageGifMaskFallback": "물체를 특정하지 못해 이미지 전체에 적용했어요."
147
169
  },
148
170
  "gifEffects": {
149
171
  "text": {
@@ -229,6 +251,10 @@
229
251
  "label": "홀로그램 포일 (골드)",
230
252
  "hint": "금박 포일처럼 따뜻한 금색 광택이 훑고 지나갑니다."
231
253
  }
254
+ },
255
+ "group": {
256
+ "object": "이미지 속 물체",
257
+ "whole": "이미지 전체"
232
258
  }
233
259
  },
234
260
  "chart": {
@@ -284,7 +310,25 @@
284
310
  "detachHint": "도형과 글자로 풀어 자유롭게 편집합니다. 푼 뒤에는 값과 막대가 따로 놉니다.",
285
311
  "panelTitle": "차트",
286
312
  "insertHint": "클릭하여 캔버스에 삽입",
287
- "panelHint": "놓은 뒤 오른쪽에서 값을 고치면 막대 길이가 따라갑니다. 엑셀에서 복사해 붙여넣을 수도 있어요."
313
+ "panelHint": "놓은 뒤 오른쪽에서 값을 고치면 막대 길이가 따라갑니다. 엑셀에서 복사해 붙여넣을 수도 있어요.",
314
+ "presets": {
315
+ "barHCompare": "가로 막대 · 비교",
316
+ "barHMinimal": "간결 가로 막대",
317
+ "barHPercent": "가로 막대 · 퍼센트",
318
+ "barHRank": "가로 막대 · 순위",
319
+ "barVAxis": "축 있는 막대",
320
+ "barVCompare": "세로 막대 · 비교",
321
+ "barVPlain": "세로 막대 · 기본",
322
+ "barVRank": "순위 세로 막대",
323
+ "donutShare": "도넛 · 구성비",
324
+ "donutSingle": "단일 비율 도넛",
325
+ "gaugeRate": "게이지 · 달성률",
326
+ "gaugeScore": "점수 게이지",
327
+ "lineCompare": "비교 꺾은선",
328
+ "lineTrend": "꺾은선 · 추이",
329
+ "stackMix": "누적 · 구성",
330
+ "stackShare": "구성비 누적"
331
+ }
288
332
  },
289
333
  "table": {
290
334
  "typeTable": "표",
@@ -347,6 +391,19 @@
347
391
  "moveColumn": "끌어서 열 순서 바꾸기",
348
392
  "removeColumn": "{{column}}열 삭제",
349
393
  "insertColumn": "{{at}}번째 자리에 열 추가"
394
+ },
395
+ "preset": {
396
+ "gridCheck": "체크 비교표",
397
+ "gridCompare": "비교표",
398
+ "gridHeader": "머리글 표",
399
+ "gridPrice": "가격표",
400
+ "gridZebra": "줄무늬 비교표",
401
+ "specAiry": "여백형 스펙표",
402
+ "specBoxed": "상자형 스펙표",
403
+ "specCelled": "칸 구분 스펙표",
404
+ "specHairline": "헤어라인 스펙표",
405
+ "specRight": "값 우측정렬",
406
+ "typeCentered": "타입별 안내"
350
407
  }
351
408
  },
352
409
  "brandAssets": {
@@ -381,7 +438,11 @@
381
438
  "notAGif": "GIF 파일만 올릴 수 있어요.",
382
439
  "upload": "GIF 올리기",
383
440
  "empty": "아직 GIF가 없어요. 텍스트·이미지·도형을 골라 GIF로 만들어 보세요.",
384
- "sectionOther": "기타"
441
+ "sectionOther": "기타",
442
+ "sectionImageEffect": "이미지 GIF (이펙트)",
443
+ "sectionImagePrompt": "이미지 GIF (프롬프트)",
444
+ "sectionShape": "도형 GIF",
445
+ "sectionText": "텍스트 GIF"
385
446
  },
386
447
  "text": {
387
448
  "heading": "제목",
@@ -573,7 +634,9 @@
573
634
  "next": "다음",
574
635
  "replace": "바꾸기",
575
636
  "replaceAll": "모두 바꾸기",
576
- "chartHint": "차트 안 글자는 값에서 다시 그려지므로 빠집니다."
637
+ "chartHint": "차트 안 글자는 값에서 다시 그려지므로 빠집니다.",
638
+ "idle": "문서 전체에서 찾습니다",
639
+ "none": "없습니다"
577
640
  },
578
641
  "pageToolbar": {
579
642
  "aiGenerate": "AI 생성",
@@ -744,7 +807,15 @@
744
807
  "previewAlt": "코드 미리보기",
745
808
  "previewEmpty": "내용을 넣으면 여기에 미리보기가 나와요.",
746
809
  "insert": "캔버스에 넣기",
747
- "hint": "코드는 브라우저에서 바로 만들어져요. 크레딧이 들지 않아요."
810
+ "hint": "코드는 브라우저에서 바로 만들어져요. 크레딧이 들지 않아요.",
811
+ "eanInvalid": "12자리를 넣으면 검증번호를 채워 드려요. 13자리면 검증번호가 맞아야 해요.",
812
+ "eanLabel": "상품 바코드 번호",
813
+ "eanPlaceholder": "숫자 12자리 또는 13자리",
814
+ "kindEan": "바코드",
815
+ "kindQr": "QR 코드",
816
+ "qrInvalid": "내용이 너무 길어 QR로 만들 수 없어요.",
817
+ "valueLabel": "링크 또는 텍스트",
818
+ "valuePlaceholder": "https://…"
748
819
  },
749
820
  "bubbleTail": {
750
821
  "handle": "꼬리 끝을 끌어 위치를 바꿉니다 (몸통 안으로 끌면 꼬리가 사라집니다)"
@@ -775,10 +846,18 @@
775
846
  "brandShapes": "브랜드 도형",
776
847
  "brandReferences": "내 레퍼런스",
777
848
  "layers": "레이어",
778
- "structure": "구조"
849
+ "structure": "구조",
850
+ "charts": "차트",
851
+ "decorations": "장식",
852
+ "icons": "아이콘",
853
+ "qr": "QR",
854
+ "shapes": "도형",
855
+ "tables": "표"
779
856
  },
780
857
  "recents": {
781
- "title": "즐겨찾기 · 최근"
858
+ "title": "즐겨찾기 · 최근",
859
+ "pin": "즐겨찾기에 추가",
860
+ "unpin": "즐겨찾기에서 빼기"
782
861
  },
783
862
  "crop": {
784
863
  "title": "자르기",
package/index.ts CHANGED
@@ -22,3 +22,19 @@ export type * from "./types/detail-page-api";
22
22
  export type * from "./types/commerce";
23
23
 
24
24
  export { DetailPageEditor } from "./components/detail-page/detail-page-editor";
25
+
26
+ /**
27
+ * 편집기가 부르는 주소(라우트 · 정적 자산)를 앱에 맞춘다.
28
+ *
29
+ * `basePath` 를 쓰는 앱은 **반드시** 불러야 한다 — 루트 절대경로 `fetch` 는 Next 의
30
+ * basePath 를 안 타므로, 안 부르면 아이콘·사진·폰트가 앱 바깥을 두드린다.
31
+ */
32
+ export {
33
+ configureDetailPageEditor,
34
+ detailPageEditorConfig,
35
+ } from "./lib/detail-page/runtime-config";
36
+ export type {
37
+ DetailPageEditorAssets,
38
+ DetailPageEditorConfig,
39
+ DetailPageEditorEndpoints,
40
+ } from "./lib/detail-page/runtime-config";
@@ -15,6 +15,8 @@ import {
15
15
  type CatalogFont,
16
16
  } from "@leviosa-ai/konva";
17
17
 
18
+ import { editorAssetBase } from "../detail-page/runtime-config";
19
+
18
20
  export type FontOption = CatalogFont;
19
21
 
20
22
  /**
@@ -77,11 +79,15 @@ const COMMON_HANGUL_SAMPLE =
77
79
  * load. Stamping the bundle version into the URL makes each bundle its own cache key,
78
80
  * so a deploy can never be shadowed by the previous bundle's stylesheet.
79
81
  */
82
+ /**
83
+ * 번들의 뿌리는 설정에서 온다(`configureDetailPageEditor`). 기본값은 예전과 같은
84
+ * `/render-fonts` 라, 아무것도 안 부른 소비자에게는 주소가 그대로다.
85
+ */
80
86
  function bundleUrl(path: string): string {
81
- return `${path}?v=${LEVIOSA_KONVA_VERSION}`;
87
+ return `${editorAssetBase("fontBundle")}${path}?v=${LEVIOSA_KONVA_VERSION}`;
82
88
  }
83
89
 
84
- const LOCAL_FONT_CSS_URL = bundleUrl("/render-fonts/font-css.css");
90
+ const localFontCssUrl = () => bundleUrl("/font-css.css");
85
91
 
86
92
  export class FontLoadError extends Error {
87
93
  constructor(message: string) {
@@ -139,23 +145,23 @@ function ensureFontFaceStylesheets(
139
145
  for (const { family, weight } of requests) {
140
146
  const slug = slugifyFamily(family.trim());
141
147
  if (!slug || !LOCAL_FONT_FAMILY_SLUGS.has(slug)) {
142
- hrefs.push(LOCAL_FONT_CSS_URL);
148
+ hrefs.push(localFontCssUrl());
143
149
  continue;
144
150
  }
145
151
  const key = `${slug}\n${weight}`;
146
152
  if (seen.has(key)) continue;
147
153
  seen.add(key);
148
- hrefs.push(bundleUrl(`/render-fonts/family-css/${slug}-${weight}.css`));
154
+ hrefs.push(bundleUrl(`/family-css/${slug}-${weight}.css`));
149
155
  }
150
156
  return Promise.all(
151
157
  hrefs.map((href) =>
152
158
  ensureStylesheet(href).catch(() => {
153
159
  const slug = href.match(/family-css\/(.+)-\d+\.css\?/)?.[1];
154
160
  return slug
155
- ? ensureStylesheet(bundleUrl(`/render-fonts/family-css/${slug}.css`)).catch(() =>
156
- ensureStylesheet(LOCAL_FONT_CSS_URL),
161
+ ? ensureStylesheet(bundleUrl(`/family-css/${slug}.css`)).catch(() =>
162
+ ensureStylesheet(localFontCssUrl()),
157
163
  )
158
- : ensureStylesheet(LOCAL_FONT_CSS_URL);
164
+ : ensureStylesheet(localFontCssUrl());
159
165
  }),
160
166
  ),
161
167
  ).then(() => {});
@@ -285,7 +291,7 @@ export function prefetchFontVariants(requests: FontFaceRequest[]): void {
285
291
  * loads the font faces it uses through `loadFontFaces`.
286
292
  */
287
293
  export function loadAllGoogleFonts(): Promise<void> {
288
- return ensureStylesheet(LOCAL_FONT_CSS_URL).catch(() => {});
294
+ return ensureStylesheet(localFontCssUrl()).catch(() => {});
289
295
  }
290
296
 
291
297
  export function loadDefaultCardnewsFonts(): Promise<void> {