@leviosa-ai/detail-page-editor 0.1.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/cardnews/color-input.tsx +380 -0
- package/components/detail-page/ai-generate-panel.tsx +763 -0
- package/components/detail-page/annotation-canvas.tsx +860 -0
- package/components/detail-page/annotation-dialog.tsx +397 -0
- package/components/detail-page/bubble-tail-overlay.tsx +209 -0
- package/components/detail-page/canvas-context-menu.tsx +234 -0
- package/components/detail-page/canvas-observer.tsx +68 -0
- package/components/detail-page/canvas-overlay-host.tsx +44 -0
- package/components/detail-page/canvas-store-facade.ts +51 -0
- package/components/detail-page/chart-inspector.tsx +531 -0
- package/components/detail-page/design-reference-picker.tsx +383 -0
- package/components/detail-page/detail-page-basic-shapes.tsx +180 -0
- package/components/detail-page/detail-page-brand-gifs-panel.tsx +261 -0
- package/components/detail-page/detail-page-brand-kit-panel.tsx +77 -0
- package/components/detail-page/detail-page-brand-panel-header.tsx +44 -0
- package/components/detail-page/detail-page-charts-panel.tsx +104 -0
- package/components/detail-page/detail-page-decorations-panel.tsx +125 -0
- package/components/detail-page/detail-page-download-dialog.tsx +524 -0
- package/components/detail-page/detail-page-editor.tsx +974 -0
- package/components/detail-page/detail-page-elements-panel.tsx +117 -0
- package/components/detail-page/detail-page-font-picker.tsx +231 -0
- package/components/detail-page/detail-page-history-buttons.tsx +87 -0
- package/components/detail-page/detail-page-host-context.tsx +465 -0
- package/components/detail-page/detail-page-icons-panel.tsx +251 -0
- package/components/detail-page/detail-page-konva-json-preview.tsx +474 -0
- package/components/detail-page/detail-page-layers-panel.tsx +618 -0
- package/components/detail-page/detail-page-my-images-panel.tsx +179 -0
- package/components/detail-page/detail-page-my-shapes-panel.tsx +174 -0
- package/components/detail-page/detail-page-page-toolbar.tsx +171 -0
- package/components/detail-page/detail-page-pages-panel.tsx +299 -0
- package/components/detail-page/detail-page-pages-timeline.tsx +79 -0
- package/components/detail-page/detail-page-photos-panel.tsx +257 -0
- package/components/detail-page/detail-page-properties-panel.tsx +2680 -0
- package/components/detail-page/detail-page-qr-panel.tsx +137 -0
- package/components/detail-page/detail-page-references-panel.tsx +166 -0
- package/components/detail-page/detail-page-selection.ts +44 -0
- package/components/detail-page/detail-page-shapes-panel.tsx +16 -0
- package/components/detail-page/detail-page-sidebar-sections.tsx +324 -0
- package/components/detail-page/detail-page-tables-panel.tsx +106 -0
- package/components/detail-page/detail-page-text-panel.tsx +179 -0
- package/components/detail-page/detail-page-thumbnail-bus.ts +42 -0
- package/components/detail-page/edit-quota-ui.tsx +150 -0
- package/components/detail-page/editor-history.ts +56 -0
- package/components/detail-page/editor-hotkeys.tsx +165 -0
- package/components/detail-page/element-recents-strip.tsx +105 -0
- package/components/detail-page/element-rects.ts +82 -0
- package/components/detail-page/fill-control.tsx +261 -0
- package/components/detail-page/find-replace-panel.tsx +252 -0
- package/components/detail-page/generation-progress-fill.tsx +57 -0
- package/components/detail-page/gif-animator.tsx +184 -0
- package/components/detail-page/gif-effect-picker.tsx +136 -0
- package/components/detail-page/group-drill-in.tsx +219 -0
- package/components/detail-page/group-prompt-edit-panel.tsx +154 -0
- package/components/detail-page/hover-highlight-overlay.tsx +111 -0
- package/components/detail-page/hovered-layer.ts +34 -0
- package/components/detail-page/inspector-controls.tsx +175 -0
- package/components/detail-page/leviosa-canvas-workspace.tsx +313 -0
- package/components/detail-page/nested-selection-overlay.tsx +364 -0
- package/components/detail-page/panel-search-input.tsx +49 -0
- package/components/detail-page/prompt-edit-panel.tsx +146 -0
- package/components/detail-page/section-height-handle.tsx +276 -0
- package/components/detail-page/section-reauthor-controller.tsx +210 -0
- package/components/detail-page/spec-prompt-edit-panel.tsx +178 -0
- package/components/detail-page/spec-resize-absorber.tsx +149 -0
- package/components/detail-page/svg-prompt-edit-panel.tsx +139 -0
- package/components/detail-page/table-canvas-overlay.tsx +707 -0
- package/components/detail-page/table-inspector.tsx +586 -0
- package/components/ui/button.tsx +64 -0
- package/components/ui/command.tsx +175 -0
- package/components/ui/dialog.tsx +167 -0
- package/components/ui/label.tsx +24 -0
- package/components/ui/select.tsx +190 -0
- package/components/ui/slider.tsx +63 -0
- package/components/ui/switch.tsx +35 -0
- package/components/ui/tooltip.tsx +57 -0
- package/config/detail-page-bundle-font-tags.json +39 -0
- package/config/detail-page-fonts.json +373 -0
- package/index.ts +24 -0
- package/lib/cardnews/font-loader.ts +306 -0
- package/lib/detail-page/animation-sniff.ts +101 -0
- package/lib/detail-page/asset-bytes-url.ts +70 -0
- package/lib/detail-page/basic-shapes.ts +603 -0
- package/lib/detail-page/canvas-menu.ts +182 -0
- package/lib/detail-page/chart/defaults.ts +283 -0
- package/lib/detail-page/chart/format.ts +66 -0
- package/lib/detail-page/chart/normalize.ts +149 -0
- package/lib/detail-page/chart/palette.ts +85 -0
- package/lib/detail-page/chart/paste.ts +82 -0
- package/lib/detail-page/chart/preview.ts +78 -0
- package/lib/detail-page/chart/render.ts +188 -0
- package/lib/detail-page/chart/renderers/bar-h.ts +103 -0
- package/lib/detail-page/chart/renderers/bar-v.ts +213 -0
- package/lib/detail-page/chart/renderers/donut.ts +186 -0
- package/lib/detail-page/chart/renderers/gauge.ts +96 -0
- package/lib/detail-page/chart/renderers/line.ts +143 -0
- package/lib/detail-page/chart/renderers/shared.ts +263 -0
- package/lib/detail-page/chart/renderers/stack.ts +121 -0
- package/lib/detail-page/chart/sync.ts +92 -0
- package/lib/detail-page/chart/types.ts +145 -0
- package/lib/detail-page/css-color.ts +34 -0
- package/lib/detail-page/data-gif-payload.ts +167 -0
- package/lib/detail-page/design-reference.ts +244 -0
- package/lib/detail-page/distribute.ts +89 -0
- package/lib/detail-page/editor-routes.ts +10 -0
- package/lib/detail-page/element-recents.ts +167 -0
- package/lib/detail-page/find-replace.ts +120 -0
- package/lib/detail-page/format-painter.ts +159 -0
- package/lib/detail-page/gif-frames.ts +216 -0
- package/lib/detail-page/gif-timeline.ts +65 -0
- package/lib/detail-page/group-action.ts +62 -0
- package/lib/detail-page/hangul-roman.ts +58 -0
- package/lib/detail-page/icon-curated.ts +290 -0
- package/lib/detail-page/icon-fuzzy.ts +250 -0
- package/lib/detail-page/icon-keywords.generated.ts +3766 -0
- package/lib/detail-page/icon-keywords.ko.ts +524 -0
- package/lib/detail-page/icon-search.ts +257 -0
- package/lib/detail-page/icons.ts +67 -0
- package/lib/detail-page/image-credit.ts +121 -0
- package/lib/detail-page/image-data-uri.ts +29 -0
- package/lib/detail-page/insert-image.ts +75 -0
- package/lib/detail-page/insert-shape.ts +49 -0
- package/lib/detail-page/korean-dictionary.ts +187 -0
- package/lib/detail-page/layer-move.ts +257 -0
- package/lib/detail-page/new-product-draft-store.ts +105 -0
- package/lib/detail-page/new-product-draft.ts +283 -0
- package/lib/detail-page/personal-images-refresh.ts +30 -0
- package/lib/detail-page/product-source.ts +587 -0
- package/lib/detail-page/qr-code.ts +214 -0
- package/lib/detail-page/reference-image.ts +103 -0
- package/lib/detail-page/reference-upload.ts +181 -0
- package/lib/detail-page/replace-page.ts +35 -0
- package/lib/detail-page/replace-with-gif.ts +251 -0
- package/lib/detail-page/section-height.ts +131 -0
- package/lib/detail-page/section-reauthor-bus.ts +61 -0
- package/lib/detail-page/shape-to-image.ts +219 -0
- package/lib/detail-page/spec-group/clipboard.ts +35 -0
- package/lib/detail-page/spec-group/sync.ts +377 -0
- package/lib/detail-page/spec-group/text-metrics.ts +48 -0
- package/lib/detail-page/stock-photos.ts +88 -0
- package/lib/detail-page/svg-colors.ts +74 -0
- package/lib/detail-page/table/defaults.ts +344 -0
- package/lib/detail-page/table/edit.ts +312 -0
- package/lib/detail-page/table/harvest.ts +103 -0
- package/lib/detail-page/table/normalize.ts +125 -0
- package/lib/detail-page/table/paste.ts +49 -0
- package/lib/detail-page/table/preview.ts +66 -0
- package/lib/detail-page/table/render.ts +388 -0
- package/lib/detail-page/table/sync.ts +107 -0
- package/lib/detail-page/table/types.ts +102 -0
- package/lib/detail-page/text-gif-layout.ts +245 -0
- package/lib/detail-page/text-gif-payload.ts +67 -0
- package/lib/detail-page/text-presets.ts +230 -0
- package/lib/detail-page/use-fake-progress.ts +67 -0
- package/lib/detail-page/z-order.ts +82 -0
- package/lib/detail-page-canvas/bubble-path.ts +190 -0
- package/lib/detail-page-canvas/canvas-key.ts +22 -0
- package/lib/detail-page-canvas/clip-rect.ts +65 -0
- package/lib/detail-page-canvas/custom-props-adapter.ts +219 -0
- package/lib/detail-page-canvas/editor-fonts.ts +161 -0
- package/lib/detail-page-canvas/export/ai.ts +413 -0
- package/lib/detail-page-canvas/export/color.ts +35 -0
- package/lib/detail-page-canvas/export/document-model.ts +78 -0
- package/lib/detail-page-canvas/export/export-files.ts +290 -0
- package/lib/detail-page-canvas/export/font-subset.ts +264 -0
- package/lib/detail-page-canvas/export/gif-export.ts +310 -0
- package/lib/detail-page-canvas/export/gif-plan.ts +95 -0
- package/lib/detail-page-canvas/export/mp4-encode.ts +158 -0
- package/lib/detail-page-canvas/export/mp4-support.ts +22 -0
- package/lib/detail-page-canvas/export/pdf/geometry.ts +309 -0
- package/lib/detail-page-canvas/export/pdf/inline-svg.ts +399 -0
- package/lib/detail-page-canvas/export/pdf/resources.ts +443 -0
- package/lib/detail-page-canvas/export/pdf/text-ops.ts +81 -0
- package/lib/detail-page-canvas/export/pdf/writer.ts +109 -0
- package/lib/detail-page-canvas/export/psd.ts +354 -0
- package/lib/detail-page-canvas/export/raster.ts +255 -0
- package/lib/detail-page-canvas/export/svg.ts +479 -0
- package/lib/detail-page-canvas/export/text-layout.ts +142 -0
- package/lib/detail-page-canvas/font-catalog.ts +177 -0
- package/lib/detail-page-canvas/font-tags.ts +47 -0
- package/lib/detail-page-canvas/gif-web-fonts.ts +90 -0
- package/lib/detail-page-canvas/konva-fallback.ts +477 -0
- package/lib/detail-page-canvas/text-background-plan.ts +124 -0
- package/lib/detail-page-canvas/text-highlight-bands.ts +127 -0
- package/lib/utils.ts +10 -0
- package/package.json +68 -0
- package/types/commerce.ts +171 -0
- package/types/detail-page-api.ts +200 -0
- package/types/detail-page-canvas.ts +20 -0
- package/types/fontkit.d.ts +42 -0
- package/types/gifenc.d.ts +51 -0
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useEffect, useRef } from "react";
|
|
4
|
+
import { observer } from "./canvas-observer";
|
|
5
|
+
import Konva from "konva";
|
|
6
|
+
|
|
7
|
+
import { isGifSrc } from "../../lib/detail-page-canvas/export/gif-plan";
|
|
8
|
+
import {
|
|
9
|
+
decodeAnimation,
|
|
10
|
+
frameAtTime,
|
|
11
|
+
type DecodedAnimation,
|
|
12
|
+
} from "../../lib/detail-page/gif-frames";
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* 움직이는 이미지(GIF·WebP)를 캔버스에서 재생시키는 컨트롤러(오버레이 없음).
|
|
16
|
+
*
|
|
17
|
+
* 핵심 사실 하나에서 출발한다. **`drawImage(움직이는 이미지)` 는 언제나 첫 프레임을
|
|
18
|
+
* 준다.** 이미지가 문서에 붙어 있든 아니든, GIF 든 WebP 든 마찬가지다(실측: 헤드리스·
|
|
19
|
+
* 헤드풀 Chrome 양쪽에서 3프레임 애니메이션을 1.5초 동안 12번 찍어도 색이 하나였다).
|
|
20
|
+
* 브라우저는 화면에 그려지는 `<img>` 만 프레임을 넘기고, 캔버스로 퍼 갈 때는 넘겨주지
|
|
21
|
+
* 않는다. 그래서 "레이어를 계속 다시 그리면 브라우저가 넘긴 프레임이 찍힌다"는 접근은
|
|
22
|
+
* 성립하지 않는다 — 첫 프레임을 15fps 로 다시 그릴 뿐이다.
|
|
23
|
+
*
|
|
24
|
+
* 그러므로 프레임은 우리가 직접 넘긴다. 소스를 한 번 디코딩해 두고(gif-frames), 매 틱
|
|
25
|
+
* Konva.Image 의 `image` 를 그 시각의 프레임 캔버스로 갈아 끼운 뒤 레이어를 다시 그린다.
|
|
26
|
+
* 프레임 캔버스는 원본과 같은 크기라서 엔진이 계산해 둔 `crop`·`cornerRadius`·배치가
|
|
27
|
+
* 그대로 맞는다 — 잘라내기 수식을 여기서 다시 구현하지 않아도 된다(예전에는 편집기의
|
|
28
|
+
* 오프스크린 스냅샷을 흉내 내느라 복제했고, 엔진이 바뀔 때마다 어긋났다).
|
|
29
|
+
*
|
|
30
|
+
* GIF는 진짜 Konva 노드이므로 z-order·그 위 텍스트·불투명도·드래그가 모두 네이티브로
|
|
31
|
+
* 맞다. 실제 export 는 별도로 프레임을 합성하므로 이 프리뷰 경로와 무관하다.
|
|
32
|
+
*
|
|
33
|
+
* 성능: 움직이는 요소가 있고 탭이 보일 때만 돈다. 스택 워크스페이스는 활성 인덱스 주변
|
|
34
|
+
* 몇 페이지만 마운트하므로 다시 그리는 레이어는 보통 1~2개다. 각 src는 한 번만 디코드.
|
|
35
|
+
*/
|
|
36
|
+
|
|
37
|
+
type ElementLike = {
|
|
38
|
+
id: string;
|
|
39
|
+
type?: string;
|
|
40
|
+
src?: string;
|
|
41
|
+
visible?: boolean;
|
|
42
|
+
custom?: Record<string, unknown> | null;
|
|
43
|
+
children?: ElementLike[];
|
|
44
|
+
};
|
|
45
|
+
type StoreLike = { pages?: Array<{ children?: ElementLike[] }> };
|
|
46
|
+
|
|
47
|
+
const FPS = 15;
|
|
48
|
+
const FRAME_MS = 1000 / FPS;
|
|
49
|
+
|
|
50
|
+
function isGifElement(el: ElementLike): boolean {
|
|
51
|
+
if (el.custom && (el.custom as { detailPageGif?: unknown }).detailPageGif) return true;
|
|
52
|
+
return (el.type === "image" || el.type === "svg") && isGifSrc(el.src);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function collectGifElements(children: ElementLike[] | undefined, out: ElementLike[]): void {
|
|
56
|
+
for (const el of children ?? []) {
|
|
57
|
+
if (isGifElement(el) && el.src && el.visible !== false) out.push(el);
|
|
58
|
+
if (el.children) collectGifElements(el.children, out);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* 재생 대상 요소 id(모든 페이지, 그룹 재귀). 숨김(visible:false)·src 없음은 제외.
|
|
64
|
+
* 애니메이션 루프가 다시 그릴 노드를 이 id들로 찾는다.
|
|
65
|
+
*/
|
|
66
|
+
export function gifElementIds(pages: Array<{ children?: ElementLike[] }> | undefined): string[] {
|
|
67
|
+
const els: ElementLike[] = [];
|
|
68
|
+
for (const page of pages ?? []) collectGifElements(page.children, els);
|
|
69
|
+
return els.map((el) => el.id);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** `image()` 를 가진 노드 — 이미지 노드 자신이거나, 요소 그룹 안의 이미지 자식. */
|
|
73
|
+
type ImageNode = Konva.Node & { image: (value?: CanvasImageSource) => unknown };
|
|
74
|
+
|
|
75
|
+
function hasImageAccessor(node: Konva.Node | null): node is ImageNode {
|
|
76
|
+
return typeof (node as { image?: unknown } | null)?.image === "function";
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* 요소 id로 그릴 이미지 노드를 찾는다.
|
|
81
|
+
*
|
|
82
|
+
* 엔진이 요소 하나를 `id` 를 단 Group 으로 감싸고 그 안에 이미지 노드를 두므로, id 로
|
|
83
|
+
* 찾은 노드가 곧 이미지 노드는 아니다. 한 겹 더 내려가야 한다.
|
|
84
|
+
*/
|
|
85
|
+
export function findImageNode(id: string, stages: Konva.Stage[]): ImageNode | null {
|
|
86
|
+
for (const stage of stages) {
|
|
87
|
+
const found = stage.findOne(`#${id}`);
|
|
88
|
+
if (!found) continue;
|
|
89
|
+
if (hasImageAccessor(found)) return found;
|
|
90
|
+
const inner = (found as Konva.Container).findOne?.("Image");
|
|
91
|
+
return hasImageAccessor(inner ?? null) ? (inner as ImageNode) : null;
|
|
92
|
+
}
|
|
93
|
+
return null;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export const GifAnimator = observer(function GifAnimator({ store }: { store: unknown }) {
|
|
97
|
+
const s = store as StoreLike;
|
|
98
|
+
// render 중에 읽어(mobx) GIF가 생기거나 숨겨지면 루프가 재구성되게 한다.
|
|
99
|
+
const els: ElementLike[] = [];
|
|
100
|
+
for (const page of s.pages ?? []) collectGifElements(page.children, els);
|
|
101
|
+
const key = els.map((el) => `${el.id}:${el.src ?? ""}`).join(",");
|
|
102
|
+
|
|
103
|
+
// 요소 참조/디코드 캐시(effect 밖 stable ref). 루프는 매 틱 최신 요소 속성을 읽는다.
|
|
104
|
+
const elMapRef = useRef<Map<string, ElementLike>>(new Map());
|
|
105
|
+
const decodedRef = useRef<Map<string, DecodedAnimation>>(new Map()); // src → 디코드 프레임
|
|
106
|
+
const failedRef = useRef<Set<string>>(new Set()); // 디코드 실패한 src(재시도·재로그 억제)
|
|
107
|
+
const staticRef = useRef<Set<string>>(new Set()); // 한 장으로 디코드된 src(재로그 억제)
|
|
108
|
+
elMapRef.current = new Map(els.map((el) => [el.id, el]));
|
|
109
|
+
|
|
110
|
+
useEffect(() => {
|
|
111
|
+
if (!els.length) return;
|
|
112
|
+
let raf = 0;
|
|
113
|
+
let last = 0;
|
|
114
|
+
let stopped = false;
|
|
115
|
+
const startedAt = typeof performance !== "undefined" ? performance.now() : 0;
|
|
116
|
+
|
|
117
|
+
// 새 src만 디코드(멱등). 실패하면 그 GIF는 정적으로 남는데, 예전엔 그걸 조용히
|
|
118
|
+
// 삼켜서 "캔버스에서만 재생이 안 되는" 증상만 남았다(원인은 CSP connect-src 차단).
|
|
119
|
+
// 같은 src로는 재시도하지 않고, 대신 이유를 한 번 남긴다.
|
|
120
|
+
for (const el of els) {
|
|
121
|
+
const src = el.src;
|
|
122
|
+
if (src && !decodedRef.current.has(src) && !failedRef.current.has(src)) {
|
|
123
|
+
decodeAnimation(src)
|
|
124
|
+
.then((g) => {
|
|
125
|
+
if (!stopped) decodedRef.current.set(src, g);
|
|
126
|
+
})
|
|
127
|
+
.catch((err) => {
|
|
128
|
+
failedRef.current.add(src);
|
|
129
|
+
console.warn("[GifAnimator] 디코드 실패 — 정적으로 표시됩니다.", src, err);
|
|
130
|
+
});
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
const tick = (t: number) => {
|
|
135
|
+
if (stopped) return;
|
|
136
|
+
raf = requestAnimationFrame(tick);
|
|
137
|
+
if (t - last < FRAME_MS) return; // ~15fps로 스로틀.
|
|
138
|
+
last = t;
|
|
139
|
+
if (typeof document !== "undefined" && document.hidden) return;
|
|
140
|
+
|
|
141
|
+
const layers = new Set<Konva.Layer>();
|
|
142
|
+
for (const [id, el] of elMapRef.current) {
|
|
143
|
+
const src = el.src;
|
|
144
|
+
const gif = src ? decodedRef.current.get(src) : undefined;
|
|
145
|
+
if (!gif) continue;
|
|
146
|
+
// 한 장짜리(정지 이미지·디코드 폴백)는 갈아 끼울 것이 없다. 다만 **움직이는
|
|
147
|
+
// 자산으로 꽂힌 요소가** 한 장으로 나오면 그건 사고다 — 디코더가 프레임 수를
|
|
148
|
+
// 잘못 준 적이 있고(ImageDecoder tracks.ready 누락), 실패가 아니라서 콘솔에
|
|
149
|
+
// 아무 흔적도 남지 않아 "그냥 안 움직인다"로만 보였다. 한 번은 남긴다.
|
|
150
|
+
if (gif.frames.length <= 1) {
|
|
151
|
+
if (src && !staticRef.current.has(src)) {
|
|
152
|
+
staticRef.current.add(src);
|
|
153
|
+
console.warn("[GifAnimator] 프레임이 한 장이라 정지 상태로 둡니다.", src);
|
|
154
|
+
}
|
|
155
|
+
continue;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
const node = findImageNode(id, Konva.stages);
|
|
159
|
+
const layer = node?.getLayer();
|
|
160
|
+
if (!node || !layer) continue;
|
|
161
|
+
|
|
162
|
+
const frame = frameAtTime(gif, t - startedAt);
|
|
163
|
+
if (node.image() === frame.canvas) continue; // 같은 프레임이면 건드리지 않는다.
|
|
164
|
+
node.image(frame.canvas);
|
|
165
|
+
// 필터로 노드가 캐시돼 있으면 캐시를 비워야 갱신이 보인다(필터효과는 재생 중 생략).
|
|
166
|
+
const cached = node as unknown as { isCached?: () => boolean; clearCache?: () => void };
|
|
167
|
+
if (cached.isCached?.()) cached.clearCache?.();
|
|
168
|
+
layers.add(layer);
|
|
169
|
+
}
|
|
170
|
+
for (const layer of layers) layer.batchDraw();
|
|
171
|
+
};
|
|
172
|
+
|
|
173
|
+
raf = requestAnimationFrame(tick);
|
|
174
|
+
return () => {
|
|
175
|
+
stopped = true;
|
|
176
|
+
cancelAnimationFrame(raf);
|
|
177
|
+
};
|
|
178
|
+
// ids/속성은 key(문자열)로 대표. Konva.stages·요소는 라이브로 읽으므로 dep 불필요.
|
|
179
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
180
|
+
}, [key]);
|
|
181
|
+
|
|
182
|
+
return null;
|
|
183
|
+
});
|
|
184
|
+
GifAnimator.displayName = "GifAnimator";
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
// GIF 이펙트 픽커 — 폰트 픽커와 같은 방식(팝오버 + 실물 미리보기)으로 고른다.
|
|
4
|
+
//
|
|
5
|
+
// 원래는 <select> 였는데, "물결"·"흔들림"·"홀로그램 포일 (실버)" 같은 이름만 봐서는
|
|
6
|
+
// 뭐가 어떻게 움직이는지 알 수 없어 매번 크레딧을 써서 만들어 봐야 했다. 폰트 픽커가
|
|
7
|
+
// 글자 미리보기 WebP를 보여주듯, 여기서는 그 이펙트로 실제로 구운 GIF를 보여준다.
|
|
8
|
+
// 미리보기는 정적 자산이다(`public/gif-effect-previews/`, 소싱 저장소의
|
|
9
|
+
// `scripts/detail_page_gif_effect_previews.py` 로 굽는다) — 고르는 데 서버 호출이 없다.
|
|
10
|
+
|
|
11
|
+
import { useState } from "react";
|
|
12
|
+
import Image from "next/image";
|
|
13
|
+
import { Check, ChevronDown } from "lucide-react";
|
|
14
|
+
import { Popover as PopoverPrimitive } from "radix-ui";
|
|
15
|
+
import { useTranslation } from "react-i18next";
|
|
16
|
+
|
|
17
|
+
export type GifEffectOption = {
|
|
18
|
+
id: string;
|
|
19
|
+
label: string;
|
|
20
|
+
/** 목록 아래에 띄울 한 줄 설명. */
|
|
21
|
+
hint?: string;
|
|
22
|
+
/** 있으면 이 라벨로 묶어서 보여준다(이미지 이펙트의 통짜/물체 구분). */
|
|
23
|
+
group?: string;
|
|
24
|
+
/** 미리보기 GIF 경로(`/gif-effect-previews/...`). */
|
|
25
|
+
previewSrc: string;
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
export function GifEffectPicker({
|
|
29
|
+
value,
|
|
30
|
+
options,
|
|
31
|
+
onChange,
|
|
32
|
+
disabled = false,
|
|
33
|
+
ariaLabel,
|
|
34
|
+
}: {
|
|
35
|
+
value: string;
|
|
36
|
+
options: GifEffectOption[];
|
|
37
|
+
onChange: (id: string) => void;
|
|
38
|
+
disabled?: boolean;
|
|
39
|
+
ariaLabel?: string;
|
|
40
|
+
}) {
|
|
41
|
+
const { t } = useTranslation("branding");
|
|
42
|
+
const [open, setOpen] = useState(false);
|
|
43
|
+
const selected = options.find((option) => option.id === value);
|
|
44
|
+
const label =
|
|
45
|
+
ariaLabel ??
|
|
46
|
+
t("detailPage.properties.gifEffectChoose", { defaultValue: "이펙트 고르기" });
|
|
47
|
+
|
|
48
|
+
// 그룹 순서는 options 등장 순서를 따른다(카탈로그 순서 = 픽커 순서).
|
|
49
|
+
const groups: Array<{ name: string; items: GifEffectOption[] }> = [];
|
|
50
|
+
for (const option of options) {
|
|
51
|
+
const name = option.group ?? "";
|
|
52
|
+
const bucket = groups.find((entry) => entry.name === name);
|
|
53
|
+
if (bucket) bucket.items.push(option);
|
|
54
|
+
else groups.push({ name, items: [option] });
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
return (
|
|
58
|
+
<PopoverPrimitive.Root open={open} onOpenChange={setOpen}>
|
|
59
|
+
<PopoverPrimitive.Trigger asChild>
|
|
60
|
+
<button
|
|
61
|
+
type="button"
|
|
62
|
+
disabled={disabled}
|
|
63
|
+
aria-label={label}
|
|
64
|
+
aria-expanded={open}
|
|
65
|
+
className="flex min-w-0 items-center justify-between gap-2 rounded-md border border-neutral-200 bg-white px-2 py-1.5 text-left text-sm text-neutral-900 outline-none hover:bg-neutral-50 focus:border-neutral-400 disabled:cursor-not-allowed disabled:opacity-50"
|
|
66
|
+
>
|
|
67
|
+
<span className="min-w-0 truncate">{selected?.label ?? value}</span>
|
|
68
|
+
<ChevronDown
|
|
69
|
+
aria-hidden="true"
|
|
70
|
+
className="shrink-0 text-neutral-400"
|
|
71
|
+
size={14}
|
|
72
|
+
/>
|
|
73
|
+
</button>
|
|
74
|
+
</PopoverPrimitive.Trigger>
|
|
75
|
+
<PopoverPrimitive.Portal>
|
|
76
|
+
<PopoverPrimitive.Content
|
|
77
|
+
side="left"
|
|
78
|
+
align="start"
|
|
79
|
+
sideOffset={8}
|
|
80
|
+
collisionPadding={12}
|
|
81
|
+
className="z-[120] w-[300px] overflow-hidden rounded-lg border border-neutral-200 bg-white shadow-[0_10px_32px_rgba(0,0,0,0.14)]"
|
|
82
|
+
>
|
|
83
|
+
<div className="max-h-[420px] overflow-y-auto p-1">
|
|
84
|
+
{groups.map((group) => (
|
|
85
|
+
<div key={group.name || "default"}>
|
|
86
|
+
{group.name ? (
|
|
87
|
+
<p className="px-2 pb-1 pt-2 text-[10px] font-semibold uppercase tracking-[0.06em] text-neutral-400">
|
|
88
|
+
{group.name}
|
|
89
|
+
</p>
|
|
90
|
+
) : null}
|
|
91
|
+
{group.items.map((option) => (
|
|
92
|
+
<button
|
|
93
|
+
key={option.id}
|
|
94
|
+
type="button"
|
|
95
|
+
aria-pressed={option.id === value}
|
|
96
|
+
onClick={() => {
|
|
97
|
+
onChange(option.id);
|
|
98
|
+
setOpen(false);
|
|
99
|
+
}}
|
|
100
|
+
className="flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-left hover:bg-neutral-50"
|
|
101
|
+
>
|
|
102
|
+
<span className="flex h-12 w-[92px] shrink-0 items-center justify-center overflow-hidden rounded border border-neutral-100 bg-neutral-50">
|
|
103
|
+
{/* 미리보기는 움직여야 의미가 있다 — 최적화(정지 프레임 변환)를 끈다. */}
|
|
104
|
+
<Image
|
|
105
|
+
src={option.previewSrc}
|
|
106
|
+
alt={option.label}
|
|
107
|
+
width={184}
|
|
108
|
+
height={96}
|
|
109
|
+
className="h-full w-auto object-contain"
|
|
110
|
+
unoptimized
|
|
111
|
+
/>
|
|
112
|
+
</span>
|
|
113
|
+
<span className="min-w-0 flex-1">
|
|
114
|
+
<span className="block truncate text-sm text-neutral-900">
|
|
115
|
+
{option.label}
|
|
116
|
+
</span>
|
|
117
|
+
{option.hint ? (
|
|
118
|
+
<span className="mt-0.5 block text-[11px] leading-4 text-neutral-400">
|
|
119
|
+
{option.hint}
|
|
120
|
+
</span>
|
|
121
|
+
) : null}
|
|
122
|
+
</span>
|
|
123
|
+
{option.id === value ? (
|
|
124
|
+
<Check aria-hidden="true" className="shrink-0" size={15} />
|
|
125
|
+
) : null}
|
|
126
|
+
</button>
|
|
127
|
+
))}
|
|
128
|
+
</div>
|
|
129
|
+
))}
|
|
130
|
+
</div>
|
|
131
|
+
<PopoverPrimitive.Arrow className="fill-white" />
|
|
132
|
+
</PopoverPrimitive.Content>
|
|
133
|
+
</PopoverPrimitive.Portal>
|
|
134
|
+
</PopoverPrimitive.Root>
|
|
135
|
+
);
|
|
136
|
+
}
|
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useEffect, useRef, type RefObject } from "react";
|
|
4
|
+
|
|
5
|
+
import {
|
|
6
|
+
konvaClientRect,
|
|
7
|
+
pointInRect,
|
|
8
|
+
type ClientRect,
|
|
9
|
+
} from "./element-rects";
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Figma-style drill-in: click a group once to select the group, click again on a
|
|
13
|
+
* shape inside it to select that shape.
|
|
14
|
+
*
|
|
15
|
+
* Canvas cannot do this on its own — its canvas resolves every hit node to
|
|
16
|
+
* ``el.top`` (the outermost non-group ancestor), so a press inside a group always
|
|
17
|
+
* re-selects the group and a group child is unreachable from the canvas. This
|
|
18
|
+
* listener watches the gesture on the workspace container without ever blocking it:
|
|
19
|
+
* when the group under the pointer already holds the selection, a click (press and
|
|
20
|
+
* release in place) selects the shape under the cursor. A press that turns into a
|
|
21
|
+
* DRAG is left alone — that is the user moving the whole group.
|
|
22
|
+
*
|
|
23
|
+
* Double-click falls out of the same rule for free — the first press selects the
|
|
24
|
+
* group, and by the time the second press is captured the group IS selected, so it
|
|
25
|
+
* drills in. No separate dblclick path, no timing heuristic.
|
|
26
|
+
*
|
|
27
|
+
* The hit test walks LEAVES, never a group's own box: the stock editor does not stamp the
|
|
28
|
+
* element id onto the Konva.Group it renders, so asking for the group's rect gets
|
|
29
|
+
* nothing and every drill silently no-ops. We find the front-most leaf under the
|
|
30
|
+
* cursor and then look at what it belongs to.
|
|
31
|
+
*
|
|
32
|
+
* Once a child is selected, ``NestedSelectionOverlay`` draws its box and owns the
|
|
33
|
+
* drag, so the shape genuinely moves and resizes.
|
|
34
|
+
*/
|
|
35
|
+
|
|
36
|
+
export type DrillElement = {
|
|
37
|
+
id: string;
|
|
38
|
+
type?: string;
|
|
39
|
+
visible?: boolean;
|
|
40
|
+
selectable?: boolean;
|
|
41
|
+
locked?: boolean;
|
|
42
|
+
children?: DrillElement[];
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
export type DrillPoint = { x: number; y: number };
|
|
46
|
+
|
|
47
|
+
type DrillStore = {
|
|
48
|
+
pages?: Array<{ id: string; children?: DrillElement[] }>;
|
|
49
|
+
selectedElementsIds?: string[];
|
|
50
|
+
selectElements?: (ids: string[]) => void;
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
/** A hidden or locked layer is not a click target — the user put it out of reach. */
|
|
54
|
+
function isHittable(el: DrillElement): boolean {
|
|
55
|
+
return el.visible !== false && el.selectable !== false && !el.locked;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* The path (top-level element → … → leaf) to the front-most leaf at `p`, searching
|
|
60
|
+
* back to front because later children paint on top. Null when nothing is hit.
|
|
61
|
+
*/
|
|
62
|
+
export function frontmostPath(
|
|
63
|
+
els: ReadonlyArray<DrillElement>,
|
|
64
|
+
rectOf: (id: string) => ClientRect | null,
|
|
65
|
+
p: DrillPoint,
|
|
66
|
+
): DrillElement[] | null {
|
|
67
|
+
for (let i = els.length - 1; i >= 0; i--) {
|
|
68
|
+
const el = els[i];
|
|
69
|
+
if (!isHittable(el)) continue;
|
|
70
|
+
if (el.children?.length) {
|
|
71
|
+
const inner = frontmostPath(el.children, rectOf, p);
|
|
72
|
+
if (inner) return [el, ...inner];
|
|
73
|
+
continue;
|
|
74
|
+
}
|
|
75
|
+
if (pointInRect(rectOf(el.id), p)) return [el];
|
|
76
|
+
}
|
|
77
|
+
return null;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** Every id under `el`, itself included — used to ask "is the selection in here?". */
|
|
81
|
+
export function descendantIds(el: DrillElement, into = new Set<string>()): Set<string> {
|
|
82
|
+
into.add(el.id);
|
|
83
|
+
for (const c of el.children ?? []) descendantIds(c, into);
|
|
84
|
+
return into;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* The id to select for a press at `p`, or null to let Canvas handle the press
|
|
89
|
+
* normally (nothing to drill into, or this is the FIRST click on the group).
|
|
90
|
+
*/
|
|
91
|
+
export function drillTarget(
|
|
92
|
+
children: ReadonlyArray<DrillElement>,
|
|
93
|
+
rectOf: (id: string) => ClientRect | null,
|
|
94
|
+
p: DrillPoint,
|
|
95
|
+
selectedIds: ReadonlyArray<string>,
|
|
96
|
+
): string | null {
|
|
97
|
+
const path = frontmostPath(children, rectOf, p);
|
|
98
|
+
if (!path || path.length < 2) return null; // nothing hit, or not inside a group
|
|
99
|
+
const top = path[0];
|
|
100
|
+
const leaf = path[path.length - 1];
|
|
101
|
+
// First click on an unselected group: Canvas selects the group, as it should.
|
|
102
|
+
// Only once the selection is already inside this group do we drill deeper.
|
|
103
|
+
const inside = descendantIds(top);
|
|
104
|
+
if (!selectedIds.some((id) => inside.has(id))) return null;
|
|
105
|
+
if (selectedIds.includes(leaf.id)) return null; // already there: let it drag
|
|
106
|
+
return leaf.id;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* A press inside an already-selected group is ambiguous until the pointer comes back
|
|
111
|
+
* up: released in place it is a CLICK (drill into the shape under the cursor);
|
|
112
|
+
* dragged away it is a MOVE of the whole group, which is the stock editor's own transformer's
|
|
113
|
+
* job. Acting at pointerdown broke the drag outright — the press never reached Konva,
|
|
114
|
+
* so the group could not be dragged at all. So the press goes through untouched, and
|
|
115
|
+
* the decision is taken at pointerup, once the intent is actually known.
|
|
116
|
+
*/
|
|
117
|
+
const DRAG_SLOP = 4; // px of travel before a press stops being a click
|
|
118
|
+
|
|
119
|
+
/** Has the pointer travelled far enough that this is a drag, not a click? */
|
|
120
|
+
export function exceedsDragSlop(from: DrillPoint, to: DrillPoint): boolean {
|
|
121
|
+
return Math.hypot(to.x - from.x, to.y - from.y) > DRAG_SLOP;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Canvas commits the selection on the mouseUP, not on the press (verified against
|
|
126
|
+
* the live store: after pointerdown+mousedown alone `selectedElementsIds` is still
|
|
127
|
+
* empty; it fills on mouseup). So a drill would be overwritten by the tail of its own
|
|
128
|
+
* gesture — the mouseup resolves the hit to el.top and snaps the selection back to
|
|
129
|
+
* the group.
|
|
130
|
+
*
|
|
131
|
+
* The obvious answer — swallow the tail — is WRONG, and it left the editor holding
|
|
132
|
+
* the element after the user let go: Konva ends its drag on that same pointerup /
|
|
133
|
+
* mouseup, so eating them strands it mid-drag (a ghost element following the cursor,
|
|
134
|
+
* a marquee that never closes).
|
|
135
|
+
*
|
|
136
|
+
* So we never block anything. Konva and Canvas run the gesture end to end exactly as
|
|
137
|
+
* they normally would, and the drill lands one macrotask later, overwriting the
|
|
138
|
+
* selection Canvas just made. The cost is that the group is briefly selected before
|
|
139
|
+
* the child is; the benefit is that no event Konva depends on ever goes missing.
|
|
140
|
+
*/
|
|
141
|
+
type Pending = { id: string; pointerId: number; x: number; y: number };
|
|
142
|
+
|
|
143
|
+
export function GroupDrillIn({
|
|
144
|
+
store,
|
|
145
|
+
containerRef,
|
|
146
|
+
}: {
|
|
147
|
+
store: unknown;
|
|
148
|
+
containerRef: RefObject<HTMLDivElement | null>;
|
|
149
|
+
}) {
|
|
150
|
+
const pending = useRef<Pending | null>(null);
|
|
151
|
+
|
|
152
|
+
useEffect(() => {
|
|
153
|
+
const host = containerRef.current;
|
|
154
|
+
if (!host) return;
|
|
155
|
+
const s = store as DrillStore;
|
|
156
|
+
let timer: ReturnType<typeof setTimeout> | null = null;
|
|
157
|
+
|
|
158
|
+
const onPointerDown = (e: PointerEvent) => {
|
|
159
|
+
pending.current = null; // a new gesture: never carry a stale candidate
|
|
160
|
+
if (e.button !== 0) return;
|
|
161
|
+
// A modified click is the stock editor's multi-select (shift) — drilling would replace
|
|
162
|
+
// the multi-selection with one child and break ⌘G on two groups.
|
|
163
|
+
if (e.shiftKey || e.metaKey || e.ctrlKey || e.altKey) return;
|
|
164
|
+
// A press on OUR selection box belongs to the nested-selection overlay, which
|
|
165
|
+
// owns that drag. (The shapes under a selected nested group are not themselves
|
|
166
|
+
// selected, so the rule below would otherwise drill straight past it.)
|
|
167
|
+
const target = e.target as Element | null;
|
|
168
|
+
if (target?.closest?.("[data-dp-nested-selection]")) return;
|
|
169
|
+
|
|
170
|
+
const p = { x: e.clientX, y: e.clientY };
|
|
171
|
+
const selected = s.selectedElementsIds ?? [];
|
|
172
|
+
for (const page of s.pages ?? []) {
|
|
173
|
+
const id = drillTarget(page.children ?? [], konvaClientRect, p, selected);
|
|
174
|
+
if (!id) continue;
|
|
175
|
+
pending.current = { id, pointerId: e.pointerId, x: p.x, y: p.y };
|
|
176
|
+
return;
|
|
177
|
+
}
|
|
178
|
+
};
|
|
179
|
+
|
|
180
|
+
const onPointerMove = (e: PointerEvent) => {
|
|
181
|
+
const p = pending.current;
|
|
182
|
+
if (!p || e.pointerId !== p.pointerId) return;
|
|
183
|
+
if (exceedsDragSlop(p, { x: e.clientX, y: e.clientY })) {
|
|
184
|
+
pending.current = null; // the user is dragging the group, not picking a child
|
|
185
|
+
}
|
|
186
|
+
};
|
|
187
|
+
|
|
188
|
+
const onPointerUp = (e: PointerEvent) => {
|
|
189
|
+
const p = pending.current;
|
|
190
|
+
pending.current = null;
|
|
191
|
+
if (!p || e.pointerId !== p.pointerId) return;
|
|
192
|
+
// Released in place: a click. Let the gesture finish — Konva needs it — and
|
|
193
|
+
// take the selection right after Canvas has made its own.
|
|
194
|
+
if (timer) clearTimeout(timer);
|
|
195
|
+
timer = setTimeout(() => {
|
|
196
|
+
timer = null;
|
|
197
|
+
s.selectElements?.([p.id]);
|
|
198
|
+
}, 0);
|
|
199
|
+
};
|
|
200
|
+
|
|
201
|
+
const onCancel = () => {
|
|
202
|
+
pending.current = null;
|
|
203
|
+
};
|
|
204
|
+
|
|
205
|
+
host.addEventListener("pointerdown", onPointerDown, { capture: true });
|
|
206
|
+
host.addEventListener("pointermove", onPointerMove, { capture: true });
|
|
207
|
+
host.addEventListener("pointerup", onPointerUp, { capture: true });
|
|
208
|
+
host.addEventListener("pointercancel", onCancel, { capture: true });
|
|
209
|
+
return () => {
|
|
210
|
+
if (timer) clearTimeout(timer);
|
|
211
|
+
host.removeEventListener("pointerdown", onPointerDown, { capture: true });
|
|
212
|
+
host.removeEventListener("pointermove", onPointerMove, { capture: true });
|
|
213
|
+
host.removeEventListener("pointerup", onPointerUp, { capture: true });
|
|
214
|
+
host.removeEventListener("pointercancel", onCancel, { capture: true });
|
|
215
|
+
};
|
|
216
|
+
}, [store, containerRef]);
|
|
217
|
+
|
|
218
|
+
return null;
|
|
219
|
+
}
|