@leviosa-ai/detail-page-editor 0.12.0 → 0.13.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.
@@ -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 = {
@@ -58,7 +61,8 @@ type ExportStoreLike = {
58
61
  };
59
62
 
60
63
  // 등록 플랫폼은 파일명 접미사에만 반영된다(상세페이지 폭은 750px 고정). 셀러가
61
- // 어디에 올릴지 고르며 맥락을 잡도록 노출한다.
64
+ // 어디에 올릴지 고르며 맥락을 잡도록 노출한다. 캐러셀은 인스타그램 한 곳으로만
65
+ // 나가므로 이 목록을 아예 안 띄운다 — `profile.registerPlatform` 이 그것을 정한다.
62
66
  const PLATFORMS = [
63
67
  { value: "naver", label: "네이버 스마트 스토어" },
64
68
  { value: "coupang", label: "쿠팡" },
@@ -198,7 +202,10 @@ export const DetailPageDownloadDialog = observer(function DetailPageDownloadDial
198
202
 
199
203
  const selectedPages = useMemo(() => {
200
204
  if (scope === "current" && s.activePage) return [s.activePage];
201
- return s.pages;
205
+ // 후보 여러 벌이 한 문서에 있으면 «전체»는 문서 전체가 아니라 **보고 있는 한 벌**
206
+ // 이다. 넷을 다 내보내면 마흔 장짜리 묶음이 나오고, 그걸 원해서 누르는 사람은
207
+ // 없다. 꼬리표 없는 문서에서는 지금까지처럼 전부다.
208
+ return activeFramePages(s.pages, s.activePage?.id);
202
209
  }, [scope, s.activePage, s.pages]);
203
210
 
204
211
  const docWidth = useMemo(
@@ -220,7 +227,7 @@ export const DetailPageDownloadDialog = observer(function DetailPageDownloadDial
220
227
  setError(null);
221
228
  const mime = format === "png" ? "image/png" : "image/jpeg";
222
229
  const ext = format === "png" ? "png" : "jpg";
223
- const base = `${fileName}-${platform}`;
230
+ const base = profile.registerPlatform ? `${fileName}-${platform}` : fileName;
224
231
  try {
225
232
  if (format === "psd" || format === "svg" || format === "ai") {
226
233
  // 문서 JSON 기반 내보내기. ag-psd 포함 모듈은 이 시점에만 로드한다.
@@ -384,23 +391,25 @@ export const DetailPageDownloadDialog = observer(function DetailPageDownloadDial
384
391
  </Select>
385
392
  </div>
386
393
 
387
- <div className="space-y-1.5">
388
- <Label className="text-xs text-dpe-ink-500">
389
- {t("editor.registerPlatform")}
390
- </Label>
391
- <Select value={platform} onValueChange={setPlatform}>
392
- <SelectTrigger className="w-full">
393
- <SelectValue />
394
- </SelectTrigger>
395
- <SelectContent>
396
- {PLATFORMS.map((p) => (
397
- <SelectItem key={p.value} value={p.value}>
398
- {p.label}
399
- </SelectItem>
400
- ))}
401
- </SelectContent>
402
- </Select>
403
- </div>
394
+ {profile.registerPlatform && (
395
+ <div className="space-y-1.5">
396
+ <Label className="text-xs text-dpe-ink-500">
397
+ {t("editor.registerPlatform")}
398
+ </Label>
399
+ <Select value={platform} onValueChange={setPlatform}>
400
+ <SelectTrigger className="w-full">
401
+ <SelectValue />
402
+ </SelectTrigger>
403
+ <SelectContent>
404
+ {PLATFORMS.map((p) => (
405
+ <SelectItem key={p.value} value={p.value}>
406
+ {p.label}
407
+ </SelectItem>
408
+ ))}
409
+ </SelectContent>
410
+ </Select>
411
+ </div>
412
+ )}
404
413
 
405
414
  <div className="space-y-1.5">
406
415
  <Label className="text-xs text-dpe-ink-500">{t("editor.pageScope")}</Label>
@@ -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 {
@@ -90,7 +90,7 @@ export function EditUsageBadge({
90
90
  if (unlimited) {
91
91
  return (
92
92
  <span
93
- className="ml-auto text-[11px] tabular-nums text-muted-foreground"
93
+ className="ml-auto text-[11px] tabular-nums text-dpe-ink-500"
94
94
  title={t("detailPage.editQuota.unlimitedTitle")}
95
95
  >
96
96
  {t("detailPage.editQuota.usedUnlimited", { used })}
@@ -104,10 +104,10 @@ export function EditUsageBadge({
104
104
  className={
105
105
  "ml-auto text-[11px] tabular-nums " +
106
106
  (remaining === 0
107
- ? "text-destructive"
107
+ ? "text-dpe-danger-600"
108
108
  : warn
109
109
  ? "text-dpe-warn-500"
110
- : "text-muted-foreground")
110
+ : "text-dpe-ink-500")
111
111
  }
112
112
  title={t("detailPage.editQuota.remainingTitle", { remaining })}
113
113
  >
@@ -130,17 +130,17 @@ export function EditQuotaBlock({
130
130
  : t("detailPage.editQuota.textEdit");
131
131
  return (
132
132
  <div className="flex flex-col items-center gap-2 px-4 py-4 text-center">
133
- <p className="text-[13px] font-dpe-medium text-foreground">
133
+ <p className="text-[13px] font-dpe-medium text-dpe-ink-900">
134
134
  {t("detailPage.editQuota.quotaExhausted", { label })}
135
135
  </p>
136
- <p className="text-[11px] leading-4 text-muted-foreground">
136
+ <p className="text-[11px] leading-4 text-dpe-ink-500">
137
137
  {t("detailPage.editQuota.quotaHint")}
138
138
  </p>
139
139
  <button
140
140
  type="button"
141
141
  onClick={onBuyMore}
142
142
  disabled={!onBuyMore}
143
- className="mt-1 inline-flex items-center gap-1.5 rounded-dpe-lg bg-primary px-3 py-1.5 text-xs font-dpe-medium text-primary-foreground disabled:opacity-40"
143
+ className="mt-1 inline-flex items-center gap-1.5 rounded-dpe-lg bg-dpe-ink-900 px-3 py-1.5 text-xs font-dpe-medium text-dpe-on-accent disabled:opacity-40"
144
144
  >
145
145
  <Sparkles size={13} />
146
146
  {t("detailPage.editQuota.buyCredits")}
@@ -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
+ }