@leviosa-ai/detail-page-editor 0.11.0 → 0.11.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.
@@ -1,6 +1,6 @@
1
1
  "use client";
2
2
 
3
- import { useSyncExternalStore } from "react";
3
+ import { useRef, useState, useSyncExternalStore } from "react";
4
4
  import { observer } from "./canvas-observer";
5
5
  import { useTranslation } from "react-i18next";
6
6
  import { Wand2 } from "lucide-react";
@@ -17,9 +17,19 @@ import {
17
17
  * 예전에는 세로 띠에 AI·위·아래·복제·추가·삭제가 줄줄이 붙어 있었다. 화면을 가리는
18
18
  * 데 비해 쓰는 것은 하나뿐이었고, 나머지 넷은 페이지를 다루는 일이라 페이지 목록과
19
19
  * 오른쪽 페이지 패널로 옮겼다.
20
+ *
21
+ * 붙박이가 아니라 끌어서 옮길 수 있다. 옮긴 자리는 편집기가 열려 있는 동안 유지된다
22
+ * (화면을 바꿀 때마다 다시 붙는 위치로 튀면 옮긴 의미가 없다).
20
23
  */
21
24
 
22
25
  type PageLike = { id: string };
26
+ type Offset = { x: number; y: number };
27
+
28
+ /** 이 편집기 세션 동안 기억한다 — 화면을 바꿔도 버튼이 제자리에 있게. */
29
+ let keptOffset: Offset = { x: 0, y: 0 };
30
+
31
+ /** 이만큼도 못 움직였으면 끈 것이 아니라 누른 것이다. */
32
+ const DRAG_SLOP = 4;
23
33
 
24
34
  export const DetailPagePageToolbar = observer(function DetailPagePageToolbar({
25
35
  store,
@@ -36,6 +46,30 @@ export const DetailPagePageToolbar = observer(function DetailPagePageToolbar({
36
46
  () => false,
37
47
  );
38
48
  const p = page as PageLike;
49
+ const [offset, setOffset] = useState<Offset>(keptOffset);
50
+ const drag = useRef<{ x: number; y: number; from: Offset } | null>(null);
51
+ const dragged = useRef(false);
52
+
53
+ const move = (event: React.PointerEvent<HTMLButtonElement>) => {
54
+ const start = drag.current;
55
+ if (!start) return;
56
+ const dx = event.clientX - start.x;
57
+ const dy = event.clientY - start.y;
58
+ if (Math.abs(dx) > DRAG_SLOP || Math.abs(dy) > DRAG_SLOP)
59
+ dragged.current = true;
60
+ if (!dragged.current) return;
61
+ const rect = event.currentTarget.getBoundingClientRect();
62
+ // 화면 밖으로 던져 놓으면 다시 잡을 수가 없다 — 창 안으로 묶는다.
63
+ const next = { x: start.from.x + dx, y: start.from.y + dy };
64
+ const left = rect.left - offset.x + next.x;
65
+ const top = rect.top - offset.y + next.y;
66
+ const maxX = window.innerWidth - rect.width - 8;
67
+ const maxY = window.innerHeight - rect.height - 8;
68
+ next.x += Math.min(0, maxX - left) + Math.max(0, 8 - left);
69
+ next.y += Math.min(0, maxY - top) + Math.max(0, 8 - top);
70
+ keptOffset = next;
71
+ setOffset(next);
72
+ };
39
73
 
40
74
  // 다시 만들기를 못 꽂은 화면에서는 띠 자체가 없다 — 버튼이 하나뿐이라 그것이
41
75
  // 빠지면 빈 껍데기만 남는다.
@@ -50,11 +84,25 @@ export const DetailPagePageToolbar = observer(function DetailPagePageToolbar({
50
84
  aria-label={t("detailPage.pageToolbar.reauthor", {
51
85
  defaultValue: "이 화면 다시 만들기",
52
86
  })}
87
+ onPointerDown={(e) => {
88
+ if (e.button !== 0) return;
89
+ dragged.current = false;
90
+ drag.current = { x: e.clientX, y: e.clientY, from: offset };
91
+ e.currentTarget.setPointerCapture?.(e.pointerId);
92
+ }}
93
+ onPointerMove={move}
94
+ onPointerUp={(e) => {
95
+ drag.current = null;
96
+ e.currentTarget.releasePointerCapture?.(e.pointerId);
97
+ }}
53
98
  onClick={(e) => {
54
99
  e.stopPropagation();
100
+ // 끌어다 놓은 것이 다시 만들기로 이어지면 크레딧이 그냥 나간다.
101
+ if (dragged.current) return;
55
102
  requestSectionReauthor(p.id);
56
103
  }}
57
- className="flex h-10 w-10 items-center justify-center rounded-full border border-dpe-ink-200 bg-dpe-surface/95 shadow-md backdrop-blur-sm transition-colors hover:bg-dpe-ink-100"
104
+ style={{ transform: `translate(${offset.x}px, ${offset.y}px)` }}
105
+ className="flex h-10 w-10 cursor-grab touch-none items-center justify-center rounded-full border border-dpe-ink-200 bg-dpe-surface/95 shadow-md backdrop-blur-sm transition-colors hover:bg-dpe-ink-100 active:cursor-grabbing"
58
106
  >
59
107
  <Wand2 size={17} className="text-dpe-ai-alt" />
60
108
  </button>
@@ -2,10 +2,10 @@
2
2
 
3
3
  import { detailPageEditorProfile } from "../../lib/detail-page/editor-profile";
4
4
 
5
- import { Fragment, useCallback, useState, useSyncExternalStore } from "react";
5
+ import { Fragment, useCallback, useSyncExternalStore } from "react";
6
6
  import { observer } from "./canvas-observer";
7
7
  import { useTranslation } from "react-i18next";
8
- import { GripVertical, Plus } from "lucide-react";
8
+ import { Copy, GripVertical, Plus, Trash2 } from "lucide-react";
9
9
  import {
10
10
  DndContext,
11
11
  closestCenter,
@@ -23,7 +23,6 @@ import {
23
23
  import { CSS } from "@dnd-kit/utilities";
24
24
 
25
25
  import { detailPageThumbnailBus } from "./detail-page-thumbnail-bus";
26
- import { FillControl } from "./fill-control";
27
26
 
28
27
  /**
29
28
  * Custom Canvas side-panel "페이지" section (hookable-style page list).
@@ -53,6 +52,7 @@ type PageLike = {
53
52
  /** 단색 hex 또는 `linear-gradient(...)` 문자열. `FillControl`이 둘 다 만든다. */
54
53
  background?: string;
55
54
  set?: (props: Record<string, unknown>) => void;
55
+ clone?: () => void;
56
56
  /** 문서 안 몇 번째인가를 바꾼다. 페이지 자신이 가진 함수다 — 스토어에는 없다. */
57
57
  setZIndex?: (index: number) => void;
58
58
  };
@@ -61,6 +61,7 @@ type StoreLike = {
61
61
  activePage?: PageLike;
62
62
  selectPage: (id: string) => void;
63
63
  addPage: (props: Record<string, unknown>) => PageLike;
64
+ deletePages?: (ids: string[]) => void;
64
65
  };
65
66
 
66
67
  type TFn = (key: string, opts?: Record<string, unknown>) => string;
@@ -115,11 +116,11 @@ function InsertHere({
115
116
  const next = store.addPage({
116
117
  width:
117
118
  profile.page.width === "document"
118
- ? ref?.computedWidth ?? "auto"
119
+ ? (ref?.computedWidth ?? "auto")
119
120
  : profile.page.width,
120
121
  height: profile.page.fixed
121
122
  ? profile.page.height
122
- : ref?.computedHeight ?? profile.page.height,
123
+ : (ref?.computedHeight ?? profile.page.height),
123
124
  });
124
125
  next.setZIndex?.(after + 1);
125
126
  store.selectPage(next.id);
@@ -170,8 +171,7 @@ const PageRow = observer(function PageRow({
170
171
  const active = store.activePage?.id === page.id;
171
172
  const ratio = page.computedHeight / Math.max(1, page.computedWidth);
172
173
  const { role, title } = pageLabel(page, index, t);
173
- const [editingBackground, setEditingBackground] = useState(false);
174
- const background = page.background || "#ffffff";
174
+ const maxPages = detailPageEditorProfile().maxPages;
175
175
 
176
176
  const style: React.CSSProperties = {
177
177
  transform: CSS.Transform.toString(transform),
@@ -199,70 +199,71 @@ const PageRow = observer(function PageRow({
199
199
  ].join(" ")}
200
200
  >
201
201
  <div className="flex items-center gap-2">
202
- <span
203
- {...listeners}
204
- aria-label={t("detailPage.pages.reorderHandle")}
205
- className={[
206
- "shrink-0 touch-none rounded text-dpe-ink-300 hover:text-dpe-ink-500",
207
- isDragging ? "cursor-grabbing" : "cursor-grab",
208
- ].join(" ")}
209
- >
210
- <GripVertical aria-hidden="true" size={16} />
211
- </span>
212
- <div
213
- className="relative shrink-0 overflow-hidden rounded-dpe-md border border-dpe-ink-100 bg-dpe-ink-50"
214
- style={{ width: 64, height: Math.min(80, Math.max(40, 64 * ratio)) }}
215
- >
216
- {thumb ? (
217
- // eslint-disable-next-line @next/next/no-img-element
218
- <img
219
- src={thumb}
220
- alt={title || t("detailPage.pages.pageN", { number: index + 1 })}
221
- draggable={false}
222
- className="h-full w-full object-cover object-top"
223
- />
224
- ) : null}
225
- </div>
226
- <div className="min-w-0 flex-1">
227
- <p className="truncate text-sm">
228
- <span className="text-dpe-ink-400">{role}</span>
229
- {title ? (
230
- <>
231
- <span className="mx-1 text-dpe-ink-300">|</span>
232
- <span className="font-dpe-bold text-dpe-ink-900">{title}</span>
233
- </>
234
- ) : null}
235
- </p>
236
- </div>
237
- {/*
238
- 페이지 배경. 우측 인스펙터에도 같은 컨트롤이 있지만 **아무것도 선택 안 됐을 때만**
239
- 뜬다 — 20섹션짜리 문서를 만지는 동안 선택이 비는 순간이 거의 없어서 사실상 안 보였다.
240
- 쓰기는 우측과 똑같이 `page.set({background})`이라 상태가 갈라지지 않는다.
241
- */}
242
- <button
243
- type="button"
244
- onClick={(event) => {
245
- event.stopPropagation();
246
- setEditingBackground((open) => !open);
247
- }}
248
- aria-label={t("detailPage.pages.background")}
249
- aria-expanded={editingBackground}
250
- title={t("detailPage.pages.background")}
251
- className="h-6 w-6 shrink-0 rounded-dpe-md border border-dpe-ink-200 hover:border-dpe-ink-400"
252
- style={{ background }}
253
- />
254
- </div>
255
- {editingBackground ? (
202
+ <span
203
+ {...listeners}
204
+ aria-label={t("detailPage.pages.reorderHandle")}
205
+ className={[
206
+ "shrink-0 touch-none rounded text-dpe-ink-300 hover:text-dpe-ink-500",
207
+ isDragging ? "cursor-grabbing" : "cursor-grab",
208
+ ].join(" ")}
209
+ >
210
+ <GripVertical aria-hidden="true" size={16} />
211
+ </span>
256
212
  <div
257
- className="mt-2 border-t border-dpe-ink-100 pt-2"
258
- onClick={(event) => event.stopPropagation()}
213
+ className="relative shrink-0 overflow-hidden rounded-dpe-md border border-dpe-ink-100 bg-dpe-ink-50"
214
+ style={{ width: 64, height: Math.min(80, Math.max(40, 64 * ratio)) }}
259
215
  >
260
- <FillControl
261
- value={background}
262
- onChange={(next) => page.set?.({ background: next })}
263
- />
216
+ {thumb ? (
217
+ // eslint-disable-next-line @next/next/no-img-element
218
+ <img
219
+ src={thumb}
220
+ alt={title || t("detailPage.pages.pageN", { number: index + 1 })}
221
+ draggable={false}
222
+ className="h-full w-full object-cover object-top"
223
+ />
224
+ ) : null}
225
+ </div>
226
+ <div className="min-w-0 flex-1">
227
+ <p className="truncate text-sm">
228
+ <span className="text-dpe-ink-400">{role}</span>
229
+ {title ? (
230
+ <>
231
+ <span className="mx-1 text-dpe-ink-300">|</span>
232
+ <span className="font-dpe-bold text-dpe-ink-900">{title}</span>
233
+ </>
234
+ ) : null}
235
+ </p>
264
236
  </div>
265
- ) : null}
237
+ {/* 복제 / 삭제. 판을 고르지 않아도 목록에서 바로 되게 둔다. */}
238
+ <div className="flex shrink-0 flex-col gap-1">
239
+ <button
240
+ type="button"
241
+ disabled={store.pages.length >= maxPages}
242
+ onClick={(event) => {
243
+ event.stopPropagation();
244
+ page.clone?.();
245
+ }}
246
+ aria-label={t("detailPage.pageToolbar.duplicate")}
247
+ title={t("detailPage.pageToolbar.duplicate")}
248
+ className="flex h-6 w-6 items-center justify-center rounded-dpe-md border border-dpe-ink-200 text-dpe-ink-500 hover:border-dpe-ink-400 hover:text-dpe-ink-800 disabled:cursor-not-allowed disabled:text-dpe-ink-200 disabled:hover:border-dpe-ink-200"
249
+ >
250
+ <Copy aria-hidden="true" size={13} />
251
+ </button>
252
+ <button
253
+ type="button"
254
+ disabled={store.pages.length <= 1}
255
+ onClick={(event) => {
256
+ event.stopPropagation();
257
+ store.deletePages?.([page.id]);
258
+ }}
259
+ aria-label={t("detailPage.pageToolbar.delete")}
260
+ title={t("detailPage.pageToolbar.delete")}
261
+ className="flex h-6 w-6 items-center justify-center rounded-dpe-md border border-dpe-ink-200 text-dpe-ink-500 hover:border-dpe-ink-400 hover:text-dpe-danger-600 disabled:cursor-not-allowed disabled:text-dpe-ink-200 disabled:hover:border-dpe-ink-200"
262
+ >
263
+ <Trash2 aria-hidden="true" size={13} />
264
+ </button>
265
+ </div>
266
+ </div>
266
267
  </div>
267
268
  );
268
269
  });
@@ -315,7 +316,7 @@ export const DetailPagePagesPanel = observer(function DetailPagePagesPanel({
315
316
  ? Math.round(
316
317
  typeof profile.page.height === "number"
317
318
  ? profile.page.height
318
- : s.pages[0]?.computedHeight ?? 0,
319
+ : (s.pages[0]?.computedHeight ?? 0),
319
320
  )
320
321
  : Math.round(s.pages.reduce((acc, p) => acc + p.computedHeight, 0));
321
322
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@leviosa-ai/detail-page-editor",
3
- "version": "0.11.0",
3
+ "version": "0.11.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, and every URL it fetches comes from configureDetailPageEditor.",
5
5
  "type": "module",
6
6
  "license": "UNLICENSED",