@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.
- package/components/detail-page/detail-page-layers-panel.tsx +19 -2
- package/components/detail-page/detail-page-properties-panel.tsx +67 -13
- package/components/detail-page/image-crop-overlay.tsx +43 -8
- 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
|
@@ -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
|
-
|
|
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
|
|
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
|
|
@@ -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%)" }}
|
|
@@ -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
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
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
|
-
|
|
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
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
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)
|
|
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
|
-
|
|
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.
|
|
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.
|
|
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",
|