@leviosa-ai/detail-page-editor 0.17.2 → 0.19.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.
- package/components/detail-page/canvas-selection-tools.tsx +8 -2
- package/components/detail-page/detail-page-download-dialog.tsx +4 -2
- package/components/detail-page/detail-page-editor.tsx +36 -8
- package/components/detail-page/detail-page-layers-panel.tsx +21 -3
- package/components/detail-page/detail-page-properties-panel.tsx +72 -15
- package/components/detail-page/image-crop-overlay.tsx +43 -8
- package/components/detail-page/inspector-controls.tsx +5 -1
- package/components/detail-page/leviosa-canvas-workspace.tsx +219 -14
- package/components/detail-page/mobile-editor-bars.tsx +382 -0
- package/components/detail-page/selection-quick-toolbar.tsx +24 -2
- package/lib/detail-page-canvas/export/ai.ts +7 -23
- package/lib/detail-page-canvas/export/document-model.ts +3 -0
- package/lib/detail-page-canvas/export/frame.ts +28 -2
- package/lib/detail-page-canvas/export/svg.ts +26 -10
- package/package.json +2 -2
|
@@ -18,6 +18,7 @@ import { CANVAS_MENU_ICONS } from "./canvas-context-menu";
|
|
|
18
18
|
import { ImageCropOverlay, type CropElement } from "./image-crop-overlay";
|
|
19
19
|
import {
|
|
20
20
|
SelectionQuickToolbar,
|
|
21
|
+
popoverHorizontal,
|
|
21
22
|
useQuickPopoverPlacement,
|
|
22
23
|
type QuickToolbarItem,
|
|
23
24
|
} from "./selection-quick-toolbar";
|
|
@@ -58,12 +59,17 @@ function Popover({
|
|
|
58
59
|
children: React.ReactNode;
|
|
59
60
|
width?: number;
|
|
60
61
|
}) {
|
|
61
|
-
const { side, maxHeight } = useQuickPopoverPlacement();
|
|
62
|
+
const { side, maxHeight, left, hostWidth } = useQuickPopoverPlacement();
|
|
63
|
+
const fit = popoverHorizontal(width, left, hostWidth);
|
|
62
64
|
return (
|
|
63
65
|
<div
|
|
64
66
|
data-dp-quick-popover=""
|
|
65
67
|
data-dp-quick-popover-side={side}
|
|
66
|
-
style={{
|
|
68
|
+
style={{
|
|
69
|
+
width: fit.width,
|
|
70
|
+
maxHeight: `min(60vh, 520px, ${maxHeight}px)`,
|
|
71
|
+
transform: fit.shift ? `translateX(${fit.shift}px)` : undefined,
|
|
72
|
+
}}
|
|
67
73
|
className={[
|
|
68
74
|
"overflow-y-auto rounded-le-xl border border-le-ink-200 bg-le-surface shadow-lg",
|
|
69
75
|
side === "above" ? "absolute bottom-full left-0 mb-1.5" : "mt-1.5",
|
|
@@ -529,10 +529,12 @@ export const DetailPageDownloadDialog = observer(function DetailPageDownloadDial
|
|
|
529
529
|
onClick={() => setOpen((v) => !v)}
|
|
530
530
|
aria-haspopup="dialog"
|
|
531
531
|
aria-expanded={open}
|
|
532
|
-
|
|
532
|
+
aria-label={t("editor.download")}
|
|
533
|
+
className="inline-flex h-9 shrink-0 items-center gap-2 rounded-le-md bg-le-ink-900 px-3 text-sm font-le-semibold text-le-on-accent transition-colors hover:bg-le-ink-800"
|
|
533
534
|
>
|
|
534
535
|
<Download aria-hidden="true" size={16} />
|
|
535
|
-
{
|
|
536
|
+
{/* 폰에서는 아이콘만 — 글자까지 넣으면 헤더가 넘쳐 한 글자씩 꺾인다. */}
|
|
537
|
+
<span className="hidden sm:inline">{t("editor.download")}</span>
|
|
536
538
|
</button>
|
|
537
539
|
|
|
538
540
|
{open ? (
|
|
@@ -37,6 +37,7 @@ import {
|
|
|
37
37
|
useDetailPageHost,
|
|
38
38
|
} from "./detail-page-host-context";
|
|
39
39
|
import { SectionReauthorController } from "./section-reauthor-controller";
|
|
40
|
+
import { MobileBottomBar, useIsMobile, useKeyboardViewport } from "./mobile-editor-bars";
|
|
40
41
|
import type {
|
|
41
42
|
GenerateGifFn,
|
|
42
43
|
GenerateImageFn,
|
|
@@ -190,6 +191,9 @@ export function DetailPageEditor({
|
|
|
190
191
|
// 프롬프트 편집 사용량은 여기서 한 번만 조회한다 — 캔버스 위 띠와 우측 패널(표·차트)이
|
|
191
192
|
// 같은 숫자를 봐야 "몇 번 남았는가"가 갈라지지 않는다.
|
|
192
193
|
const { usage, applyUsage } = useDetailPageEditUsage(generatedId);
|
|
194
|
+
// 폰에서는 좌측 레일·우측 인스펙터를 하단 띠 하나로 접는다(mobile-editor-bars).
|
|
195
|
+
const mobile = useIsMobile();
|
|
196
|
+
const keyboard = useKeyboardViewport();
|
|
193
197
|
const [saveError, setSaveError] = useState<string | null>(null);
|
|
194
198
|
const [saveOk, setSaveOk] = useState(false);
|
|
195
199
|
// 편집 한도 소진 시 "편집 크레딧 추가하기" → pricing 모달을 인플레이스로 연다.
|
|
@@ -330,7 +334,7 @@ export function DetailPageEditor({
|
|
|
330
334
|
return (
|
|
331
335
|
<div className="absolute inset-0 flex">
|
|
332
336
|
{hotkeys}
|
|
333
|
-
{SidebarSlot ? (
|
|
337
|
+
{mobile ? null : SidebarSlot ? (
|
|
334
338
|
<SidebarSlot
|
|
335
339
|
store={store}
|
|
336
340
|
sections={sidebarSections as never}
|
|
@@ -357,7 +361,7 @@ export function DetailPageEditor({
|
|
|
357
361
|
</div>
|
|
358
362
|
</div>
|
|
359
363
|
);
|
|
360
|
-
}, [store, sidebarSections, SidebarSlot, chosenFrame, onChooseFrame, handleSave, uploadFile]);
|
|
364
|
+
}, [store, sidebarSections, SidebarSlot, mobile, chosenFrame, onChooseFrame, handleSave, uploadFile]);
|
|
361
365
|
|
|
362
366
|
const aiValue = useMemo(
|
|
363
367
|
() => ({
|
|
@@ -413,7 +417,7 @@ export function DetailPageEditor({
|
|
|
413
417
|
<ChevronLeft aria-hidden="true" size={20} />
|
|
414
418
|
</button>
|
|
415
419
|
) : null}
|
|
416
|
-
<p className="ml-1 max-w-[280px] truncate text-sm font-le-semibold text-le-ink-900">
|
|
420
|
+
<p className="ml-1 min-w-0 max-w-[280px] truncate text-sm font-le-semibold text-le-ink-900">
|
|
417
421
|
{productName?.trim() || t("editor.untitled")}
|
|
418
422
|
</p>
|
|
419
423
|
|
|
@@ -443,10 +447,13 @@ export function DetailPageEditor({
|
|
|
443
447
|
type="button"
|
|
444
448
|
onClick={handleSave}
|
|
445
449
|
disabled={saving}
|
|
446
|
-
|
|
450
|
+
aria-label={saving ? t("editor.saving") : t("editor.save")}
|
|
451
|
+
className="inline-flex h-9 shrink-0 items-center gap-2 rounded-le-md border border-le-ink-200 bg-le-surface px-3 text-sm font-le-semibold text-le-ink-900 hover:bg-le-ink-50 disabled:cursor-not-allowed disabled:opacity-50"
|
|
447
452
|
>
|
|
448
453
|
<Save aria-hidden="true" size={16} />
|
|
449
|
-
|
|
454
|
+
<span className="hidden sm:inline">
|
|
455
|
+
{saving ? t("editor.saving") : t("editor.save")}
|
|
456
|
+
</span>
|
|
450
457
|
</button>
|
|
451
458
|
{downloadPart}
|
|
452
459
|
|
|
@@ -516,22 +523,43 @@ export function DetailPageEditor({
|
|
|
516
523
|
<TooltipProvider>
|
|
517
524
|
<div
|
|
518
525
|
data-le-root=""
|
|
519
|
-
className="flex h-
|
|
526
|
+
className="flex h-dvh min-h-[480px] flex-col md:h-screen md:min-h-[640px] bg-le-ink-100"
|
|
527
|
+
// 폰에서 키보드가 뜨면 보이는 자리만큼으로 줄여 띠·입력창을 키보드 위에 둔다.
|
|
528
|
+
style={
|
|
529
|
+
mobile && keyboard
|
|
530
|
+
? {
|
|
531
|
+
height: keyboard.height,
|
|
532
|
+
minHeight: 0,
|
|
533
|
+
position: "fixed",
|
|
534
|
+
insetInline: 0,
|
|
535
|
+
top: keyboard.top,
|
|
536
|
+
}
|
|
537
|
+
: undefined
|
|
538
|
+
}
|
|
520
539
|
>
|
|
521
540
|
{/* hookable식 상단 헤더: 뒤로가기 · 상품명 · (되돌리기/다시실행) · 저장 ·
|
|
522
541
|
다운로드 · 앱 공용 크롬(크레딧/알림/언어, 호스트 주입). 높이를 고정하고
|
|
523
542
|
본문은 flex-1 min-h-0으로 두어, 헤더 높이가 바뀌어도 캔버스가 남는 높이를
|
|
524
543
|
정확히 채워 페이지 하단이 잘리지 않는다. */}
|
|
525
544
|
{header}
|
|
526
|
-
<div
|
|
545
|
+
<div
|
|
546
|
+
className={`grid min-h-0 flex-1 ${mobile ? "grid-cols-1" : "grid-cols-[1fr_360px]"}`}
|
|
547
|
+
>
|
|
527
548
|
{/* 캔버스 칸의 높이를 못 박는다. 안 그러면 그리드 행이 내용만큼 늘어나(높이
|
|
528
549
|
100%가 auto로 풀린다) 작업 영역이 화면 아래로 자라고, 아래 붙는 배율·화면
|
|
529
550
|
띠가 화면 밖으로 밀린다. */}
|
|
530
551
|
<div data-le-part="workspace" className="relative min-w-0 overflow-hidden">
|
|
531
552
|
{canvas}
|
|
532
553
|
</div>
|
|
533
|
-
{inspector}
|
|
554
|
+
{mobile ? null : inspector}
|
|
534
555
|
</div>
|
|
556
|
+
{mobile ? (
|
|
557
|
+
<MobileBottomBar
|
|
558
|
+
store={store}
|
|
559
|
+
sections={sidebarSections}
|
|
560
|
+
inspector={defaultInspector}
|
|
561
|
+
/>
|
|
562
|
+
) : null}
|
|
535
563
|
{/* 화면 하나만 마크업째 다시 저작 — 요청은 캔버스 옆 페이지 툴바에서 온다. */}
|
|
536
564
|
<SectionReauthorController
|
|
537
565
|
store={store}
|
|
@@ -191,7 +191,8 @@ function IconButton({
|
|
|
191
191
|
onClick();
|
|
192
192
|
}}
|
|
193
193
|
className={[
|
|
194
|
-
|
|
194
|
+
// 터치 화면(pointer: coarse)에서는 손가락 폭만큼 키운다.
|
|
195
|
+
"flex h-6 w-6 items-center justify-center rounded transition-colors pointer-coarse:h-9 pointer-coarse:w-9",
|
|
195
196
|
active ? "text-le-ink-800" : "text-le-ink-400",
|
|
196
197
|
disabled
|
|
197
198
|
? "cursor-not-allowed opacity-30"
|
|
@@ -342,6 +343,18 @@ export function matchLayers<
|
|
|
342
343
|
);
|
|
343
344
|
}
|
|
344
345
|
|
|
346
|
+
/**
|
|
347
|
+
* ⇧클릭의 기준점 — 기억해 둔 줄이 아직 선택에 있으면(또는 아무것도 안 골랐으면) 그것,
|
|
348
|
+
* 아니면 지금 선택의 마지막 것.
|
|
349
|
+
*/
|
|
350
|
+
export function shiftAnchor(
|
|
351
|
+
anchorId: string | null,
|
|
352
|
+
selected: readonly string[],
|
|
353
|
+
): string | null {
|
|
354
|
+
if (anchorId && (!selected.length || selected.includes(anchorId))) return anchorId;
|
|
355
|
+
return selected[selected.length - 1] ?? null;
|
|
356
|
+
}
|
|
357
|
+
|
|
345
358
|
/** 누른 줄 하나를 선택에서 넣거나 뺀다(⌘/Ctrl 클릭). */
|
|
346
359
|
export function toggleId(selected: readonly string[], id: string): string[] {
|
|
347
360
|
return selected.includes(id)
|
|
@@ -636,8 +649,13 @@ export const DetailPageLayersPanel = observer(function DetailPageLayersPanel({
|
|
|
636
649
|
*/
|
|
637
650
|
const onSelect = (id: string, mods: { shift: boolean; meta: boolean }) => {
|
|
638
651
|
if (mods.shift) {
|
|
639
|
-
|
|
640
|
-
//
|
|
652
|
+
// 기준점은 목록에서 마지막으로 누른 줄인데, 그사이 캔버스에서 다른 걸 골랐으면
|
|
653
|
+
// 낡은 값이다(없거나 선택 밖). 그때 그대로 쓰면 누른 줄 하나만 잡혀 ⇧클릭이
|
|
654
|
+
// «가끔 안 되는» 것처럼 보인다 — 지금 선택의 마지막 것을 기준점으로 삼는다.
|
|
655
|
+
const anchor = shiftAnchor(anchorId, selectedIds) ?? id;
|
|
656
|
+
s.selectElements(rangeIds(rows, anchor, id));
|
|
657
|
+
// 기준점은 옮기지 않는다 — 범위를 잡았다 놨다 하며 늘릴 수 있어야 한다.
|
|
658
|
+
setAnchorId(anchor);
|
|
641
659
|
return;
|
|
642
660
|
}
|
|
643
661
|
if (mods.meta) {
|
|
@@ -72,6 +72,9 @@ import {
|
|
|
72
72
|
extractSvgColors,
|
|
73
73
|
} from "../../lib/detail-page/svg-colors";
|
|
74
74
|
import { readColorReplace } from "@leviosa-ai/canvas/render/svg-source";
|
|
75
|
+
import { elementRect, type Rect, type RectSource } from "@leviosa-ai/canvas/edit/rect";
|
|
76
|
+
import { groupResizePatches } from "@leviosa-ai/canvas/render/interaction";
|
|
77
|
+
import type { CanvasElement } from "@leviosa-ai/canvas/store";
|
|
75
78
|
import { selectedElementsDeep } from "./detail-page-selection";
|
|
76
79
|
import { useEditorAi } from "./editor-ai-context";
|
|
77
80
|
import { parseAuthoringImageSrc } from "../../lib/detail-page/authoring-image-src";
|
|
@@ -97,8 +100,6 @@ import { setZ as setElementZ, zOrderOf } from "../../lib/detail-page/z-order";
|
|
|
97
100
|
import {
|
|
98
101
|
canDistribute,
|
|
99
102
|
distributeCoords,
|
|
100
|
-
toItems,
|
|
101
|
-
type DistributeElement,
|
|
102
103
|
} from "../../lib/detail-page/distribute";
|
|
103
104
|
import { useDetailPageHost } from "./detail-page-host-context";
|
|
104
105
|
import type {
|
|
@@ -236,8 +237,9 @@ export function groupFrame(
|
|
|
236
237
|
let lo = Infinity;
|
|
237
238
|
let hi = -Infinity;
|
|
238
239
|
for (const sib of siblings) {
|
|
239
|
-
const
|
|
240
|
-
const
|
|
240
|
+
const rect = rectOf(sib);
|
|
241
|
+
const start = axis === "x" ? rect.x : rect.y;
|
|
242
|
+
const size = axis === "x" ? rect.width : rect.height;
|
|
241
243
|
lo = Math.min(lo, start);
|
|
242
244
|
hi = Math.max(hi, start + size);
|
|
243
245
|
}
|
|
@@ -245,6 +247,20 @@ export function groupFrame(
|
|
|
245
247
|
return { start: lo, size: hi - lo };
|
|
246
248
|
}
|
|
247
249
|
|
|
250
|
+
/**
|
|
251
|
+
* 요소가 **보이는** 네모(부모 좌표). 그룹은 자기 x/y·폭·높이를 안 믿으므로(자식이 좌표를
|
|
252
|
+
* 들고, 그룹 폭·높이는 0일 수 있다) x/width를 그대로 읽으면 정렬이 그룹을 날려 보낸다.
|
|
253
|
+
* 캔버스가 정렬·스냅에 쓰는 `elementRect`로 잰다.
|
|
254
|
+
*/
|
|
255
|
+
function rectOf(el: unknown): Rect {
|
|
256
|
+
return elementRect(el as RectSource);
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
/** 보이는 네모의 축 시작이 `coord`에 오게 하는 x(또는 y) 값. */
|
|
260
|
+
function placeAt(el: ElementLike, axis: AlignAxis, coord: number): number {
|
|
261
|
+
return num(el[axis]) + coord - rectOf(el)[axis];
|
|
262
|
+
}
|
|
263
|
+
|
|
248
264
|
/** Where a box of `size` lands when aligned inside `frame`. */
|
|
249
265
|
export function alignedCoord(
|
|
250
266
|
frame: AlignFrame,
|
|
@@ -287,8 +303,9 @@ function alignInFrame(
|
|
|
287
303
|
for (const el of els) {
|
|
288
304
|
const frame = frameOf(store, el, axis);
|
|
289
305
|
if (!frame) continue;
|
|
290
|
-
const
|
|
291
|
-
const
|
|
306
|
+
const rect = rectOf(el);
|
|
307
|
+
const size = axis === "x" ? rect.width : rect.height;
|
|
308
|
+
const coord = Math.round(placeAt(el, axis, alignedCoord(frame, size, where)));
|
|
292
309
|
el.set(axis === "x" ? { x: coord } : { y: coord });
|
|
293
310
|
}
|
|
294
311
|
});
|
|
@@ -307,9 +324,10 @@ function currentAlign(
|
|
|
307
324
|
for (const el of els) {
|
|
308
325
|
const frame = frameOf(store, el, axis);
|
|
309
326
|
if (!frame) return null;
|
|
310
|
-
const
|
|
327
|
+
const rect = rectOf(el);
|
|
328
|
+
const size = axis === "x" ? rect.width : rect.height;
|
|
311
329
|
if (frame.size - size < 1) return null; // 상자를 꽉 채움: 정렬 구분 무의미
|
|
312
|
-
const coord =
|
|
330
|
+
const coord = rect[axis];
|
|
313
331
|
let where: AlignWhere | null = null;
|
|
314
332
|
for (const w of ["start", "center", "end"] as const) {
|
|
315
333
|
if (Math.abs(alignedCoord(frame, size, w) - coord) <= 1) {
|
|
@@ -326,14 +344,19 @@ function currentAlign(
|
|
|
326
344
|
|
|
327
345
|
// 선택 요소끼리 간격을 고르게. 양 끝은 그대로 두고 사이 여백만 나눈다(distribute.ts).
|
|
328
346
|
function spreadEvenly(els: ElementLike[], axis: "x" | "y") {
|
|
329
|
-
|
|
330
|
-
const
|
|
347
|
+
// 보이는 네모로 잰다 — 그룹이 섞여 있어도 같은 자로 벌린다.
|
|
348
|
+
const items = els.map((el) => {
|
|
349
|
+
const rect = rectOf(el);
|
|
350
|
+
return { id: el.id, start: rect[axis], size: axis === "x" ? rect.width : rect.height };
|
|
351
|
+
});
|
|
352
|
+
const coords = distributeCoords(items);
|
|
331
353
|
if (!coords) return;
|
|
332
354
|
transact(historyOf(els[0]), () => {
|
|
333
355
|
for (const el of els) {
|
|
334
356
|
const coord = coords.get(el.id);
|
|
335
357
|
if (coord == null) continue;
|
|
336
|
-
|
|
358
|
+
const next = Math.round(placeAt(el, axis, coord));
|
|
359
|
+
el.set(axis === "x" ? { x: next } : { y: next });
|
|
337
360
|
}
|
|
338
361
|
});
|
|
339
362
|
}
|
|
@@ -741,13 +764,21 @@ const SizeSection = observer(function SizeSection({ els }: { els: ElementLike[]
|
|
|
741
764
|
const { t } = useTranslation("branding");
|
|
742
765
|
const el = els[0];
|
|
743
766
|
if (!el) return null;
|
|
767
|
+
// 그룹은 자기 x/y·폭·높이를 안 믿는다 — 보이는 네모를 보여 주고, 고치면 그 네모가
|
|
768
|
+
// 움직이거나 커지게 한다(자손까지 같이). 잎은 제 값 그대로.
|
|
769
|
+
const group = el.type === "group" && Array.isArray(el.children);
|
|
770
|
+
const rect = group ? rectOf(el) : null;
|
|
744
771
|
const field = (label: string, key: "width" | "height" | "x" | "y") => (
|
|
745
772
|
<NumberField
|
|
746
773
|
label={label}
|
|
747
|
-
value={num(el[key])}
|
|
774
|
+
value={rect ? Math.round(rect[key] * 100) / 100 : num(el[key])}
|
|
748
775
|
min={key === "width" || key === "height" ? 1 : undefined}
|
|
749
776
|
history={historyOf(el)}
|
|
750
|
-
onChange={(v) =>
|
|
777
|
+
onChange={(v) => {
|
|
778
|
+
if (!rect) el.set({ [key]: Math.round(v) });
|
|
779
|
+
else if (key === "x" || key === "y") el.set({ [key]: Math.round(placeAt(el, key, v)) });
|
|
780
|
+
else resizeGroup(el, key, v);
|
|
781
|
+
}}
|
|
751
782
|
/>
|
|
752
783
|
);
|
|
753
784
|
return (
|
|
@@ -763,6 +794,29 @@ const SizeSection = observer(function SizeSection({ els }: { els: ElementLike[]
|
|
|
763
794
|
});
|
|
764
795
|
SizeSection.displayName = "SizeSection";
|
|
765
796
|
|
|
797
|
+
/** 그룹의 보이는 폭(또는 높이)을 `size`로 — 자손을 같이 늘리고 왼쪽 위는 제자리에 둔다. */
|
|
798
|
+
function resizeGroup(el: ElementLike, key: "width" | "height", size: number) {
|
|
799
|
+
const before = rectOf(el);
|
|
800
|
+
const scale = size / (key === "width" ? before.width : before.height);
|
|
801
|
+
if (!Number.isFinite(scale) || scale <= 0) return;
|
|
802
|
+
const group = el as unknown as CanvasElement;
|
|
803
|
+
transact(historyOf(el), () => {
|
|
804
|
+
const patches = groupResizePatches(group, {
|
|
805
|
+
x: num(el.x),
|
|
806
|
+
y: num(el.y),
|
|
807
|
+
width: num(el.width),
|
|
808
|
+
height: num(el.height),
|
|
809
|
+
rotation: num(el.rotation),
|
|
810
|
+
scaleX: key === "width" ? scale : 1,
|
|
811
|
+
scaleY: key === "height" ? scale : 1,
|
|
812
|
+
});
|
|
813
|
+
for (const { id, patch } of patches) group.store.getElementById(id)?.set(patch);
|
|
814
|
+
// 자손 좌표는 그룹 원점 기준으로 곱해지므로 보이는 왼쪽 위가 밀린다 — 되돌린다.
|
|
815
|
+
const after = rectOf(el);
|
|
816
|
+
el.set({ x: num(el.x) + before.x - after.x, y: num(el.y) + before.y - after.y });
|
|
817
|
+
});
|
|
818
|
+
}
|
|
819
|
+
|
|
766
820
|
function DeleteRow({ store, els }: { store: StoreLike; els: ElementLike[] }) {
|
|
767
821
|
const { t } = useTranslation("branding");
|
|
768
822
|
// A single selected group can be split back into its elements. Mirrors the
|
|
@@ -1858,7 +1912,10 @@ function InspectorHeader({ els }: { els: ElementLike[] }) {
|
|
|
1858
1912
|
label = t("detailPage.properties.selectionCount", { count: els.length });
|
|
1859
1913
|
}
|
|
1860
1914
|
return (
|
|
1861
|
-
<div
|
|
1915
|
+
<div
|
|
1916
|
+
data-le-inspector-header=""
|
|
1917
|
+
className="flex items-center gap-2 border-b border-le-ink-200 px-4 py-3 text-sm font-le-semibold text-le-ink-950"
|
|
1918
|
+
>
|
|
1862
1919
|
{icon}
|
|
1863
1920
|
{label}
|
|
1864
1921
|
</div>
|
|
@@ -1929,7 +1986,7 @@ export const DetailPageProperties = observer(function DetailPageProperties({
|
|
|
1929
1986
|
return (
|
|
1930
1987
|
<div className="flex h-full min-h-0 flex-col">
|
|
1931
1988
|
<InspectorHeader els={els} />
|
|
1932
|
-
<div className="min-h-0 flex-1 overflow-y-auto">
|
|
1989
|
+
<div data-le-inspector-body="" className="min-h-0 flex-1 overflow-y-auto">
|
|
1933
1990
|
{els.length === 0 ? (
|
|
1934
1991
|
<PageInspector
|
|
1935
1992
|
store={s}
|
|
@@ -57,6 +57,8 @@ export type CropElement = {
|
|
|
57
57
|
width?: unknown;
|
|
58
58
|
height?: unknown;
|
|
59
59
|
rotation?: unknown;
|
|
60
|
+
flipX?: unknown;
|
|
61
|
+
flipY?: unknown;
|
|
60
62
|
set: (patch: Record<string, unknown>) => void;
|
|
61
63
|
};
|
|
62
64
|
|
|
@@ -64,8 +66,8 @@ const ACCENT = "rgb(0, 161, 255)";
|
|
|
64
66
|
const HANDLE = 10;
|
|
65
67
|
/** 자르기 밖을 덮는 그림자의 짙기. 잘려 나가는 부분이 보이되 상자 안이 또렷해야 한다. */
|
|
66
68
|
const OUTSIDE_OPACITY = 0.5;
|
|
67
|
-
/** 조작 줄을 작업 영역 안에 남기는 여백/높이. */
|
|
68
|
-
const BAR_MARGIN =
|
|
69
|
+
/** 조작 줄을 작업 영역 안에 남기는 여백/높이. 여백은 줄 너비의 반에 이만큼을 더한다. */
|
|
70
|
+
const BAR_MARGIN = 8;
|
|
69
71
|
const BAR_HEIGHT = 56;
|
|
70
72
|
|
|
71
73
|
const CORNERS: CropHandle[] = ["nw", "ne", "sw", "se"];
|
|
@@ -123,6 +125,8 @@ export function ImageCropOverlay({
|
|
|
123
125
|
const [presetId, setPresetId] = useState<CropPresetId>("custom");
|
|
124
126
|
const [menuOpen, setMenuOpen] = useState(false);
|
|
125
127
|
const drag = useRef<DragState | null>(null);
|
|
128
|
+
// 조작 줄의 실제 너비 — 언어에 따라 달라서 재야 가장자리에서 안 잘린다.
|
|
129
|
+
const barRef = useRef<HTMLDivElement | null>(null);
|
|
126
130
|
|
|
127
131
|
// 그림은 캔버스가 이미 받아 둔 것을 그대로 꺼내 쓴다 — 다시 받으면 같은 그림이 두 벌
|
|
128
132
|
// 디코드되고, 교차 출처 설정이 달라 알파 판정이 어긋날 수 있다.
|
|
@@ -236,6 +240,20 @@ export function ImageCropOverlay({
|
|
|
236
240
|
setRect(applyAspect(rect, start.image, resolveAspect(next, natural)));
|
|
237
241
|
};
|
|
238
242
|
|
|
243
|
+
const [barWidth, setBarWidth] = useState(0);
|
|
244
|
+
const barShown = Boolean(start && rect && box);
|
|
245
|
+
useLayoutEffect(() => {
|
|
246
|
+
const node = barRef.current;
|
|
247
|
+
if (!node) return;
|
|
248
|
+
const measure = () => setBarWidth(node.offsetWidth);
|
|
249
|
+
measure();
|
|
250
|
+
// 글꼴·스타일이 늦게 붙으면 너비가 바뀐다.
|
|
251
|
+
if (typeof ResizeObserver === "undefined") return;
|
|
252
|
+
const observer = new ResizeObserver(measure);
|
|
253
|
+
observer.observe(node);
|
|
254
|
+
return () => observer.disconnect();
|
|
255
|
+
}, [barShown]);
|
|
256
|
+
|
|
239
257
|
if (!start || !rect || !box) return null;
|
|
240
258
|
|
|
241
259
|
const scale = box.scale || 1;
|
|
@@ -243,6 +261,10 @@ export function ImageCropOverlay({
|
|
|
243
261
|
const cos = Math.cos(radians);
|
|
244
262
|
const sin = Math.sin(radians);
|
|
245
263
|
const zoom = rectZoom(rect, start.image, aspect);
|
|
264
|
+
// 뒤집힌 사진은 화면에서도 뒤집혀 있다 — 오버레이도 상자 가운데를 축으로 같이 뒤집어야
|
|
265
|
+
// 보이는 자리와 자르는 자리가 맞는다. 끈 양도 그 축으로 되돌린다.
|
|
266
|
+
const flipX = el.flipX === true;
|
|
267
|
+
const flipY = el.flipY === true;
|
|
246
268
|
|
|
247
269
|
const begin = (event: ReactPointerEvent<HTMLElement>, handle: CropHandle) => {
|
|
248
270
|
event.preventDefault();
|
|
@@ -264,8 +286,8 @@ export function ImageCropOverlay({
|
|
|
264
286
|
const screenX = event.clientX - state.startX;
|
|
265
287
|
const screenY = event.clientY - state.startY;
|
|
266
288
|
// 화면에서 끈 양을 요소 좌표로 되돌린다(배율을 나누고 회전을 되감는다).
|
|
267
|
-
const dx = (screenX * cos + screenY * sin) / scale;
|
|
268
|
-
const dy = (-screenX * sin + screenY * cos) / scale;
|
|
289
|
+
const dx = ((screenX * cos + screenY * sin) / scale) * (flipX ? -1 : 1);
|
|
290
|
+
const dy = ((-screenX * sin + screenY * cos) / scale) * (flipY ? -1 : 1);
|
|
269
291
|
setRect(dragCrop(state.rect, state.handle, dx, dy, start.image, { aspect }));
|
|
270
292
|
};
|
|
271
293
|
|
|
@@ -294,10 +316,12 @@ export function ImageCropOverlay({
|
|
|
294
316
|
const host = containerRef.current;
|
|
295
317
|
const hostWidth = host?.clientWidth ?? 0;
|
|
296
318
|
const hostHeight = host?.clientHeight ?? 0;
|
|
297
|
-
const barCenter =
|
|
319
|
+
const barCenter =
|
|
320
|
+
box.left + (flipX ? box.width - view.left - view.width : view.left) + view.width / 2;
|
|
321
|
+
const barEdge = barWidth / 2 + BAR_MARGIN;
|
|
298
322
|
const barPosition = {
|
|
299
323
|
left: hostWidth
|
|
300
|
-
? Math.max(
|
|
324
|
+
? Math.max(barEdge, Math.min(barCenter, hostWidth - barEdge))
|
|
301
325
|
: barCenter,
|
|
302
326
|
top: hostHeight
|
|
303
327
|
? Math.min(box.top + view.top + view.height + 12, hostHeight - BAR_HEIGHT)
|
|
@@ -327,7 +351,11 @@ export function ImageCropOverlay({
|
|
|
327
351
|
top: box.top,
|
|
328
352
|
width: box.width,
|
|
329
353
|
height: box.height,
|
|
330
|
-
transform:
|
|
354
|
+
transform: [
|
|
355
|
+
`rotate(${box.rotation}deg)`,
|
|
356
|
+
flipX ? `translateX(${box.width}px) scaleX(-1)` : "",
|
|
357
|
+
flipY ? `translateY(${box.height}px) scaleY(-1)` : "",
|
|
358
|
+
].join(" "),
|
|
331
359
|
transformOrigin: "0 0",
|
|
332
360
|
}}
|
|
333
361
|
>
|
|
@@ -416,7 +444,13 @@ export function ImageCropOverlay({
|
|
|
416
444
|
background: "#fff",
|
|
417
445
|
border: `1px solid ${ACCENT}`,
|
|
418
446
|
boxSizing: "border-box",
|
|
419
|
-
|
|
447
|
+
// 한 번 뒤집힌 상자에서는 대각선 손잡이가 반대 대각선에 놓인다.
|
|
448
|
+
cursor:
|
|
449
|
+
flipX !== flipY && (handle === "nw" || handle === "se")
|
|
450
|
+
? "nesw-resize"
|
|
451
|
+
: flipX !== flipY && (handle === "ne" || handle === "sw")
|
|
452
|
+
? "nwse-resize"
|
|
453
|
+
: CURSOR[handle],
|
|
420
454
|
touchAction: "none",
|
|
421
455
|
}}
|
|
422
456
|
/>
|
|
@@ -426,6 +460,7 @@ export function ImageCropOverlay({
|
|
|
426
460
|
|
|
427
461
|
{/* 조작 줄은 회전과 무관하게 세워 둔다 — 기울어진 버튼은 누르기 어렵다. */}
|
|
428
462
|
<div
|
|
463
|
+
ref={barRef}
|
|
429
464
|
data-dp-crop-bar=""
|
|
430
465
|
onPointerDown={(event) => event.stopPropagation()}
|
|
431
466
|
style={{ position: "absolute", ...barPosition, transform: "translateX(-50%)" }}
|
|
@@ -104,7 +104,11 @@ export function Section({
|
|
|
104
104
|
children: React.ReactNode;
|
|
105
105
|
}) {
|
|
106
106
|
return (
|
|
107
|
-
|
|
107
|
+
// `data-le-section` 은 모바일 하단 띠가 섹션을 탭으로 세우는 손잡이다(mobile-editor-bars).
|
|
108
|
+
<section
|
|
109
|
+
data-le-section={title}
|
|
110
|
+
className="border-t border-le-ink-200 px-4 py-3 first:border-t-0"
|
|
111
|
+
>
|
|
108
112
|
<h4 className="mb-2 text-[11px] font-le-semibold uppercase tracking-[0.06em] text-le-ink-400">
|
|
109
113
|
{title}
|
|
110
114
|
</h4>
|