@leviosa-ai/detail-page-editor 0.11.1 → 0.11.3

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>
@@ -26,6 +26,7 @@ import {
26
26
  import { useTranslation } from "react-i18next";
27
27
 
28
28
  import { observer } from "./canvas-observer";
29
+ import { detailPageEditorProfile } from "../../lib/detail-page/editor-profile";
29
30
  import {
30
31
  applySectionHeight,
31
32
  type SectionHeightPage,
@@ -56,7 +57,9 @@ export const SectionHeightHandle = observer(function SectionHeightHandle({
56
57
  history?: HistoryLike;
57
58
  }) {
58
59
  const { t } = useTranslation("branding");
59
- const drag = useRef<{ pointerId: number; y: number; base: number } | null>(null);
60
+ const drag = useRef<{ pointerId: number; y: number; base: number } | null>(
61
+ null,
62
+ );
60
63
  const [dragging, setDragging] = useState(false);
61
64
  const height = Math.round(Number(page.computedHeight) || 0);
62
65
 
@@ -115,15 +118,33 @@ export const SectionHeightHandle = observer(function SectionHeightHandle({
115
118
  pointerEvents: "auto",
116
119
  }}
117
120
  >
121
+ {/* 흰 알약 + 테두리. 예전에는 반투명 회색 막대 하나였는데, 사진이나 어두운
122
+ 배경으로 끝나는 장 밑에서는 배경에 묻혀 «없어진» 것처럼 보였다. */}
118
123
  <div
119
124
  style={{
120
- width: dragging ? 64 : 44,
121
- height: 5,
125
+ width: dragging ? 64 : 48,
126
+ height: 10,
122
127
  borderRadius: 999,
123
- background: dragging ? "rgb(139, 92, 246)" : "rgba(23,23,23,0.28)",
128
+ display: "flex",
129
+ alignItems: "center",
130
+ justifyContent: "center",
131
+ background: dragging ? "rgb(139, 92, 246)" : "rgba(255,255,255,0.96)",
132
+ border: dragging ? "none" : "1px solid rgba(23,23,23,0.18)",
133
+ boxShadow: "0 1px 3px rgba(0,0,0,0.18)",
124
134
  transition: "width 120ms ease, background 120ms ease",
125
135
  }}
126
- />
136
+ >
137
+ <div
138
+ style={{
139
+ width: 22,
140
+ height: 2,
141
+ borderRadius: 999,
142
+ background: dragging
143
+ ? "rgba(255,255,255,0.9)"
144
+ : "rgba(23,23,23,0.45)",
145
+ }}
146
+ />
147
+ </div>
127
148
  {dragging ? (
128
149
  <span
129
150
  style={{
@@ -196,7 +217,9 @@ export const CanvasSectionHeightHandle = observer(
196
217
  const measure = () => {
197
218
  const host = containerRef.current;
198
219
  const node = pageId
199
- ? host?.querySelector<HTMLElement>(`[data-lc-page="${CSS.escape(pageId)}"]`)
220
+ ? host?.querySelector<HTMLElement>(
221
+ `[data-lc-page="${CSS.escape(pageId)}"]`,
222
+ )
200
223
  : null;
201
224
  const next: Box | null =
202
225
  host && node
@@ -249,6 +272,8 @@ export const CanvasSectionHeightHandle = observer(
249
272
  // eslint-disable-next-line react-hooks/exhaustive-deps
250
273
  }, [pageId, scrollRef]);
251
274
 
275
+ // 캐러셀 판은 1080×1350 고정이다 — 끌 수 있는 손잡이를 두면 안 된다.
276
+ if (detailPageEditorProfile().page.fixed) return null;
252
277
  if (!page || !box) return null;
253
278
 
254
279
  return (
@@ -264,11 +289,7 @@ export const CanvasSectionHeightHandle = observer(
264
289
  zIndex: 21,
265
290
  }}
266
291
  >
267
- <SectionHeightHandle
268
- page={page}
269
- scale={scale}
270
- history={s.history}
271
- />
292
+ <SectionHeightHandle page={page} scale={scale} history={s.history} />
272
293
  </div>
273
294
  );
274
295
  },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@leviosa-ai/detail-page-editor",
3
- "version": "0.11.1",
3
+ "version": "0.11.3",
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",