@leviosa-ai/detail-page-editor 0.6.0 → 0.6.2

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.
@@ -23,10 +23,7 @@ import {
23
23
  import { CanvasStoreContext } from "./canvas-observer";
24
24
  import { LeviosaCanvasWorkspace } from "./leviosa-canvas-workspace";
25
25
  import { DetailPagePagesTimeline } from "./detail-page-pages-timeline";
26
- import {
27
- SidePanel as LeviosaSidePanel,
28
- ZoomButtons as LeviosaZoomButtons,
29
- } from "@leviosa-ai/canvas";
26
+ import { SidePanel as LeviosaSidePanel } from "@leviosa-ai/canvas";
30
27
  import { createCanvasStore } from "@leviosa-ai/canvas/store";
31
28
  import { collectFontRequests } from "@leviosa-ai/canvas/render/use-document-fonts";
32
29
  import { ensureCanvasKey } from "../../lib/detail-page-canvas/canvas-key";
@@ -709,10 +706,6 @@ export function DetailPageEditor({
709
706
  {findReplace}
710
707
  <DetailPagePagesTimeline store={store} />
711
708
  </LeviosaCanvasWorkspace>
712
- {/* 배율은 오른쪽 아래로 — 가운데 아래는 삽입 띠 자리다(작업 영역이 그린다). */}
713
- <div className="absolute bottom-4 right-4 z-20 rounded-dpe-lg border border-dpe-ink-200 bg-dpe-surface/95 px-2 py-1 shadow-sm backdrop-blur-sm">
714
- <LeviosaZoomButtons store={store} />
715
- </div>
716
709
  </div>
717
710
  </div>
718
711
  );
@@ -23,6 +23,19 @@ type StoreLike = {
23
23
  selectPage: (id: string) => void;
24
24
  };
25
25
 
26
+ /**
27
+ * 이 띠가 차지하는 높이와, 떠 있는지 여부.
28
+ *
29
+ * 아래 띠(삽입·배율)가 이 위로 비켜서야 하는데, 그 셈을 작업 영역이 따로 하면
30
+ * 두 벌이 갈라진다 — 여기가 정본이고 높이는 아래 style 로 자기가 쓴다.
31
+ */
32
+ export const PAGES_TIMELINE_HEIGHT = 53;
33
+
34
+ export function pagesTimelineVisible(store: unknown): boolean {
35
+ const pages = (store as StoreLike | null)?.pages;
36
+ return Array.isArray(pages) && pages.length >= 2;
37
+ }
38
+
26
39
  export const DetailPagePagesTimeline = observer(function DetailPagePagesTimeline({
27
40
  store,
28
41
  }: {
@@ -36,11 +49,12 @@ export const DetailPagePagesTimeline = observer(function DetailPagePagesTimeline
36
49
  );
37
50
  const activeId = s.activePage?.id;
38
51
 
39
- if (s.pages.length < 2) return null;
52
+ if (!pagesTimelineVisible(s)) return null;
40
53
 
41
54
  return (
42
55
  <div
43
56
  data-dp-pages-timeline=""
57
+ style={{ minHeight: PAGES_TIMELINE_HEIGHT }}
44
58
  className="pointer-events-auto absolute inset-x-0 bottom-0 z-20 flex items-center gap-1.5 overflow-x-auto border-t border-dpe-ink-200 bg-dpe-surface/95 px-3 py-1.5 backdrop-blur-sm"
45
59
  >
46
60
  {s.pages.map((page, index) => {
@@ -31,12 +31,17 @@ import { CanvasInsertToolbar } from "./canvas-insert-toolbar";
31
31
  import { CanvasOverlayHost } from "./canvas-overlay-host";
32
32
  import { CanvasSelectionTools } from "./canvas-selection-tools";
33
33
  import { DetailPagePageToolbar } from "./detail-page-page-toolbar";
34
+ import {
35
+ PAGES_TIMELINE_HEIGHT,
36
+ pagesTimelineVisible,
37
+ } from "./detail-page-pages-timeline";
34
38
  import { detailPageThumbnailBus } from "./detail-page-thumbnail-bus";
35
39
  import { GifAnimator } from "./gif-animator";
36
40
  import { GroupDrillIn } from "./group-drill-in";
37
41
  import { HoverHighlightOverlay } from "./hover-highlight-overlay";
38
42
  import { CanvasSectionHeightHandle } from "./section-height-handle";
39
43
  import { loadEditorFont } from "../../lib/detail-page-canvas/editor-fonts";
44
+ import { ZoomButtons } from "@leviosa-ai/canvas";
40
45
  import { CanvasView } from "@leviosa-ai/canvas/render/canvas-view";
41
46
  import { useCanvasVersion } from "@leviosa-ai/canvas/use-canvas";
42
47
  import type { CanvasStore } from "@leviosa-ai/canvas/store";
@@ -49,6 +54,9 @@ const clamp = (v: number, lo: number, hi: number) =>
49
54
  /** 썸네일 해상도. 페이지 패널의 칸이 작아 이 정도면 충분하다. */
50
55
  const THUMB_PIXEL_RATIO = 0.12;
51
56
 
57
+ /** 아래 띠가 가장자리·화면 목록에서 떨어지는 거리. */
58
+ const DOCK_GAP = 16;
59
+
52
60
  export function LeviosaCanvasWorkspace({
53
61
  store,
54
62
  gap = 4,
@@ -299,19 +307,37 @@ export function LeviosaCanvasWorkspace({
299
307
 
300
308
  {children}
301
309
 
302
- {/* 삽입 띠 — 글상자·기본 도형을 좌측 패널까지 가지 않고 넣는다. 화면 아래 가운데는
303
- 피그마·캔바가 모두 쓰는 자리라 손이 먼저 간다(배율은 오른쪽 아래로 비켰다). */}
310
+ {/* 아래 띠 한 줄 — 가운데는 삽입(글상자·기본 도형), 오른쪽 끝은 배율. 화면 아래
311
+ 가운데는 피그마·캔바가 모두 쓰는 자리라 손이 먼저 간다.
312
+
313
+ 화면 목록(가로 띠)이 떠 있으면 그 높이만큼 위로 비킨다 — 안 그러면 두 띠가
314
+ 겹쳐서 아래 것이 안 눌린다. 높이와 표시 규칙은 그 띠가 들고 있다. */}
304
315
  <div
305
- data-dp-insert-dock=""
316
+ data-dp-bottom-dock=""
306
317
  style={{
307
318
  position: "absolute",
308
- left: "50%",
309
- bottom: 16,
310
- transform: "translateX(-50%)",
319
+ left: 0,
320
+ right: 0,
321
+ bottom: pagesTimelineVisible(store)
322
+ ? PAGES_TIMELINE_HEIGHT + DOCK_GAP
323
+ : DOCK_GAP,
311
324
  zIndex: 30,
325
+ display: "flex",
326
+ alignItems: "center",
327
+ justifyContent: "center",
328
+ pointerEvents: "none",
312
329
  }}
313
330
  >
314
- <CanvasInsertToolbar store={store} />
331
+ <div data-dp-insert-dock="" style={{ pointerEvents: "auto" }}>
332
+ <CanvasInsertToolbar store={store} />
333
+ </div>
334
+ <div
335
+ data-dp-zoom-dock=""
336
+ style={{ position: "absolute", right: DOCK_GAP, pointerEvents: "auto" }}
337
+ className="rounded-dpe-lg border border-dpe-ink-200 bg-dpe-surface/95 px-2 py-1 shadow-sm backdrop-blur-sm"
338
+ >
339
+ <ZoomButtons store={store} />
340
+ </div>
315
341
  </div>
316
342
 
317
343
  {store.activePage ? (
@@ -73,6 +73,19 @@ export const MAX_REFERENCE_FILE_BYTES = 12 * 1024 * 1024;
73
73
  /** 줄인 **뒤** 서버가 받아 주는 한 장의 크기. 서버의 ``MAX_REFERENCE_BYTES`` 와 같다. */
74
74
  export const MAX_REFERENCE_UPLOAD_BYTES = 4 * 1024 * 1024;
75
75
 
76
+ /**
77
+ * 세로로 긴 캡쳐 한 장의 상한. 위 값보다 크다.
78
+ *
79
+ * 서버의 한 장 상한(``MAX_REFERENCE_BYTES``)은 **안 나눈 한 장**에만 걸린다
80
+ * (``_normalize_data_uri`` 의 ``len(pieces) == 1``). 세로로 긴 캡쳐는 서버가 밴드로
81
+ * 나누므로 그 상한을 안 지나가고, 대신 나누기 전 상한(16MB)과 합계 상한(18MB)이
82
+ * 받아 준다. 여기서 4MB 로 막으면 **폭을 살려 보내려던 그림이 붙는 자리에서 거절된다** —
83
+ * 그러면 유저는 다시 긴 변을 줄인 띠를 넣게 되고, 사고가 그대로 돌아온다.
84
+ *
85
+ * 파일 상한과 같은 값이다: 붙이는 자리에서 이미 통과시킨 크기를 다시 막지 않는다.
86
+ */
87
+ export const MAX_TALL_REFERENCE_UPLOAD_BYTES = MAX_REFERENCE_FILE_BYTES;
88
+
76
89
  /** 붙일 수 없는 파일이면 이유를, 괜찮으면 null. */
77
90
  export function referenceFileRejection(file: File): string | null {
78
91
  const type = String(file.type || "").toLowerCase();
@@ -126,8 +139,11 @@ export async function finalizeReferenceDataUri(
126
139
  original: string,
127
140
  ): Promise<{ uri: string; inputTokens: number } | { error: string }> {
128
141
  const shrunk = await shrinkReferenceDataUri(original);
129
- if (dataUriByteLength(shrunk.uri) > MAX_REFERENCE_UPLOAD_BYTES) {
130
- const limit = Math.round(MAX_REFERENCE_UPLOAD_BYTES / (1024 * 1024));
142
+ const cap = shrunk.tall
143
+ ? MAX_TALL_REFERENCE_UPLOAD_BYTES
144
+ : MAX_REFERENCE_UPLOAD_BYTES;
145
+ if (dataUriByteLength(shrunk.uri) > cap) {
146
+ const limit = Math.round(cap / (1024 * 1024));
131
147
  return {
132
148
  error: `참고 사진을 ${limit}MB 아래로 줄이지 못했어요. 더 작은 그림을 써 주세요.`,
133
149
  };
@@ -6,13 +6,52 @@
6
6
  * 구성뿐이라 긴 변 1,024px 이면 충분하다. 비용도 여기서 갈린다 — 비전 입력은 픽셀이
7
7
  * 아니라 타일 수로 계산되므로 줄인 만큼 그대로 싸진다.
8
8
  *
9
+ * ## ⚠ 세로로 긴 캡쳐에 긴 변 상한을 쓰면 안 된다
10
+ *
11
+ * 위 문단은 **정사각에 가까운 그림에서만** 맞다. 상세페이지 전체 캡쳐는 긴 변이
12
+ * 세로다. 실측 900×39418 짜리 캡쳐에 긴 변 1,024 를 걸면 **23×1024** 가 나온다 —
13
+ * 폭 23픽셀에는 섹션 경계도 장식도 글자도 남지 않는다. 에러도 경고도 없이 통과하고,
14
+ * 서버는 그 띠를 정상 입력으로 받는다. 저작이 "레퍼런스를 안 닮는" 사고의 실제 원인이
15
+ * 이것이었다(2026-08-14, job e538ce45 — 명세·저작 두 턴이 모두 그 띠를 봤다).
16
+ *
17
+ * 서버는 세로로 긴 캡쳐를 폭을 살려 밴드로 나눠 싣는다
18
+ * (`app/services/detail_page/design_reference.py` 의 ``_slice_into_bands``). 그러려면
19
+ * **폭이 살아 있는 그림이 서버에 닿아야 한다.** 그래서 여기서는 세로로 긴 그림을
20
+ * 만나면 긴 변이 아니라 **폭만** 기준으로 삼고, 줄일 것이 없으면 원본을 손대지 않고
21
+ * 그대로 보낸다.
22
+ *
9
23
  * JPEG 로 굳힌다. 레퍼런스는 사진이지 도형이 아니라서 무손실이 필요 없고, PNG 로 두면
10
24
  * 같은 그림이 서너 배 무겁다.
11
25
  */
12
26
 
13
- /** 긴 변 상한. 이보다 작은 그림은 확대하지 않는다. */
27
+ /** 긴 변 상한. **세로로 긴 캡쳐에는 쓰지 않는다**(머리말 참조). */
14
28
  export const REFERENCE_MAX_EDGE = 1024;
15
29
 
30
+ /**
31
+ * 세로로 긴 캡쳐로 보는 종횡비(세로/가로). 서버의 ``BAND_TRIGGER_RATIO`` 와 같은 값이다 —
32
+ * 여기서 통과시킨 그림을 저쪽이 밴드로 나누므로, 두 문턱이 어긋나면 "폭은 살렸는데
33
+ * 서버는 안 나누는" 구간이 생긴다.
34
+ */
35
+ export const TALL_TRIGGER_RATIO = 2.5;
36
+
37
+ /** 세로로 긴 캡쳐에서 지키는 폭. 서버의 밴드 폭(``BAND_WIDTH_PX``)과 같다. */
38
+ export const TALL_MAX_WIDTH = 1024;
39
+
40
+ /**
41
+ * 세로로 긴 캡쳐에서 더는 못 내려가는 폭. 서버의 ``BAND_MIN_WIDTH_PX`` 와 같다.
42
+ * 이 아래로 내려가면 밴드로 나눠도 글자를 못 읽으므로, 줄이는 대신 원본을 보낸다.
43
+ */
44
+ export const TALL_MIN_WIDTH = 640;
45
+
46
+ /**
47
+ * 캔버스로 다시 그릴 수 있는 넓이 상한.
48
+ *
49
+ * iOS Safari 는 16,777,216px 를 넘는 캔버스에 그리면 **예외 없이 빈 그림**을 내놓는다.
50
+ * 세로로 긴 캡쳐는 이 상한을 쉽게 넘으므로(900×39418 = 3,548만), 넘을 것 같으면
51
+ * 다시 그리지 않고 원본을 그대로 보낸다 — 빈 그림을 보내는 것보다 무거운 원본이 낫다.
52
+ */
53
+ export const MAX_CANVAS_PIXELS = 16_000_000;
54
+
16
55
  const QUALITY = 0.82;
17
56
 
18
57
  /**
@@ -59,12 +98,79 @@ export function readImageFileAsDataUri(file: File): Promise<string> {
59
98
  return readAsDataUri(file);
60
99
  }
61
100
 
101
+ /**
102
+ * 한 장을 어떻게 줄일지에 대한 판정. 캔버스 없이 **크기만으로** 정해진다.
103
+ *
104
+ * 순수 함수로 떼어 둔 이유는 이 판정이 사고가 났던 자리이기 때문이다 — 캔버스가 필요한
105
+ * 코드 안에 묻어 두면 jsdom 에서 못 재고, 못 재는 규칙은 조용히 다시 틀어진다.
106
+ */
107
+ export type ResizePlan = {
108
+ /** 세로로 긴 캡쳐인가. 상한을 고르는 쪽(``finalizeReferenceDataUri``)이 읽는다. */
109
+ tall: boolean;
110
+ /** 다시 그리지 않고 원본을 그대로 보낼 것인가. */
111
+ passThrough: boolean;
112
+ /** 보낼 그림의 크기. ``passThrough`` 면 원본 크기 그대로다. */
113
+ width: number;
114
+ height: number;
115
+ };
116
+
117
+ /**
118
+ * 보낼 크기를 정한다.
119
+ *
120
+ * - **세로로 긴 캡쳐**(세로 ≥ 가로 × {@link TALL_TRIGGER_RATIO}): 폭만 기준으로 줄인다.
121
+ * 폭이 이미 {@link TALL_MAX_WIDTH} 안이면 손대지 않는다 — 다시 그릴 이유가 없고,
122
+ * 캔버스 넓이 상한도 안 건드린다. 줄여야 하는데 캔버스 넓이 상한을 넘거나 폭이
123
+ * {@link TALL_MIN_WIDTH} 아래로 떨어지면 **줄이기를 포기하고 원본을 보낸다**(서버가
124
+ * 밴드로 나누며 자기 예산에 맞춘다).
125
+ * - 그 밖의 그림: 예전처럼 긴 변 {@link REFERENCE_MAX_EDGE}.
126
+ */
127
+ export function planReferenceResize(width: number, height: number): ResizePlan {
128
+ const w = Math.max(0, Math.floor(Number(width) || 0));
129
+ const h = Math.max(0, Math.floor(Number(height) || 0));
130
+ if (!w || !h) return { tall: false, passThrough: true, width: w, height: h };
131
+
132
+ const tall = h >= w * TALL_TRIGGER_RATIO;
133
+ if (!tall) {
134
+ const scale = Math.min(1, REFERENCE_MAX_EDGE / Math.max(w, h));
135
+ if (scale >= 1) return { tall, passThrough: true, width: w, height: h };
136
+ return {
137
+ tall,
138
+ passThrough: false,
139
+ width: Math.max(1, Math.round(w * scale)),
140
+ height: Math.max(1, Math.round(h * scale)),
141
+ };
142
+ }
143
+
144
+ let scale = Math.min(1, TALL_MAX_WIDTH / w);
145
+ if (w * h * scale * scale > MAX_CANVAS_PIXELS) {
146
+ scale = Math.min(scale, Math.sqrt(MAX_CANVAS_PIXELS / (w * h)));
147
+ }
148
+ if (scale >= 1 || w * scale < TALL_MIN_WIDTH) {
149
+ // 줄일 것이 없거나, 줄이면 읽을 수 없어진다. 둘 다 원본이 답이다.
150
+ return { tall, passThrough: true, width: w, height: h };
151
+ }
152
+ return {
153
+ tall,
154
+ passThrough: false,
155
+ width: Math.max(1, Math.round(w * scale)),
156
+ height: Math.max(1, Math.round(h * scale)),
157
+ };
158
+ }
159
+
62
160
  /**
63
161
  * 줄이기 결과. ``width``/``height`` 는 **보낼 그림의 크기**다 — 판독 값이 그 크기로
64
162
  * 정해지므로(비전 입력은 타일 수로 청구된다) 원본이 아니라 줄인 뒤 크기여야 한다.
65
163
  * 디코드가 안 되면 0 이고, 그때는 부르는 쪽이 "모른다"로 다뤄야 한다.
164
+ *
165
+ * ``tall`` 은 세로로 긴 캡쳐라 **원본 폭을 지키고 보낸다**는 표시다. 그 그림은 서버가
166
+ * 밴드로 나누므로 한 장짜리 바이트 상한을 그대로 걸면 안 된다.
66
167
  */
67
- export type ShrunkReference = { uri: string; width: number; height: number };
168
+ export type ShrunkReference = {
169
+ uri: string;
170
+ width: number;
171
+ height: number;
172
+ tall: boolean;
173
+ };
68
174
 
69
175
  /**
70
176
  * 이미 읽어 둔 data URI 를 줄인다. 디코드나 캔버스가 안 되면 **원본을 그대로** 돌려준다 —
@@ -77,27 +183,34 @@ export async function shrinkReferenceDataUri(
77
183
  // 순서를 뒤집으면 줄이지도 못할 그림을 몇 초씩 기다렸다가 포기하게 된다.
78
184
  const canvas = document.createElement("canvas");
79
185
  const ctx = canvas.getContext("2d");
80
- if (!ctx) return { uri: original, width: 0, height: 0 };
186
+ if (!ctx) return { uri: original, width: 0, height: 0, tall: false };
81
187
 
82
188
  try {
83
189
  const img = await loadImage(original);
84
- const longest = Math.max(img.naturalWidth, img.naturalHeight);
85
- if (!longest) return { uri: original, width: 0, height: 0 };
86
- const scale = Math.min(1, REFERENCE_MAX_EDGE / longest);
87
- if (scale >= 1) {
190
+ const plan = planReferenceResize(img.naturalWidth, img.naturalHeight);
191
+ if (!plan.width || !plan.height) {
192
+ return { uri: original, width: 0, height: 0, tall: false };
193
+ }
194
+ if (plan.passThrough) {
88
195
  // 줄일 것이 없어도 크기는 안다 — 여기서 0 을 내면 안 줄인 그림만 값이 비싸진다.
89
- return { uri: original, width: img.naturalWidth, height: img.naturalHeight };
196
+ return {
197
+ uri: original,
198
+ width: plan.width,
199
+ height: plan.height,
200
+ tall: plan.tall,
201
+ };
90
202
  }
91
203
 
92
- canvas.width = Math.max(1, Math.round(img.naturalWidth * scale));
93
- canvas.height = Math.max(1, Math.round(img.naturalHeight * scale));
204
+ canvas.width = plan.width;
205
+ canvas.height = plan.height;
94
206
  ctx.drawImage(img, 0, 0, canvas.width, canvas.height);
95
207
  return {
96
208
  uri: canvas.toDataURL("image/jpeg", QUALITY),
97
209
  width: canvas.width,
98
210
  height: canvas.height,
211
+ tall: plan.tall,
99
212
  };
100
213
  } catch {
101
- return { uri: original, width: 0, height: 0 };
214
+ return { uri: original, width: 0, height: 0, tall: false };
102
215
  }
103
216
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@leviosa-ai/detail-page-editor",
3
- "version": "0.6.0",
3
+ "version": "0.6.2",
4
4
  "description": "Leviosa detail-page editor shell. Source-only: consumers compile it through Next transpilePackages. Every app coupling (sourcing server, brand store, cache keys, toast) arrives through the DetailPageHost interface.",
5
5
  "type": "module",
6
6
  "license": "UNLICENSED",