@leviosa-ai/detail-page-editor 0.17.2 → 0.18.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.
@@ -342,6 +342,18 @@ export function matchLayers<
342
342
  );
343
343
  }
344
344
 
345
+ /**
346
+ * ⇧클릭의 기준점 — 기억해 둔 줄이 아직 선택에 있으면(또는 아무것도 안 골랐으면) 그것,
347
+ * 아니면 지금 선택의 마지막 것.
348
+ */
349
+ export function shiftAnchor(
350
+ anchorId: string | null,
351
+ selected: readonly string[],
352
+ ): string | null {
353
+ if (anchorId && (!selected.length || selected.includes(anchorId))) return anchorId;
354
+ return selected[selected.length - 1] ?? null;
355
+ }
356
+
345
357
  /** 누른 줄 하나를 선택에서 넣거나 뺀다(⌘/Ctrl 클릭). */
346
358
  export function toggleId(selected: readonly string[], id: string): string[] {
347
359
  return selected.includes(id)
@@ -636,8 +648,13 @@ export const DetailPageLayersPanel = observer(function DetailPageLayersPanel({
636
648
  */
637
649
  const onSelect = (id: string, mods: { shift: boolean; meta: boolean }) => {
638
650
  if (mods.shift) {
639
- s.selectElements(rangeIds(rows, anchorId, id));
640
- // 기준점은 그대로 둔다 — 범위를 잡았다 놨다 하며 늘릴 수 있어야 한다.
651
+ // 기준점은 목록에서 마지막으로 누른 줄인데, 그사이 캔버스에서 다른 걸 골랐으면
652
+ // 낡은 값이다(없거나 선택 밖). 그때 그대로 쓰면 누른 줄 하나만 잡혀 ⇧클릭이
653
+ // «가끔 안 되는» 것처럼 보인다 — 지금 선택의 마지막 것을 기준점으로 삼는다.
654
+ const anchor = shiftAnchor(anchorId, selectedIds) ?? id;
655
+ s.selectElements(rangeIds(rows, anchor, id));
656
+ // 기준점은 옮기지 않는다 — 범위를 잡았다 놨다 하며 늘릴 수 있어야 한다.
657
+ setAnchorId(anchor);
641
658
  return;
642
659
  }
643
660
  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
@@ -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%)" }}
@@ -22,7 +22,7 @@ import {
22
22
  import { showTextOps } from "./pdf/text-ops";
23
23
  import { fmt, PdfBuilder } from "./pdf/writer";
24
24
  import { decodeSvgDataUri } from "./svg";
25
- import { elementMatrix, IDENTITY } from "./frame";
25
+ import { elementMatrix, IDENTITY, isIdentity, leafMatrix } from "./frame";
26
26
  import {
27
27
  highlightBands,
28
28
  isItalic,
@@ -106,29 +106,13 @@ function shadingSpec(
106
106
  };
107
107
  }
108
108
 
109
- /** Rotate around the element's own origin, the way Konva does. */
109
+ /** Rotate (and flip) around the element's own origin, the way Konva does. */
110
110
  function rotationOps(el: ExportElement): { open: string[]; close: string[] } {
111
- if (el.type === "group") {
112
- // 그룹은 옮기고 돌린 자리가 자식들의 원점이다(자식 좌표는 그룹 원점 기준).
113
- const m = elementMatrix(el, IDENTITY);
114
- if (m.every((v, i) => v === IDENTITY[i])) return { open: [], close: [] };
115
- return { open: ["q", `${m.map(fmt).join(" ")} cm`], close: ["Q"] };
116
- }
117
- const degrees = num(el.rotation);
118
- if (!degrees) return { open: [], close: [] };
119
- const rad = (degrees * Math.PI) / 180;
120
- const cos = Math.cos(rad);
121
- const sin = Math.sin(rad);
122
- const x = num(el.x);
123
- const y = num(el.y);
124
- return {
125
- open: [
126
- "q",
127
- `${fmt(cos)} ${fmt(sin)} ${fmt(-sin)} ${fmt(cos)} ` +
128
- `${fmt(x - x * cos + y * sin)} ${fmt(y - x * sin - y * cos)} cm`,
129
- ],
130
- close: ["Q"],
131
- };
111
+ // 그룹은 옮기고 돌린 자리가 자식들의 원점이다(자식 좌표는 그룹 원점 기준). 잎은 제 x/y에
112
+ // 그려지므로 그 자리를 축으로 돌리고 뒤집는다.
113
+ const m = el.type === "group" ? elementMatrix(el, IDENTITY) : leafMatrix(el);
114
+ if (isIdentity(m)) return { open: [], close: [] };
115
+ return { open: ["q", `${m.map(fmt).join(" ")} cm`], close: ["Q"] };
132
116
  }
133
117
 
134
118
  function figureOps(el: ExportElement, env: AiEnv): string[] {
@@ -20,6 +20,9 @@ export type ExportElement = {
20
20
  width?: number;
21
21
  height?: number;
22
22
  rotation?: number;
23
+ /** 상자 가운데를 축으로 좌우·상하 뒤집기(`@leviosa-ai/canvas/edit/rect`의 `flipArea`). */
24
+ flipX?: boolean;
25
+ flipY?: boolean;
23
26
  opacity?: number;
24
27
  visible?: boolean;
25
28
  fill?: string;
@@ -10,6 +10,7 @@
10
10
  * 언젠가 둘이 갈라진다.
11
11
  */
12
12
 
13
+ import { flipArea } from "@leviosa-ai/canvas/edit/rect";
13
14
  import {
14
15
  hasDocumentCrop,
15
16
  imageFrame,
@@ -54,15 +55,40 @@ export function isTranslation(m: Matrix): boolean {
54
55
  }
55
56
 
56
57
  /**
57
- * 요소의 로컬 좌표(상자 왼쪽 위가 0,0) → 부모 좌표. `parent · T(x, y) · R(rotation)`.
58
+ * 요소의 로컬 좌표(상자 왼쪽 위가 0,0) → 부모 좌표. `parent · T(x, y) · R(rotation) · F`.
58
59
  * 그룹이면 이 행렬이 자식들의 부모 행렬이 된다.
60
+ *
61
+ * `F`는 `flipX`/`flipY` — 화면(element-view `flipped`)처럼 `flipArea` 가운데를 축으로
62
+ * 안쪽만 뒤집는다.
59
63
  */
60
64
  export function elementMatrix(el: ExportElement, parent: Matrix = IDENTITY): Matrix {
61
65
  const rad = (num(el.rotation) * Math.PI) / 180;
62
66
  // 0°·90° 같은 각에서 cos/sin 꼬리(6e-17)가 붙어 isTranslation이 틀리지 않게 다듬는다.
63
67
  const cos = Math.abs(Math.cos(rad)) < 1e-12 ? 0 : Math.cos(rad);
64
68
  const sin = Math.abs(Math.sin(rad)) < 1e-12 ? 0 : Math.sin(rad);
65
- return multiply(parent, [cos, sin, -sin, cos, num(el.x), num(el.y)]);
69
+ const placed = multiply(parent, [cos, sin, -sin, cos, num(el.x), num(el.y)]);
70
+ if (el.flipX !== true && el.flipY !== true) return placed;
71
+ const area = flipArea(el);
72
+ return multiply(placed, [
73
+ el.flipX === true ? -1 : 1,
74
+ 0,
75
+ 0,
76
+ el.flipY === true ? -1 : 1,
77
+ el.flipX === true ? area.x * 2 + area.width : 0,
78
+ el.flipY === true ? area.y * 2 + area.height : 0,
79
+ ]);
80
+ }
81
+
82
+ /**
83
+ * 잎 요소를 **제 x/y 그대로** 그릴 때 씌울 행렬 — `elementMatrix · T(−x, −y)`. 회전·뒤집기가
84
+ * 없으면 단위 행렬이다(그리기 함수들이 부모 좌표 x/y에 그리므로).
85
+ */
86
+ export function leafMatrix(el: ExportElement): Matrix {
87
+ return multiply(elementMatrix(el), [1, 0, 0, 1, -num(el.x), -num(el.y)]);
88
+ }
89
+
90
+ export function isIdentity(m: Matrix): boolean {
91
+ return m.every((v, i) => Math.abs(v - IDENTITY[i]) < 1e-9);
66
92
  }
67
93
 
68
94
  /**
@@ -12,7 +12,7 @@ import {
12
12
  type ExportElement,
13
13
  type ExportPage,
14
14
  } from "./document-model";
15
- import { documentCropFrame, type Size } from "./frame";
15
+ import { documentCropFrame, elementMatrix, leafMatrix, type Matrix, type Size } from "./frame";
16
16
  import {
17
17
  highlightBands,
18
18
  isItalic,
@@ -161,12 +161,14 @@ function elementMarkup(el: ExportElement, env: SvgEnv): string | null {
161
161
  const gx = num(el.x);
162
162
  const gy = num(el.y);
163
163
  const r = num(el.rotation);
164
- const transform = [
165
- gx || gy ? `translate(${fmt(gx)} ${fmt(gy)})` : "",
166
- r ? `rotate(${fmt(r)})` : "",
167
- ]
168
- .filter(Boolean)
169
- .join(" ");
164
+ const transform = flipped(el)
165
+ ? matrixAttr(elementMatrix(el))
166
+ : [
167
+ gx || gy ? `translate(${fmt(gx)} ${fmt(gy)})` : "",
168
+ r ? `rotate(${fmt(r)})` : "",
169
+ ]
170
+ .filter(Boolean)
171
+ .join(" ");
170
172
  return `<g ${attrs({ id: el.name || el.id, opacity: opacityAttr(el), transform })}>\n${inner}\n</g>`;
171
173
  }
172
174
  if (el.type === "text") return textMarkup(el, env);
@@ -174,15 +176,29 @@ function elementMarkup(el: ExportElement, env: SvgEnv): string | null {
174
176
  if (el.type === "svg") {
175
177
  const inline = inlineSvgMarkup(el);
176
178
  // 중첩 <svg>의 transform은 SVG 2에만 있다 — 돌아간 아이콘은 <g>로 감싸 돌린다.
177
- if (inline) return num(el.rotation) ? `<g ${attrs(rotateAttr(el))}>${inline}</g>` : inline;
179
+ if (inline) {
180
+ return num(el.rotation) || flipped(el) ? `<g ${attrs(rotateAttr(el))}>${inline}</g>` : inline;
181
+ }
178
182
  return imageMarkup(el, env);
179
183
  }
180
184
  if (el.type === "image") return imageMarkup(el, env);
181
185
  return null;
182
186
  }
183
187
 
184
- /** 요소 회전 — 화면(Konva)처럼 요소의 왼쪽 위(x, y)를 축으로 돈다. */
188
+ function flipped(el: ExportElement): boolean {
189
+ return el.flipX === true || el.flipY === true;
190
+ }
191
+
192
+ function matrixAttr(m: Matrix): string {
193
+ return `matrix(${m.map(fmt).join(" ")})`;
194
+ }
195
+
196
+ /**
197
+ * 요소 회전 — 화면(Konva)처럼 요소의 왼쪽 위(x, y)를 축으로 돈다. 뒤집힌 요소는 회전과
198
+ * 뒤집기를 합친 행렬로 쓴다(`frame.ts`의 `leafMatrix`).
199
+ */
185
200
  function rotateAttr(el: ExportElement): { transform?: string } {
201
+ if (flipped(el)) return { transform: matrixAttr(leafMatrix(el)) };
186
202
  const r = num(el.rotation);
187
203
  return r ? { transform: `rotate(${fmt(r)} ${fmt(num(el.x))} ${fmt(num(el.y))})` } : {};
188
204
  }
@@ -351,7 +367,7 @@ function sameLineRun(a: ExportElement, b: ExportElement, env: SvgEnv): boolean {
351
367
  // 흐르고, 위로 띄운 첨자(sup)는 선이 다르니 제 자리에 남는다.
352
368
  if (Math.abs(baselineOf(a, env) - baselineOf(b, env)) > 0.3 * fs) return false;
353
369
  if ((a.fontFamily || "") !== (b.fontFamily || "")) return false;
354
- if (num(a.rotation) || num(b.rotation)) return false;
370
+ if (num(a.rotation) || num(b.rotation) || flipped(a) || flipped(b)) return false;
355
371
  // 형광펜 띠는 런마다 따로 그려야 한다 — 한 줄로 합치면 띠가 사라진다.
356
372
  if (a.custom?.highlightColor || b.custom?.highlightColor) return false;
357
373
  if (num(a.opacity, 1) !== num(b.opacity, 1)) return false;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@leviosa-ai/detail-page-editor",
3
- "version": "0.17.2",
3
+ "version": "0.18.0",
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": "SEE LICENSE IN LICENSE",
@@ -68,7 +68,7 @@
68
68
  "woff2-encoder": "2.0.0"
69
69
  },
70
70
  "peerDependencies": {
71
- "@leviosa-ai/canvas": "0.9.1",
71
+ "@leviosa-ai/canvas": "0.10.0",
72
72
  "@tanstack/react-query": "^5.100.3",
73
73
  "konva": "^10.0.0",
74
74
  "lucide-react": "^0.563.0 || ^1.0.0",