@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.
@@ -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={{ width, maxHeight: `min(60vh, 520px, ${maxHeight}px)` }}
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
- className="inline-flex h-9 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"
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
- {t("editor.download")}
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
- className="inline-flex h-9 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"
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
- {saving ? t("editor.saving") : t("editor.save")}
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-screen min-h-[640px] flex-col bg-le-ink-100"
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 className="grid min-h-0 flex-1 grid-cols-[1fr_360px]">
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
- "flex h-6 w-6 items-center justify-center rounded transition-colors",
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
- s.selectElements(rangeIds(rows, anchorId, id));
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 start = num(axis === "x" ? sib.x : sib.y);
240
- const size = num(axis === "x" ? sib.width : sib.height);
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 size = num(axis === "x" ? el.width : el.height);
291
- const coord = Math.round(alignedCoord(frame, size, where));
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 size = num(axis === "x" ? el.width : el.height);
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 = num(axis === "x" ? el.x : el.y);
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
- const items = toItems(els as DistributeElement[], axis);
330
- const coords = items && distributeCoords(items);
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
- el.set(axis === "x" ? { x: coord } : { y: coord });
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) => el.set({ [key]: Math.round(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 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">
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 = 180;
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 = box.left + view.left + view.width / 2;
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(BAR_MARGIN, Math.min(barCenter, hostWidth - BAR_MARGIN))
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: `rotate(${box.rotation}deg)`,
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
- cursor: CURSOR[handle],
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
- <section className="border-t border-le-ink-200 px-4 py-3 first:border-t-0">
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>