@leviosa-ai/detail-page-editor 0.12.1 → 0.13.1

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.
@@ -19,6 +19,7 @@ import {
19
19
  } from "../ui/select";
20
20
  import type { ExportDocument } from "../../lib/detail-page-canvas/export/document-model";
21
21
  import { detectGifPages } from "../../lib/detail-page-canvas/export/gif-plan";
22
+ import { activeFramePages } from "../../lib/detail-page/frame-pages";
22
23
  import { isMp4EncodeSupported } from "../../lib/detail-page-canvas/export/mp4-support";
23
24
  import {
24
25
  detailPageEditorProfile,
@@ -43,6 +44,8 @@ type ExportPageLike = {
43
44
  id: string;
44
45
  computedWidth: number;
45
46
  computedHeight: number;
47
+ /** 이 판이 속한 프레임 이름이 여기 산다(`custom.frame`). */
48
+ custom?: unknown;
46
49
  };
47
50
 
48
51
  type ExportStoreLike = {
@@ -199,7 +202,10 @@ export const DetailPageDownloadDialog = observer(function DetailPageDownloadDial
199
202
 
200
203
  const selectedPages = useMemo(() => {
201
204
  if (scope === "current" && s.activePage) return [s.activePage];
202
- return s.pages;
205
+ // 후보 여러 벌이 한 문서에 있으면 «전체»는 문서 전체가 아니라 **보고 있는 한 벌**
206
+ // 이다. 넷을 다 내보내면 마흔 장짜리 묶음이 나오고, 그걸 원해서 누르는 사람은
207
+ // 없다. 꼬리표 없는 문서에서는 지금까지처럼 전부다.
208
+ return activeFramePages(s.pages, s.activePage?.id);
203
209
  }, [scope, s.activePage, s.pages]);
204
210
 
205
211
  const docWidth = useMemo(
@@ -117,6 +117,16 @@ export type DetailPageEditorProps = {
117
117
  structurePanel?: ReactNode;
118
118
  /** 헤더에 표시할 현재 상품/상세페이지 이름. 없으면 기본 라벨. */
119
119
  productName?: string;
120
+ /**
121
+ * 결과물이 될 벌 — 내려받기·발행이 향하는 곳.
122
+ *
123
+ * 문서에 안 적고 밖에서 받는다. 후보를 담은 화면은 이미 서버가 «고른 후보»를
124
+ * 들고 있고(목록 썸네일과 레퍼런스가 읽는 자리가 그것이다), 두 곳에 적어 두면
125
+ * 언젠가 서로 다른 답을 한다.
126
+ */
127
+ chosenFrame?: string;
128
+ /** 안 주면 벌에 체크를 안 그린다. */
129
+ onChooseFrame?: (frameKey: string) => void;
120
130
  /** 헤더 좌측 "뒤로가기" 동작. 없으면 버튼을 숨긴다. */
121
131
  onBack?: () => void;
122
132
  /**
@@ -152,6 +162,8 @@ export function DetailPageEditor({
152
162
  generatedId,
153
163
  structurePanel,
154
164
  productName,
165
+ chosenFrame,
166
+ onChooseFrame,
155
167
  onBack,
156
168
  headerActions,
157
169
  }: DetailPageEditorProps) {
@@ -289,14 +301,19 @@ export function DetailPageEditor({
289
301
  />
290
302
  )}
291
303
  <div className="relative min-w-0 flex-1">
292
- <LeviosaCanvasWorkspace store={store} gap={4}>
304
+ <LeviosaCanvasWorkspace
305
+ store={store}
306
+ gap={4}
307
+ chosenFrame={chosenFrame}
308
+ onChooseFrame={onChooseFrame}
309
+ >
293
310
  {findReplace}
294
311
  <DetailPagePagesTimeline store={store} />
295
312
  </LeviosaCanvasWorkspace>
296
313
  </div>
297
314
  </div>
298
315
  );
299
- }, [store, sidebarSections, SidebarSlot]);
316
+ }, [store, sidebarSections, SidebarSlot, chosenFrame, onChooseFrame]);
300
317
 
301
318
  const aiValue = useMemo(
302
319
  () => ({
@@ -0,0 +1,103 @@
1
+ "use client";
2
+
3
+ /**
4
+ * 벌 위에 붙는 것 — **이게 결과물인가**, 그리고 **바꾸는 길**.
5
+ *
6
+ * ## 화면에 라벨은 최대 둘
7
+ *
8
+ * 넷에 다 라벨을 달면 «라벨 넷»으로 읽히고, 무엇이 상태이고 무엇이 행동인지 모양으로
9
+ * 구분이 안 된다. Shopify 의 테마 목록도 Vercel 의 배포 목록도 반대로 한다 — 지금
10
+ * 나가는 것 **하나에만** 표를 달고, 바꾸는 행동은 조용한 곳에 둔다.
11
+ *
12
+ * 대표인 벌 → «대표» 글씨. 누를 것이 아니라 상태다
13
+ * 보고 있는 벌 → «대표로 지정» 테두리 버튼. 누르면 바뀐다
14
+ * 나머지 → 아무것도 없음
15
+ *
16
+ * 고르려면 먼저 그 벌을 본다 — 이미 하던 동작이라 클릭이 하나 느는 것이 아니다.
17
+ *
18
+ * ## 뱃지에 손잡이를 달지 않는다
19
+ *
20
+ * 상태를 나타내는 표는 누르는 것이 아니고, 누르는 것은 버튼처럼 생겨야 한다. 둘을
21
+ * 같은 모양으로 두면 어느 쪽이 무엇인지 읽어 봐야 안다. 그래서 대표는 **글씨**,
22
+ * 지정은 **테두리 있는 버튼**이다.
23
+ *
24
+ * ## 왜 «최종»이 아닌가
25
+ *
26
+ * 언제든 바꾸는 값인데 «최종»이라 적으면 되돌릴 수 없다는 느낌을 준다 — 누르기 전에
27
+ * 한 번 망설이게 된다. 게다가 계속 고칠 문서라 최종도 아니다. «대표»는 여럿 중 밖으로
28
+ * 나가는 하나라는 뜻이 이미 붙어 있는 말이고(대표 사진), 실제로 이 값이 목록 카드
29
+ * 썸네일과 레퍼런스로 나가는 판까지 정한다.
30
+ *
31
+ * ## 흰 판 위가 아니라 회색 바닥 위
32
+ *
33
+ * 작업물을 가리지 않는 자리다. 피그마가 프레임 이름을 두는 그 자리이기도 하다.
34
+ */
35
+
36
+ /** 이 줄이 차지하는 높이(화면 px). 배율과 무관하게 읽혀야 하므로 안 줄인다. */
37
+ export const FRAME_HEAD_HEIGHT = 22;
38
+
39
+ const COPY = {
40
+ chosen: "대표",
41
+ choose: "대표로 지정",
42
+ hint: "내려받기와 발행은 대표만 나갑니다",
43
+ };
44
+
45
+ export function DetailPageFrameHeader({
46
+ chosen,
47
+ selected,
48
+ onChoose,
49
+ }: {
50
+ chosen: boolean;
51
+ /** 지금 보고 있는 벌인가. 여기에만 바꾸는 버튼이 뜬다. */
52
+ selected: boolean;
53
+ /** 안 주면 아무것도 안 그린다 — 고를 것이 없는 문서도 있다. */
54
+ onChoose?: () => void;
55
+ }) {
56
+ if (!onChoose) return null;
57
+
58
+ const place = {
59
+ position: "absolute" as const,
60
+ left: 2,
61
+ bottom: "100%" as const,
62
+ marginBottom: 4,
63
+ height: FRAME_HEAD_HEIGHT - 4,
64
+ whiteSpace: "nowrap" as const,
65
+ };
66
+
67
+ if (chosen) {
68
+ return (
69
+ <span
70
+ style={place}
71
+ title={COPY.hint}
72
+ className="flex items-center gap-1 text-[11px] font-dpe-bold text-dpe-ink-900"
73
+ >
74
+ <svg width="11" height="11" viewBox="0 0 16 16" aria-hidden="true">
75
+ <path
76
+ d="M3 8.5 6.5 12 13 4.5"
77
+ fill="none"
78
+ stroke="currentColor"
79
+ strokeWidth="2.6"
80
+ strokeLinecap="round"
81
+ strokeLinejoin="round"
82
+ />
83
+ </svg>
84
+ {COPY.chosen}
85
+ </span>
86
+ );
87
+ }
88
+
89
+ // 보고 있지 않은 벌은 아무 말도 안 한다. 누르면 보게 되고, 그때 이 버튼이 뜬다.
90
+ if (!selected) return null;
91
+
92
+ return (
93
+ <button
94
+ type="button"
95
+ style={place}
96
+ title={COPY.hint}
97
+ onClick={onChoose}
98
+ className="flex items-center rounded-full border border-dpe-ink-400 bg-dpe-surface px-2 text-[11px] font-dpe-semibold text-dpe-ink-700 transition-colors hover:border-dpe-ink-900 hover:text-dpe-ink-900"
99
+ >
100
+ {COPY.choose}
101
+ </button>
102
+ );
103
+ }
@@ -1,6 +1,7 @@
1
1
  "use client";
2
2
 
3
3
  import { detailPageEditorProfile } from "../../lib/detail-page/editor-profile";
4
+ import { activeFramePages } from "../../lib/detail-page/frame-pages";
4
5
 
5
6
  import { Fragment, useCallback, useSyncExternalStore } from "react";
6
7
  import { observer } from "./canvas-observer";
@@ -151,11 +152,14 @@ const PageRow = observer(function PageRow({
151
152
  store,
152
153
  page,
153
154
  index,
155
+ canDuplicate,
154
156
  thumb,
155
157
  }: {
156
158
  store: StoreLike;
157
159
  page: PageLike;
158
160
  index: number;
161
+ /** 이 벌에 한 장을 더 넣을 수 있는가. 세는 것은 목록이 한다. */
162
+ canDuplicate: boolean;
159
163
  thumb: string | undefined;
160
164
  }) {
161
165
  const { t } = useTranslation("branding");
@@ -171,7 +175,6 @@ const PageRow = observer(function PageRow({
171
175
  const active = store.activePage?.id === page.id;
172
176
  const ratio = page.computedHeight / Math.max(1, page.computedWidth);
173
177
  const { role, title } = pageLabel(page, index, t);
174
- const maxPages = detailPageEditorProfile().maxPages;
175
178
 
176
179
  const style: React.CSSProperties = {
177
180
  transform: CSS.Transform.toString(transform),
@@ -238,7 +241,7 @@ const PageRow = observer(function PageRow({
238
241
  <div className="flex shrink-0 flex-col gap-1">
239
242
  <button
240
243
  type="button"
241
- disabled={store.pages.length >= maxPages}
244
+ disabled={!canDuplicate}
242
245
  onClick={(event) => {
243
246
  event.stopPropagation();
244
247
  page.clone?.();
@@ -311,22 +314,26 @@ export const DetailPagePagesPanel = observer(function DetailPagePagesPanel({
311
314
  // 합계가 곧 문서 높이지만, 캐러셀은 판이 따로따로라 합계가 아무 뜻이 없다 —
312
315
  // 8판짜리가 1080×10800 으로 보였다.
313
316
  const profile = detailPageEditorProfile();
314
- const width = Math.round(s.pages[0]?.computedWidth ?? 0);
317
+ // 목록은 «지금 보고 있는 한 벌»만 담는다. 후보 넷을 담은 문서에서 마흔 줄을
318
+ // 늘어놓으면 어느 줄이 어느 벌인지 알 수가 없다.
319
+ const pages = activeFramePages(s.pages, s.activePage?.id);
320
+ const width = Math.round(pages[0]?.computedWidth ?? 0);
315
321
  const shownHeight = profile.page.fixed
316
322
  ? Math.round(
317
323
  typeof profile.page.height === "number"
318
324
  ? profile.page.height
319
- : (s.pages[0]?.computedHeight ?? 0),
325
+ : (pages[0]?.computedHeight ?? 0),
320
326
  )
321
- : Math.round(s.pages.reduce((acc, p) => acc + p.computedHeight, 0));
327
+ : Math.round(pages.reduce((acc, p) => acc + p.computedHeight, 0));
322
328
 
323
- const canAdd = s.pages.length < profile.maxPages;
329
+ // 장 수 상한도 프레임마다다 — 한 벌이 열 장이라고 다른 벌까지 막을 이유가 없다.
330
+ const canAdd = pages.length < profile.maxPages;
324
331
 
325
332
  return (
326
333
  <div className="flex h-full flex-col">
327
334
  <div className="flex items-baseline justify-between px-4 py-3">
328
335
  <p className="text-base font-dpe-bold text-dpe-ink-900">
329
- {t("detailPage.pages.totalPages", { count: s.pages.length })}
336
+ {t("detailPage.pages.totalPages", { count: pages.length })}
330
337
  </p>
331
338
  <p className="text-xs text-dpe-ink-400">
332
339
  {width} × {shownHeight} px
@@ -341,17 +348,18 @@ export const DetailPagePagesPanel = observer(function DetailPagePagesPanel({
341
348
  onDragEnd={handleDragEnd}
342
349
  >
343
350
  <SortableContext
344
- items={s.pages.map((p) => p.id)}
351
+ items={pages.map((p) => p.id)}
345
352
  strategy={verticalListSortingStrategy}
346
353
  >
347
354
  <div className="space-y-2">
348
- {s.pages.map((page, index) => (
355
+ {pages.map((page, index) => (
349
356
  <Fragment key={page.id}>
350
357
  <PageRow
351
358
  store={s}
352
359
  page={page}
353
360
  index={index}
354
- thumb={detailPageThumbnailBus.get(page.id)}
361
+ canDuplicate={canAdd}
362
+ thumb={detailPageThumbnailBus.get(s, page.id)}
355
363
  />
356
364
  {/* 새 화면은 «어디에» 가 먼저다. 목록 끝에 버튼 하나를 두면 넣고 나서
357
365
  다시 끌어 옮기게 되므로, 넣을 자리마다 하나씩 둔다. */}
@@ -15,6 +15,7 @@ import { useSyncExternalStore } from "react";
15
15
 
16
16
  import { detailPageThumbnailBus } from "./detail-page-thumbnail-bus";
17
17
  import { observer } from "./canvas-observer";
18
+ import { activeFramePages } from "../../lib/detail-page/frame-pages";
18
19
 
19
20
  type PageLike = { id: string; name?: unknown };
20
21
  type StoreLike = {
@@ -48,6 +49,8 @@ export const DetailPagePagesTimeline = observer(function DetailPagePagesTimeline
48
49
  () => 0,
49
50
  );
50
51
  const activeId = s.activePage?.id;
52
+ // 아래 띠도 목록과 같은 것을 본다 — 둘 다 «지금 어디 있나»를 말하는 물건이다.
53
+ const pages = activeFramePages(s.pages, activeId);
51
54
 
52
55
  if (!pagesTimelineVisible(s)) return null;
53
56
 
@@ -57,9 +60,9 @@ export const DetailPagePagesTimeline = observer(function DetailPagePagesTimeline
57
60
  style={{ minHeight: PAGES_TIMELINE_HEIGHT }}
58
61
  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"
59
62
  >
60
- {s.pages.map((page, index) => {
63
+ {pages.map((page, index) => {
61
64
  const active = page.id === activeId;
62
- const thumb = detailPageThumbnailBus.get(page.id);
65
+ const thumb = detailPageThumbnailBus.get(s, page.id);
63
66
  return (
64
67
  <button
65
68
  key={page.id}
@@ -1,7 +1,7 @@
1
1
  "use client";
2
2
 
3
3
  /**
4
- * Tiny cross-component cache for page thumbnails (data URLs), keyed by page id.
4
+ * Tiny cross-component cache for page thumbnails (data URLs), keyed by store + page id.
5
5
  *
6
6
  * The stacked workspace owns the live Konva canvases (it renders the real
7
7
  * ``<Page>`` components), so it captures thumbnails from the DOM canvas there and
@@ -12,22 +12,33 @@
12
12
  * download for the same reason — real exports are server-side).
13
13
  */
14
14
 
15
- const cache = new Map<string, string>();
15
+ // 캐러셀마다 `p01`부터 다시 쓰므로 page id만 키로 쓰면 다른 문서의 그림이 섞인다.
16
+ // WeakMap이면 편집기를 닫았을 때 그 문서의 data URL도 같이 버려진다.
17
+ const cache = new WeakMap<object, Map<string, string>>();
16
18
  const listeners = new Set<() => void>();
17
19
  let version = 0;
18
20
 
21
+ function entries(scope: object): Map<string, string> {
22
+ const found = cache.get(scope);
23
+ if (found) return found;
24
+ const created = new Map<string, string>();
25
+ cache.set(scope, created);
26
+ return created;
27
+ }
28
+
19
29
  export const detailPageThumbnailBus = {
20
- get(id: string): string | undefined {
21
- return cache.get(id);
30
+ get(scope: object, id: string): string | undefined {
31
+ return cache.get(scope)?.get(id);
22
32
  },
23
- set(id: string, dataUrl: string): void {
24
- if (cache.get(id) === dataUrl) return;
25
- cache.set(id, dataUrl);
33
+ set(scope: object, id: string, dataUrl: string): void {
34
+ const scoped = entries(scope);
35
+ if (scoped.get(id) === dataUrl) return;
36
+ scoped.set(id, dataUrl);
26
37
  version += 1;
27
38
  listeners.forEach((l) => l());
28
39
  },
29
- has(id: string): boolean {
30
- return cache.has(id);
40
+ has(scope: object, id: string): boolean {
41
+ return cache.get(scope)?.has(id) ?? false;
31
42
  },
32
43
  // Monotonic version for useSyncExternalStore — changes whenever a thumbnail lands.
33
44
  getVersion(): number {
@@ -0,0 +1,65 @@
1
+ /**
2
+ * 손잡이와 끌기 층을 잇는 한 줄.
3
+ *
4
+ * 손잡이는 **판 상자 안**에 그려진다(엔진의 판 위 슬롯). 끌리는 동안 보이는 것들은
5
+ * 모든 판 **위**에 뜬 층이 그린다. 둘은 트리에서 멀리 떨어져 있는데, 그 사이로
6
+ * props 를 나르자고 여러 겹을 뚫는 것보다 이 한 줄이 싸다 — 이 저장소가 썸네일과
7
+ * 화면 다시 만들기를 잇는 방식과 같다.
8
+ */
9
+
10
+ type Starter = (pageId: string, event: React.PointerEvent) => void;
11
+
12
+ let starter: Starter | null = null;
13
+
14
+ /**
15
+ * 지금 끼어들 자리. 판들이 **밀려나며** 만드는 빈칸이라, 이 값은 판을 그리는 쪽이
16
+ * 읽어야 한다 — 위에 겹쳐 그리면 밑에 있는 판을 가릴 뿐이고 «사이가 벌어졌다»가
17
+ * 안 된다.
18
+ */
19
+ export type FrameInsert = {
20
+ frameKey: string;
21
+ /** 그 벌 안에서 몇 번째 자리인가. */
22
+ at: number;
23
+ /** 빈칸 높이(화면 px) — 끌고 있는 판의 높이다. */
24
+ height: number;
25
+ /** 넘쳐서 못 놓는 자리인가. */
26
+ full: boolean;
27
+ } | null;
28
+
29
+ let insert: FrameInsert = null;
30
+ const listeners = new Set<() => void>();
31
+
32
+ export function setFrameInsert(next: FrameInsert): void {
33
+ const same =
34
+ insert === next ||
35
+ (insert &&
36
+ next &&
37
+ insert.frameKey === next.frameKey &&
38
+ insert.at === next.at &&
39
+ insert.full === next.full &&
40
+ insert.height === next.height);
41
+ if (same) return;
42
+ insert = next;
43
+ for (const listener of [...listeners]) listener();
44
+ }
45
+
46
+ export function getFrameInsert(): FrameInsert {
47
+ return insert;
48
+ }
49
+
50
+ export function subscribeFrameInsert(listener: () => void): () => void {
51
+ listeners.add(listener);
52
+ return () => {
53
+ listeners.delete(listener);
54
+ };
55
+ }
56
+
57
+ /** 끌기 층이 자기를 걸어 둔다. */
58
+ export function setFrameDragStarter(fn: Starter | null): void {
59
+ starter = fn;
60
+ }
61
+
62
+ /** 손잡이가 부른다. 층이 없으면 아무 일도 안 일어난다. */
63
+ export function startFrameDrag(pageId: string, event: React.PointerEvent): void {
64
+ starter?.(pageId, event);
65
+ }
@@ -0,0 +1,37 @@
1
+ "use client";
2
+
3
+ /**
4
+ * 판을 다른 벌로 끌 때 잡는 자리.
5
+ *
6
+ * **판 상자 안에** 산다. 예전에는 마우스 자리를 재어 판 위에 띄웠는데, 손잡이가 판
7
+ * 모서리에 있다 보니 바깥에서 다가가는 손은 «판 밖»을 지나 온다 — 그때마다 사라져서
8
+ * 잡으러 가는 내내 깜빡였다. 크기를 키워도 그대로였다. 안에 있으면 잴 것이 없고
9
+ * 깜빡일 일도 없다.
10
+ *
11
+ * **흐리게 두지 않는다.** 옅게 깔아 두면 «있는 줄 몰라서 못 잡는» 물건이 된다.
12
+ * 20px 짜리라 늘 또렷해도 시끄럽지 않다.
13
+ */
14
+
15
+ import { GripVertical } from "lucide-react";
16
+
17
+ import { startFrameDrag } from "./frame-drag-bus";
18
+
19
+ export function FrameDragGrip({ pageId }: { pageId: string }) {
20
+ return (
21
+ <button
22
+ type="button"
23
+ data-dp-frame-grip=""
24
+ title="다른 벌로 끌어오기"
25
+ aria-label="이 판을 다른 벌로 끌어오기"
26
+ onPointerDown={(event) => {
27
+ event.preventDefault();
28
+ event.stopPropagation();
29
+ startFrameDrag(pageId, event);
30
+ }}
31
+ style={{ position: "absolute", left: 5, top: 5, width: 20, height: 20, zIndex: 6 }}
32
+ className="flex cursor-grab items-center justify-center rounded bg-dpe-surface/80 text-dpe-ink-800 transition-colors hover:bg-dpe-ink-900 hover:text-dpe-on-accent"
33
+ >
34
+ <GripVertical aria-hidden="true" size={13} />
35
+ </button>
36
+ );
37
+ }
@@ -0,0 +1,261 @@
1
+ "use client";
2
+
3
+ /**
4
+ * 판을 **다른 벌로 끌어오는 층.**
5
+ *
6
+ * ## 왜 캔버스가 아니라 여기서 하나
7
+ *
8
+ * 판마다 제 무대(Konva Stage)가 따로다. 판 위의 것을 끌어 그 무대 밖으로 나가면 그
9
+ * 캔버스에 **잘려서 사라진다** — 끌고 있다는 느낌 자체가 없다. 그래서 끌리는 동안
10
+ * 보이는 것은 무대가 아니라 모든 무대 **위에 뜬 이 층**이 그린다.
11
+ *
12
+ * 잡는 자리(손잡이)는 여기 없다. 그건 판 상자 안에 산다(`frame-drag-grip`) — 밖에서
13
+ * 자리를 재어 띄우면 손이 다가가는 동안 깜빡인다. 둘은 한 줄짜리 다리로 잇는다.
14
+ *
15
+ * ## 끄는 동안 문서는 한 글자도 안 바뀐다
16
+ *
17
+ * 놓는 순간에만 바꾼다. 그래야 중간에 그만두거나 창을 닫아도 남는 것이 없고,
18
+ * 되돌리기도 «놓은 것» 한 단계로 깔끔하다.
19
+ *
20
+ * ## 열 장은 놓기 **전에** 막는다
21
+ *
22
+ * 넘겨 놓고 서버가 거절하게 두면, 3초마다 도는 자동저장이 «저장 실패»만 띄우고
23
+ * 사람은 무엇을 되돌려야 하는지 모른다. 여기서 미리 막고 이유를 한 줄 적는다.
24
+ */
25
+
26
+ import { useCallback, useEffect, useRef, useState } from "react";
27
+
28
+ import { copyPageToFrame } from "@leviosa-ai/canvas/edit/commands";
29
+ import { frameInsertIndex, frameOf } from "@leviosa-ai/canvas/render/frames";
30
+ import type { CanvasStore } from "@leviosa-ai/canvas/store";
31
+ import { detailPageEditorProfile } from "../../lib/detail-page/editor-profile";
32
+ import { setFrameDragStarter, setFrameInsert } from "./frame-drag-bus";
33
+
34
+ /** 벌어지는 칸의 높이 — 끌고 있는 판 높이의 이만큼. */
35
+ const SLOT_RATIO = 0.28;
36
+
37
+ type Box = { left: number; top: number; width: number; height: number };
38
+
39
+ type Drop = {
40
+ frameKey: string;
41
+ /** 그 벌 안에서 몇 번째 자리인가. */
42
+ at: number;
43
+ /** 놓일 벌 전체(층 좌표). 여기를 통째로 밝혀 «저기로 간다»를 말한다. */
44
+ area: Box;
45
+ /** 넘쳐서 못 놓는가. */
46
+ full: boolean;
47
+ };
48
+
49
+ function boxIn(node: Element, host: DOMRect): Box {
50
+ const rect = node.getBoundingClientRect();
51
+ return {
52
+ left: rect.left - host.left,
53
+ top: rect.top - host.top,
54
+ width: rect.width,
55
+ height: rect.height,
56
+ };
57
+ }
58
+
59
+ export function FrameDragLayer({
60
+ store,
61
+ containerRef,
62
+ }: {
63
+ store: CanvasStore;
64
+ containerRef: React.RefObject<HTMLDivElement | null>;
65
+ }) {
66
+ const [drag, setDrag] = useState<{
67
+ pageId: string;
68
+ from: string;
69
+ box: Box;
70
+ x: number;
71
+ y: number;
72
+ drop: Drop | null;
73
+ } | null>(null);
74
+ const dragRef = useRef(drag);
75
+ dragRef.current = drag;
76
+
77
+ /** 커서 아래에서 놓을 자리를 읽는다. DOM 에서 직접 잰다 — 배율·스크롤이 다 반영돼 있다. */
78
+ const readDrop = useCallback(
79
+ (clientX: number, clientY: number): Drop | null => {
80
+ const host = containerRef.current?.getBoundingClientRect();
81
+ if (!host) return null;
82
+ const frames = Array.from(
83
+ document.querySelectorAll<HTMLElement>("[data-lc-frame]"),
84
+ );
85
+ if (!frames.length) return null;
86
+
87
+ // 커서를 품은 열, 없으면 가로로 제일 가까운 열.
88
+ const scored = frames.map((node) => {
89
+ const rect = node.getBoundingClientRect();
90
+ return {
91
+ node,
92
+ dx: Math.max(rect.left - clientX, 0, clientX - rect.right),
93
+ };
94
+ });
95
+ scored.sort((a, b) => a.dx - b.dx);
96
+ const target = scored[0];
97
+ const frameKey = target.node.dataset.lcFrame ?? "";
98
+
99
+ const pages = Array.from(
100
+ target.node.querySelectorAll<HTMLElement>("[data-lc-page]"),
101
+ );
102
+ // 커서보다 위에서 끝난 판의 개수가 곧 끼어들 자리다.
103
+ let at = pages.length;
104
+ for (let index = 0; index < pages.length; index += 1) {
105
+ const rect = pages[index].getBoundingClientRect();
106
+ if (clientY < rect.top + rect.height / 2) {
107
+ at = index;
108
+ break;
109
+ }
110
+ }
111
+
112
+ const held = store.pages.filter((page) => frameOf(page) === frameKey).length;
113
+ return {
114
+ frameKey,
115
+ at,
116
+ area: boxIn(target.node, host),
117
+ full: held >= detailPageEditorProfile().maxPages,
118
+ };
119
+ },
120
+ [containerRef, store],
121
+ );
122
+
123
+ // 손잡이가 부를 자리를 걸어 둔다.
124
+ useEffect(() => {
125
+ setFrameDragStarter((pageId, event) => {
126
+ const host = containerRef.current?.getBoundingClientRect();
127
+ const node = document.querySelector<HTMLElement>(
128
+ `[data-lc-page="${CSS.escape(pageId)}"]`,
129
+ );
130
+ const page = store.getPageById(pageId);
131
+ if (!host || !node || !page) return;
132
+ setDrag({
133
+ pageId,
134
+ from: frameOf(page),
135
+ box: boxIn(node, host),
136
+ x: event.clientX - host.left,
137
+ y: event.clientY - host.top,
138
+ drop: null,
139
+ });
140
+ });
141
+ return () => setFrameDragStarter(null);
142
+ }, [containerRef, store]);
143
+
144
+ // 끄는 동안은 창 전체를 듣는다 — 커서가 판 밖으로 나가도 따라가야 한다.
145
+ useEffect(() => {
146
+ if (!drag) return;
147
+ const host = containerRef.current?.getBoundingClientRect();
148
+ if (!host) return;
149
+ const dragBox = drag.box;
150
+
151
+ const onMove = (event: PointerEvent) => {
152
+ const drop = readDrop(event.clientX, event.clientY);
153
+ // 빈칸은 판을 그리는 쪽이 흐름 안에 넣는다 — 그래야 판들이 실제로 밀린다.
154
+ // 여기서는 «어느 벌 몇 번째»만 말한다.
155
+ setFrameInsert(
156
+ drop
157
+ ? {
158
+ frameKey: drop.frameKey,
159
+ at: drop.at,
160
+ // 판 한 장만큼 벌리면 열이 통째로 쿵 내려가서 놀란다. 자리가
161
+ // 생겼다는 것만 보이면 되므로 그보다 얕게 연다.
162
+ height: Math.max(24, dragBox.height * SLOT_RATIO),
163
+ full: drop.full,
164
+ }
165
+ : null,
166
+ );
167
+ setDrag((current) =>
168
+ current
169
+ ? {
170
+ ...current,
171
+ x: event.clientX - host.left,
172
+ y: event.clientY - host.top,
173
+ drop,
174
+ }
175
+ : current,
176
+ );
177
+ };
178
+ const onUp = () => {
179
+ const current = dragRef.current;
180
+ setDrag(null);
181
+ setFrameInsert(null);
182
+ if (!current?.drop || current.drop.full) return;
183
+ // 같은 벌 안이면 순서를 바꾼다. 예전에는 아무 일도 안 일어나서 «놓아도
184
+ // 그대로»라고 적어 줘야 했는데, 그건 설명이 필요한 동작이라는 뜻이었다.
185
+ if (current.drop.frameKey === current.from) {
186
+ const page = store.getPageById(current.pageId);
187
+ if (!page) return;
188
+ const from = store.pages.indexOf(page);
189
+ let to = frameInsertIndex(store.pages, current.drop.frameKey, current.drop.at);
190
+ // 빼고 나면 뒤쪽 자리는 한 칸 당겨진다.
191
+ if (from < to) to -= 1;
192
+ if (from !== to) page.setZIndex(to);
193
+ return;
194
+ }
195
+ copyPageToFrame(store, current.pageId, current.drop.frameKey, current.drop.at);
196
+ };
197
+ window.addEventListener("pointermove", onMove);
198
+ window.addEventListener("pointerup", onUp);
199
+ return () => {
200
+ window.removeEventListener("pointermove", onMove);
201
+ window.removeEventListener("pointerup", onUp);
202
+ };
203
+ }, [containerRef, drag, readDrop, store]);
204
+
205
+ if (!drag) return null;
206
+
207
+ return (
208
+ <>
209
+ {/* 끌리는 판. 무대 밖에서도 보여야 하므로 여기서 그린다. */}
210
+ <div
211
+ style={{
212
+ position: "absolute",
213
+ left: drag.x - drag.box.width / 2,
214
+ top: drag.y - drag.box.height / 2,
215
+ width: drag.box.width,
216
+ height: drag.box.height,
217
+ zIndex: 40,
218
+ pointerEvents: "none",
219
+ }}
220
+ className="rounded-dpe-md border-2 border-dpe-ink-900 bg-dpe-ink-900/10"
221
+ />
222
+
223
+ {drag.drop ? (
224
+ <>
225
+ {/* 놓일 벌을 통째로 밝힌다. 가는 선 하나로는 «어디로 가는지»가 안 읽힌다. */}
226
+ <div
227
+ style={{
228
+ position: "absolute",
229
+ left: drag.drop.area.left,
230
+ top: drag.drop.area.top,
231
+ width: drag.drop.area.width,
232
+ height: drag.drop.area.height,
233
+ zIndex: 39,
234
+ pointerEvents: "none",
235
+ borderRadius: 10,
236
+ outline: `3px solid ${drag.drop.full ? "#c0392b" : "#2563eb"}`,
237
+ background: drag.drop.full
238
+ ? "rgba(192, 57, 43, 0.06)"
239
+ : "rgba(37, 99, 235, 0.06)",
240
+ }}
241
+ />
242
+ {/* 못 놓는 경우에만 이유를 적는다. 되는 경우는 벌어진 자리가 대신 말한다. */}
243
+ {drag.drop.full ? (
244
+ <span
245
+ style={{
246
+ position: "absolute",
247
+ left: drag.x + 16,
248
+ top: drag.y + 16,
249
+ zIndex: 42,
250
+ pointerEvents: "none",
251
+ }}
252
+ className="rounded-dpe-md bg-dpe-danger-600 px-2 py-1 text-[11px] font-dpe-semibold text-dpe-on-accent"
253
+ >
254
+ {detailPageEditorProfile().maxPages}장이 최대입니다
255
+ </span>
256
+ ) : null}
257
+ </>
258
+ ) : null}
259
+ </>
260
+ );
261
+ }
@@ -22,6 +22,7 @@ import {
22
22
  useMemo,
23
23
  useRef,
24
24
  useState,
25
+ useSyncExternalStore,
25
26
  type ReactNode,
26
27
  } from "react";
27
28
 
@@ -36,6 +37,12 @@ import {
36
37
  pagesTimelineVisible,
37
38
  } from "./detail-page-pages-timeline";
38
39
  import { detailPageThumbnailBus } from "./detail-page-thumbnail-bus";
40
+ import { FrameDragGrip } from "./frame-drag-grip";
41
+ import {
42
+ getFrameInsert,
43
+ subscribeFrameInsert,
44
+ } from "./frame-drag-bus";
45
+ import { FrameDragLayer } from "./frame-drag-layer";
39
46
  import { GifAnimator } from "./gif-animator";
40
47
  import { GroupDrillIn } from "./group-drill-in";
41
48
  import { HoverHighlightOverlay } from "./hover-highlight-overlay";
@@ -43,6 +50,11 @@ import { CanvasSectionHeightHandle } from "./section-height-handle";
43
50
  import { loadEditorFont } from "../../lib/detail-page-canvas/editor-fonts";
44
51
  import { ZoomButtons } from "@leviosa-ai/canvas";
45
52
  import { CanvasView } from "@leviosa-ai/canvas/render/canvas-view";
53
+ import { frameOf, groupFrames } from "@leviosa-ai/canvas/render/frames";
54
+ import {
55
+ DetailPageFrameHeader,
56
+ FRAME_HEAD_HEIGHT,
57
+ } from "./detail-page-frame-header";
46
58
  import { useCanvasVersion } from "@leviosa-ai/canvas/use-canvas";
47
59
  import type { CanvasStore } from "@leviosa-ai/canvas/store";
48
60
 
@@ -51,6 +63,24 @@ const MAX_SCALE = 5;
51
63
  const clamp = (v: number, lo: number, hi: number) =>
52
64
  Math.max(lo, Math.min(hi, v));
53
65
 
66
+ /**
67
+ * 벌 사이 거리 — **판 좌표**로 잰다.
68
+ *
69
+ * 화면 px 로 고정하면 확대했을 때는 붙어 보이고 축소했을 때는 벌어져 보인다. 판
70
+ * 하나가 1080 인 캐러셀에서 이 값은 판의 4분의 1쯤이라, 어느 배율에서 보든 «저건
71
+ * 다른 벌»이 한눈에 잡힌다.
72
+ */
73
+ const FRAME_GAP_DOC = 240;
74
+
75
+ /**
76
+ * 벌 위의 줄이 설 자리(화면 px).
77
+ *
78
+ * 그 줄은 흰 판 **밖**, 회색 바닥 위에 앉는다 — 작업물을 안 가리는 자리이고 피그마가
79
+ * 프레임 이름을 두는 자리다. 대신 작업 영역 위쪽에 그만큼을 비워 둬야 한다. 안 그러면
80
+ * 스크롤 영역 바깥으로 잘려서 아예 안 보인다(실제로 그렇게 사라져 있었다).
81
+ */
82
+ const FRAME_HEAD = FRAME_HEAD_HEIGHT + 6;
83
+
54
84
  /** 썸네일 해상도. 페이지 패널의 칸이 작아 이 정도면 충분하다. */
55
85
  const THUMB_PIXEL_RATIO = 0.12;
56
86
 
@@ -62,12 +92,18 @@ export function LeviosaCanvasWorkspace({
62
92
  gap = 4,
63
93
  paddingX = 16,
64
94
  backgroundColor = "rgb(241, 241, 241)",
95
+ chosenFrame,
96
+ onChooseFrame,
65
97
  children,
66
98
  }: {
67
99
  store: CanvasStore;
68
100
  gap?: number;
69
101
  paddingX?: number;
70
102
  backgroundColor?: string;
103
+ /** 결과물이 될 벌. 내려받기·발행이 향하는 곳이다. */
104
+ chosenFrame?: string;
105
+ /** 안 주면 체크박스를 안 그린다 — 고를 것이 없는 문서도 있다. */
106
+ onChooseFrame?: (frameKey: string) => void;
71
107
  /** 작업 영역 위에 얹을 것(찾기·바꾸기 같은 편집기 고유 층). */
72
108
  children?: ReactNode;
73
109
  }) {
@@ -108,8 +144,21 @@ export function LeviosaCanvasWorkspace({
108
144
  const page = store.activePage ?? store.pages[0];
109
145
  const usableW = Math.max(1, viewport.width - 2 * paddingX);
110
146
  const usableH = Math.max(1, viewport.height - 2 * gap);
147
+ // 프레임이 여럿이면 **열 전체**가 가로로 들어와야 한다 — 한 벌만 보이면
148
+ // 나란히 놓은 뜻이 없다. 세로는 여전히 한 장 기준이다: 시안 하나가 스무
149
+ // 장이 넘는 상세페이지에서 기둥 전체를 넣으면 아무것도 안 읽힌다.
150
+ const frames = groupFrames(store.pages);
151
+ const spread =
152
+ frames.length > 1
153
+ ? frames.reduce(
154
+ (sum, frame) =>
155
+ sum + Math.max(1, ...frame.pages.map((one) => one.width)),
156
+ 0,
157
+ ) +
158
+ (frames.length - 1) * FRAME_GAP_DOC
159
+ : page.width;
111
160
  const next = clamp(
112
- Math.min(usableW / page.width, usableH / page.height) * 0.94,
161
+ Math.min(usableW / spread, usableH / page.height) * 0.94,
113
162
  MIN_SCALE,
114
163
  MAX_SCALE,
115
164
  );
@@ -152,15 +201,28 @@ export function LeviosaCanvasWorkspace({
152
201
  const recomputeActive = useCallback(() => {
153
202
  const inner = innerRef.current;
154
203
  if (!inner) return;
155
- const middle = inner.getBoundingClientRect().top + inner.clientHeight / 2;
204
+ // 화면 한가운데를 **점으로** 잡는다. 열이 하나뿐이던 때는 세로만 봐도 답이
205
+ // 하나였지만, 열이 여럿이면 그 높이를 지나는 페이지가 열 수만큼 나온다 —
206
+ // 세로만 보면 언제나 맨 왼쪽 열이 이겨서 다른 벌을 고를 수가 없다.
207
+ const frame = inner.getBoundingClientRect();
208
+ const cx = frame.left + inner.clientWidth / 2;
209
+ const cy = frame.top + inner.clientHeight / 2;
156
210
  const nodes = Array.from(
157
211
  inner.querySelectorAll<HTMLElement>("[data-lc-page]"),
158
212
  );
159
- const hit =
160
- nodes.find((node) => {
161
- const box = node.getBoundingClientRect();
162
- return box.top <= middle && box.bottom >= middle;
163
- }) ?? nodes[0];
213
+ const distance = (node: HTMLElement) => {
214
+ const box = node.getBoundingClientRect();
215
+ const dx = Math.max(box.left - cx, 0, cx - box.right);
216
+ const dy = Math.max(box.top - cy, 0, cy - box.bottom);
217
+ return dx * dx + dy * dy;
218
+ };
219
+ // 가운데를 품은 페이지는 거리가 0이라 그대로 이긴다. 아무것도 안 품으면
220
+ // (확대해서 빈 자리를 보고 있을 때) 제일 가까운 것으로 떨어진다.
221
+ const hit = nodes.reduce<HTMLElement | undefined>(
222
+ (best, node) =>
223
+ !best || distance(node) < distance(best) ? node : best,
224
+ undefined,
225
+ );
164
226
  const id = hit?.dataset.lcPage;
165
227
  if (!id || store.activePage?.id === id) return;
166
228
  scrollSetId.current = id;
@@ -197,29 +259,66 @@ export function LeviosaCanvasWorkspace({
197
259
  // 굽는 동안 그 페이지를 화면 밖에서도 그려야 해서다 — 30장을 한꺼번에 띄우면
198
260
  // 브라우저가 멈춘다.
199
261
  const panelOpen = store.openedSidePanel === "pages";
262
+ const panelOpenRef = useRef(panelOpen);
263
+ panelOpenRef.current = panelOpen;
264
+ const dirtyThumbnailIds = useRef(new Set<string>());
265
+ const [thumbnailRevision, setThumbnailRevision] = useState(0);
266
+
267
+ // 요소 속성 변경은 page.version을 올리지 않는다. 문서 변경 알림에서 현재 페이지만
268
+ // 더럽다고 적어 두고, 패널이 열려 있을 때만 아래 굽기 작업을 깨운다.
269
+ useEffect(
270
+ () =>
271
+ store.on("change", () => {
272
+ const id = store.activePage?.id;
273
+ if (id) dirtyThumbnailIds.current.add(id);
274
+ if (panelOpenRef.current) setThumbnailRevision((value) => value + 1);
275
+ }),
276
+ [store],
277
+ );
278
+
200
279
  useEffect(() => {
201
280
  if (!panelOpen) return;
202
281
  let cancelled = false;
203
- void (async () => {
204
- for (const page of store.pages) {
205
- if (cancelled) return;
206
- if (detailPageThumbnailBus.has(page.id)) continue;
207
- try {
208
- const uri = await store.toDataURL({
209
- pageId: page.id,
210
- pixelRatio: THUMB_PIXEL_RATIO,
211
- });
282
+ // 타이핑·드래그 중 매 프레임 다시 굽지 않고, 손을 잠깐 놓았을 때 바뀐 페이지만 굽는다.
283
+ const timer = window.setTimeout(() => {
284
+ void (async () => {
285
+ for (const page of store.pages) {
212
286
  if (cancelled) return;
213
- detailPageThumbnailBus.set(page.id, uri);
214
- } catch {
215
- // 못 구운 페이지는 다음에 다시 시도한다(패널을 다시 열면 온다).
287
+ if (
288
+ !dirtyThumbnailIds.current.has(page.id) &&
289
+ detailPageThumbnailBus.has(store, page.id)
290
+ ) {
291
+ continue;
292
+ }
293
+ try {
294
+ const uri = await store.toDataURL({
295
+ pageId: page.id,
296
+ pixelRatio: THUMB_PIXEL_RATIO,
297
+ });
298
+ if (cancelled) return;
299
+ dirtyThumbnailIds.current.delete(page.id);
300
+ detailPageThumbnailBus.set(store, page.id, uri);
301
+ } catch {
302
+ // 못 구운 페이지는 다음에 다시 시도한다(패널을 다시 열면 온다).
303
+ }
216
304
  }
217
- }
218
- })();
305
+ })();
306
+ }, 250);
219
307
  return () => {
220
308
  cancelled = true;
309
+ window.clearTimeout(timer);
221
310
  };
222
- }, [panelOpen, pageIds, store]);
311
+ }, [panelOpen, pageIds, store, thumbnailRevision]);
312
+
313
+ const frameCount = groupFrames(store.pages).length;
314
+ // 끼어들 자리는 끌기 층이 정하고, 빈칸은 판을 그리는 쪽이 흐름 안에 넣는다.
315
+ const frameInsert = useSyncExternalStore(
316
+ subscribeFrameInsert,
317
+ getFrameInsert,
318
+ () => null,
319
+ );
320
+ // 보고 있는 벌 — 활성 페이지가 속한 벌이다. 목록·아래 띠가 보는 것과 같다.
321
+ const activeFrame = frameOf(store.activePage ?? store.pages[0] ?? {});
223
322
 
224
323
  const pageWidth = useMemo(
225
324
  () => Math.max(1, ...store.pages.map((page) => page.width)),
@@ -249,27 +348,68 @@ export function LeviosaCanvasWorkspace({
249
348
  // 페이지 바깥의 빈 자리를 누르면 선택 해제. 페이지 안은 캔버스가 처리한다.
250
349
  const target = event.target as HTMLElement;
251
350
  if (
252
- !target.closest("[data-lc-page]") &&
253
- !target.closest("[data-dp-quicktoolbar]")
351
+ target.closest("[data-lc-page]") ||
352
+ target.closest("[data-dp-quicktoolbar]")
254
353
  ) {
255
- store.selectElements([]);
354
+ return;
256
355
  }
356
+ store.selectElements([]);
357
+ // 판 밖이어도 벌의 빈 자리를 눌렀으면 그 벌로 간다 — 이름표를 없앴으니
358
+ // 여기가 «저 벌을 보겠다»고 말하는 유일한 자리다.
359
+ const key = target.closest<HTMLElement>("[data-lc-frame]")?.dataset
360
+ .lcFrame;
361
+ if (key === undefined) return;
362
+ const first = store.pages.find((page) => frameOf(page) === key);
363
+ if (first) store.selectPage(first.id);
257
364
  }}
258
365
  style={{
259
366
  position: "absolute",
260
367
  inset: 0,
261
- overflowX: "hidden",
368
+ // 프레임이 하나뿐이면 예전 그대로다. 가운데 정렬은 내용이 넘칠 때
369
+ // 왼쪽으로 스크롤을 못 하게 만드는 자리라, 열이 여럿일 때는 왼쪽에
370
+ // 붙여 놓고 가로 스크롤을 연다.
371
+ overflowX: frameCount > 1 ? "auto" : "hidden",
262
372
  overflowY: "auto",
263
373
  display: "flex",
264
374
  flexDirection: "column",
265
- alignItems: "center",
266
- padding: `${gap}px ${paddingX}px`,
375
+ alignItems: frameCount > 1 ? "flex-start" : "center",
376
+ padding: `${frameCount > 1 ? gap + FRAME_HEAD : gap}px ${paddingX}px ${gap}px`,
267
377
  }}
268
378
  >
269
379
  <CanvasView
270
380
  store={store}
271
381
  scale={scale}
272
382
  gap={gap}
383
+ // 열이 여럿이면 가운데 정렬을 정렬 속성이 아니라 자동 여백으로 준다 —
384
+ // 축소해서 남는 자리가 생겨도 가운데를 지키고, 커져도 스크롤이 산다.
385
+ center={frameCount > 1}
386
+ // 손잡이는 판 상자 **안**에 산다. 밖에서 자리를 재어 띄우면 손이 다가가는
387
+ // 동안 «판 밖»을 지나며 깜빡인다.
388
+ renderPageChrome={
389
+ frameCount > 1 ? (id) => <FrameDragGrip pageId={id} /> : undefined
390
+ }
391
+ frameGap={FRAME_GAP_DOC * scale}
392
+ frameInsert={frameInsert}
393
+ renderFrameHeader={(key) => (
394
+ <DetailPageFrameHeader
395
+ chosen={key === chosenFrame}
396
+ selected={key === activeFrame}
397
+ onChoose={onChooseFrame ? () => onChooseFrame(key) : undefined}
398
+ />
399
+ )}
400
+ frameStyle={(key) => ({
401
+ padding: 8,
402
+ borderRadius: 10,
403
+ // 회색 바닥 위의 **흰 판** — 피그마의 프레임이 그렇다. 판을 얹을 자리가
404
+ // 밝아야 «저기서 저기까지가 한 벌»이 읽힌다.
405
+ background: "#ffffff",
406
+ border: `1px solid ${key === activeFrame ? "rgba(0,0,0,0.45)" : "rgba(0,0,0,0.10)"}`,
407
+ // **흐리게 하지 않는다.** 한때 «보고 있거나 대표인 것만 선명»으로 뒀는데,
408
+ // 이 화면은 후보를 **견주는** 자리다 — 넷 중 둘이 바래 있으면 견줄 수가
409
+ // 없고, 판을 옮기면 색이 달라져서 옮긴 것이 변한 것처럼 보인다.
410
+ // 무엇이 나가는지는 «대표» 표가, 무엇을 보고 있는지는 테두리가 이미 말한다.
411
+ transition: "border-color 0.15s ease",
412
+ })}
273
413
  interactive
274
414
  loadFont={loadEditorFont}
275
415
  />
@@ -295,6 +435,9 @@ export function LeviosaCanvasWorkspace({
295
435
  scrollRef={innerRef}
296
436
  />
297
437
  <GroupDrillIn store={store} containerRef={outerRef} />
438
+ {/* 판을 다른 벌로 끌어오는 층. 무대마다 캔버스가 따로라, 끌리는 동안 보이는
439
+ 것은 무대가 아니라 그 위에 뜬 이 층이 그린다. */}
440
+ <FrameDragLayer store={store} containerRef={outerRef} />
298
441
  <GifAnimator store={store} />
299
442
  {/* 활성 화면의 아래 끝을 잡아 끌어 길이를 바꾸는 손잡이. 우측 패널의 숫자와
300
443
  같은 함수를 거친다(`section-height.ts`) — 배경 요소까지 같이 늘리고 서버
@@ -0,0 +1,27 @@
1
+ import { frameOf } from "@leviosa-ai/canvas/render/frames";
2
+
3
+ type PageLike = { id: string; custom?: unknown };
4
+
5
+ /**
6
+ * 지금 **보고 있는 프레임**의 페이지들.
7
+ *
8
+ * 후보 여러 벌을 한 문서에 담으면 페이지 목록과 아래 띠가 곧장 쓸모를 잃는다 — 열두
9
+ * 줄짜리 목록에서 어느 줄이 어느 벌인지 알 수가 없다. 그 둘은 «지금 어디 있나»를
10
+ * 말하는 물건이라, 보고 있는 한 벌만 담는 것이 맞다.
11
+ *
12
+ * 보고 있는 프레임은 **활성 페이지가 속한 프레임**이다. 따로 기억하지 않는다 —
13
+ * 상태가 하나 더 생기면 캔버스와 목록이 서로 다른 답을 하는 날이 온다.
14
+ *
15
+ * 꼬리표가 하나도 없으면 문서 전체가 한 프레임이다. 지금까지 만들어진 문서에서
16
+ * 이 함수는 아무것도 안 거른다.
17
+ */
18
+ export function activeFramePages<T extends PageLike>(
19
+ pages: readonly T[],
20
+ activePageId?: string,
21
+ ): T[] {
22
+ if (!pages.some((page) => frameOf(page))) return [...pages];
23
+ const active = pages.find((page) => page.id === activePageId) ?? pages[0];
24
+ if (!active) return [];
25
+ const frame = frameOf(active);
26
+ return pages.filter((page) => frameOf(page) === frame);
27
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@leviosa-ai/detail-page-editor",
3
- "version": "0.12.1",
3
+ "version": "0.13.1",
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, and every URL it fetches comes from configureDetailPageEditor.",
5
5
  "type": "module",
6
6
  "license": "UNLICENSED",
@@ -67,7 +67,7 @@
67
67
  "woff2-encoder": "2.0.0"
68
68
  },
69
69
  "peerDependencies": {
70
- "@leviosa-ai/canvas": "0.5.0",
70
+ "@leviosa-ai/canvas": "0.6.1",
71
71
  "@tanstack/react-query": "^5.100.3",
72
72
  "konva": "^10.0.0",
73
73
  "lucide-react": "^0.563.0 || ^1.0.0",