@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,313 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* 우리 엔진 위의 작업 영역 (G7-b).
|
|
5
|
+
*
|
|
6
|
+
* ``StackedCanvasWorkspace``가 하던 일을 그대로 한다 — 세로로 쌓기, 처음 한 번
|
|
7
|
+
* 화면에 맞추기, ⌘/ctrl+휠 확대, 스크롤에 따라 활성 화면 바꾸기, 썸네일 굽기,
|
|
8
|
+
* 그리고 캔버스 위에 얹히는 층들. 다른 점은 페이지를 **누가** 그리느냐뿐이다.
|
|
9
|
+
*
|
|
10
|
+
* 창(windowing)을 여기서 안 센다. 엔진의 ``CanvasView``가 페이지마다 화면 근처인지
|
|
11
|
+
* 스스로 보고 Stage를 만들거나 만들지 않는다 — 슬롯 높이를 미리 계산해 인덱스로
|
|
12
|
+
* 세던 예전 방식보다 정확하고, 페이지 높이가 제각각인 상세페이지에서 특히 그렇다.
|
|
13
|
+
*
|
|
14
|
+
* 썸네일도 DOM에서 캔버스를 긁지 않는다. 엔진의 ``store.toDataURL({pageId})``가
|
|
15
|
+
* 화면 밖 페이지를 잠깐 띄워 굽고 다시 놓아준다 — 예전에 이 길을 못 쓴 이유(내보내기
|
|
16
|
+
* 인스턴스가 화면의 Konva 를 못 찾는 문제)는 우리 엔진에는 없다.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import {
|
|
20
|
+
useCallback,
|
|
21
|
+
useEffect,
|
|
22
|
+
useMemo,
|
|
23
|
+
useRef,
|
|
24
|
+
useState,
|
|
25
|
+
type ReactNode,
|
|
26
|
+
} from "react";
|
|
27
|
+
|
|
28
|
+
import { BubbleTailOverlay } from "./bubble-tail-overlay";
|
|
29
|
+
import { CanvasContextMenu } from "./canvas-context-menu";
|
|
30
|
+
import { CanvasOverlayHost } from "./canvas-overlay-host";
|
|
31
|
+
import { DetailPagePageToolbar } from "./detail-page-page-toolbar";
|
|
32
|
+
import { detailPageThumbnailBus } from "./detail-page-thumbnail-bus";
|
|
33
|
+
import { GifAnimator } from "./gif-animator";
|
|
34
|
+
import { GroupDrillIn } from "./group-drill-in";
|
|
35
|
+
import { HoverHighlightOverlay } from "./hover-highlight-overlay";
|
|
36
|
+
import { CanvasSectionHeightHandle } from "./section-height-handle";
|
|
37
|
+
import { loadEditorFont } from "../../lib/detail-page-canvas/editor-fonts";
|
|
38
|
+
import { CanvasView } from "@leviosa-ai/canvas/render/canvas-view";
|
|
39
|
+
import { useCanvasVersion } from "@leviosa-ai/canvas/use-canvas";
|
|
40
|
+
import type { CanvasStore } from "@leviosa-ai/canvas/store";
|
|
41
|
+
|
|
42
|
+
const MIN_SCALE = 0.05;
|
|
43
|
+
const MAX_SCALE = 5;
|
|
44
|
+
const clamp = (v: number, lo: number, hi: number) =>
|
|
45
|
+
Math.max(lo, Math.min(hi, v));
|
|
46
|
+
|
|
47
|
+
/** 썸네일 해상도. 페이지 패널의 칸이 작아 이 정도면 충분하다. */
|
|
48
|
+
const THUMB_PIXEL_RATIO = 0.12;
|
|
49
|
+
|
|
50
|
+
export function LeviosaCanvasWorkspace({
|
|
51
|
+
store,
|
|
52
|
+
gap = 4,
|
|
53
|
+
paddingX = 16,
|
|
54
|
+
backgroundColor = "rgb(241, 241, 241)",
|
|
55
|
+
children,
|
|
56
|
+
}: {
|
|
57
|
+
store: CanvasStore;
|
|
58
|
+
gap?: number;
|
|
59
|
+
paddingX?: number;
|
|
60
|
+
backgroundColor?: string;
|
|
61
|
+
/** 작업 영역 위에 얹을 것(찾기·바꾸기 같은 편집기 고유 층). */
|
|
62
|
+
children?: ReactNode;
|
|
63
|
+
}) {
|
|
64
|
+
useCanvasVersion(store);
|
|
65
|
+
const outerRef = useRef<HTMLDivElement>(null);
|
|
66
|
+
const innerRef = useRef<HTMLDivElement>(null);
|
|
67
|
+
const [viewport, setViewport] = useState({ width: 0, height: 0 });
|
|
68
|
+
// 배율은 **스토어**에 산다. 확대 버튼도, 여기 휠도 같은 자리를 만져야 한 쪽이
|
|
69
|
+
// 다른 쪽을 되돌려 놓지 않는다.
|
|
70
|
+
const scale = store.scale;
|
|
71
|
+
const setScale = useCallback((next: number) => store.setScale(next), [store]);
|
|
72
|
+
const pageIds = store.pages.map((page) => page.id).join(",");
|
|
73
|
+
|
|
74
|
+
useEffect(() => {
|
|
75
|
+
const el = outerRef.current;
|
|
76
|
+
if (!el) return;
|
|
77
|
+
const apply = () =>
|
|
78
|
+
setViewport({ width: el.clientWidth, height: el.clientHeight });
|
|
79
|
+
apply();
|
|
80
|
+
const ro = new ResizeObserver(apply);
|
|
81
|
+
ro.observe(el);
|
|
82
|
+
return () => ro.disconnect();
|
|
83
|
+
}, []);
|
|
84
|
+
|
|
85
|
+
// 첫 화면을 통째로 화면에 넣는다. 폭만 맞추면 긴 상세페이지가 화면보다 길어진다.
|
|
86
|
+
//
|
|
87
|
+
// **한 번만 하면 안 된다.** 편집기가 처음 뜰 때는 좌우 패널이 아직 자리를 안 잡아
|
|
88
|
+
// 작업 영역이 실제보다 넓게 측정되고, 그 폭에 맞춘 배율은 패널이 들어오는 순간
|
|
89
|
+
// 너무 크다(맨 처음 붙였을 때 134%가 나왔다). 그래서 **사용자가 배율을 만지기
|
|
90
|
+
// 전까지는** 영역이 바뀔 때마다 다시 맞춘다. 손을 대는 순간 주인이 바뀐다.
|
|
91
|
+
const fittedScale = useRef<number | null>(null);
|
|
92
|
+
useEffect(() => {
|
|
93
|
+
if (!viewport.width || !viewport.height || store.pages.length === 0) return;
|
|
94
|
+
// 우리가 맞춰 놓은 값과 다르면 사용자가 만진 것이다.
|
|
95
|
+
if (fittedScale.current !== null && store.scale !== fittedScale.current) {
|
|
96
|
+
return;
|
|
97
|
+
}
|
|
98
|
+
const page = store.activePage ?? store.pages[0];
|
|
99
|
+
const usableW = Math.max(1, viewport.width - 2 * paddingX);
|
|
100
|
+
const usableH = Math.max(1, viewport.height - 2 * gap);
|
|
101
|
+
const next = clamp(
|
|
102
|
+
Math.min(usableW / page.width, usableH / page.height) * 0.94,
|
|
103
|
+
MIN_SCALE,
|
|
104
|
+
MAX_SCALE,
|
|
105
|
+
);
|
|
106
|
+
fittedScale.current = next;
|
|
107
|
+
setScale(next);
|
|
108
|
+
}, [viewport, paddingX, gap, store, setScale]);
|
|
109
|
+
|
|
110
|
+
// ⌘/ctrl+휠(맥 트랙패드 핀치가 이 모양으로 온다)로 커서 자리를 붙잡고 확대.
|
|
111
|
+
// 그냥 휠은 브라우저 스크롤 그대로 둔다.
|
|
112
|
+
useEffect(() => {
|
|
113
|
+
const inner = innerRef.current;
|
|
114
|
+
if (!inner) return;
|
|
115
|
+
const onWheel = (event: WheelEvent) => {
|
|
116
|
+
if (!event.ctrlKey && !event.metaKey) return;
|
|
117
|
+
event.preventDefault();
|
|
118
|
+
const current = store.scale;
|
|
119
|
+
const next = clamp(
|
|
120
|
+
current * (event.deltaY < 0 ? 1.08 : 1 / 1.08),
|
|
121
|
+
MIN_SCALE,
|
|
122
|
+
MAX_SCALE,
|
|
123
|
+
);
|
|
124
|
+
if (next === current) return;
|
|
125
|
+
const rect = inner.getBoundingClientRect();
|
|
126
|
+
const anchor = event.clientY - rect.top + inner.scrollTop;
|
|
127
|
+
const ratio = next / current;
|
|
128
|
+
store.setScale(next);
|
|
129
|
+
requestAnimationFrame(() => {
|
|
130
|
+
inner.scrollTop = anchor * ratio - (event.clientY - rect.top);
|
|
131
|
+
});
|
|
132
|
+
};
|
|
133
|
+
inner.addEventListener("wheel", onWheel, { passive: false });
|
|
134
|
+
return () => inner.removeEventListener("wheel", onWheel);
|
|
135
|
+
}, [store]);
|
|
136
|
+
|
|
137
|
+
/** 스크롤 때문에 우리가 바꾼 활성 화면 — 바깥에서 바꾼 것과 구분해 되울림을 막는다. */
|
|
138
|
+
const scrollSetId = useRef<string | null>(null);
|
|
139
|
+
|
|
140
|
+
// 화면 한가운데를 지나는 페이지가 활성 화면이다. 자리는 DOM 에서 직접 읽는다 —
|
|
141
|
+
// 높이가 제각각이라 계산으로 맞추면 한 픽셀씩 어긋난다.
|
|
142
|
+
const recomputeActive = useCallback(() => {
|
|
143
|
+
const inner = innerRef.current;
|
|
144
|
+
if (!inner) return;
|
|
145
|
+
const middle = inner.getBoundingClientRect().top + inner.clientHeight / 2;
|
|
146
|
+
const nodes = Array.from(
|
|
147
|
+
inner.querySelectorAll<HTMLElement>("[data-lc-page]"),
|
|
148
|
+
);
|
|
149
|
+
const hit =
|
|
150
|
+
nodes.find((node) => {
|
|
151
|
+
const box = node.getBoundingClientRect();
|
|
152
|
+
return box.top <= middle && box.bottom >= middle;
|
|
153
|
+
}) ?? nodes[0];
|
|
154
|
+
const id = hit?.dataset.lcPage;
|
|
155
|
+
if (!id || store.activePage?.id === id) return;
|
|
156
|
+
scrollSetId.current = id;
|
|
157
|
+
store.selectPage(id);
|
|
158
|
+
}, [store]);
|
|
159
|
+
|
|
160
|
+
const scrollRaf = useRef<number | null>(null);
|
|
161
|
+
const onScroll = useCallback(() => {
|
|
162
|
+
if (scrollRaf.current != null) return;
|
|
163
|
+
scrollRaf.current = requestAnimationFrame(() => {
|
|
164
|
+
scrollRaf.current = null;
|
|
165
|
+
recomputeActive();
|
|
166
|
+
});
|
|
167
|
+
}, [recomputeActive]);
|
|
168
|
+
|
|
169
|
+
// 바깥(페이지 패널의 행 클릭)에서 활성 화면이 바뀌면 그 페이지를 위로 올린다.
|
|
170
|
+
const activeId = store.activePage?.id;
|
|
171
|
+
useEffect(() => {
|
|
172
|
+
const inner = innerRef.current;
|
|
173
|
+
if (!inner || !activeId || activeId === scrollSetId.current) return;
|
|
174
|
+
scrollSetId.current = activeId;
|
|
175
|
+
const node = inner.querySelector<HTMLElement>(
|
|
176
|
+
`[data-lc-page="${CSS.escape(activeId)}"]`,
|
|
177
|
+
);
|
|
178
|
+
if (!node) return;
|
|
179
|
+
inner.scrollTo({
|
|
180
|
+
top: inner.scrollTop + node.getBoundingClientRect().top -
|
|
181
|
+
inner.getBoundingClientRect().top - gap,
|
|
182
|
+
behavior: "smooth",
|
|
183
|
+
});
|
|
184
|
+
}, [activeId, gap]);
|
|
185
|
+
|
|
186
|
+
// 페이지 패널을 열면 아직 없는 썸네일을 한 장씩 굽는다. 한 번에 하나씩 굽는 이유는
|
|
187
|
+
// 굽는 동안 그 페이지를 화면 밖에서도 그려야 해서다 — 30장을 한꺼번에 띄우면
|
|
188
|
+
// 브라우저가 멈춘다.
|
|
189
|
+
const panelOpen = store.openedSidePanel === "pages";
|
|
190
|
+
useEffect(() => {
|
|
191
|
+
if (!panelOpen) return;
|
|
192
|
+
let cancelled = false;
|
|
193
|
+
void (async () => {
|
|
194
|
+
for (const page of store.pages) {
|
|
195
|
+
if (cancelled) return;
|
|
196
|
+
if (detailPageThumbnailBus.has(page.id)) continue;
|
|
197
|
+
try {
|
|
198
|
+
const uri = await store.toDataURL({
|
|
199
|
+
pageId: page.id,
|
|
200
|
+
pixelRatio: THUMB_PIXEL_RATIO,
|
|
201
|
+
});
|
|
202
|
+
if (cancelled) return;
|
|
203
|
+
detailPageThumbnailBus.set(page.id, uri);
|
|
204
|
+
} catch {
|
|
205
|
+
// 못 구운 페이지는 다음에 다시 시도한다(패널을 다시 열면 온다).
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
})();
|
|
209
|
+
return () => {
|
|
210
|
+
cancelled = true;
|
|
211
|
+
};
|
|
212
|
+
}, [panelOpen, pageIds, store]);
|
|
213
|
+
|
|
214
|
+
const pageWidth = useMemo(
|
|
215
|
+
() => Math.max(1, ...store.pages.map((page) => page.width)),
|
|
216
|
+
// 페이지 구성이 바뀔 때만 다시 잰다.
|
|
217
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
218
|
+
[pageIds, store],
|
|
219
|
+
);
|
|
220
|
+
|
|
221
|
+
return (
|
|
222
|
+
<div
|
|
223
|
+
ref={outerRef}
|
|
224
|
+
data-lc-workspace=""
|
|
225
|
+
style={{
|
|
226
|
+
width: "100%",
|
|
227
|
+
height: "100%",
|
|
228
|
+
position: "relative",
|
|
229
|
+
outline: "none",
|
|
230
|
+
backgroundColor,
|
|
231
|
+
overflow: "hidden",
|
|
232
|
+
}}
|
|
233
|
+
tabIndex={0}
|
|
234
|
+
>
|
|
235
|
+
<div
|
|
236
|
+
ref={innerRef}
|
|
237
|
+
onScroll={onScroll}
|
|
238
|
+
onPointerDown={(event) => {
|
|
239
|
+
// 페이지 바깥의 빈 자리를 누르면 선택 해제. 페이지 안은 캔버스가 처리한다.
|
|
240
|
+
const target = event.target as HTMLElement;
|
|
241
|
+
if (
|
|
242
|
+
!target.closest("[data-lc-page]") &&
|
|
243
|
+
!target.closest("[data-dp-quicktoolbar]")
|
|
244
|
+
) {
|
|
245
|
+
store.selectElements([]);
|
|
246
|
+
}
|
|
247
|
+
}}
|
|
248
|
+
style={{
|
|
249
|
+
position: "absolute",
|
|
250
|
+
inset: 0,
|
|
251
|
+
overflowX: "hidden",
|
|
252
|
+
overflowY: "auto",
|
|
253
|
+
display: "flex",
|
|
254
|
+
flexDirection: "column",
|
|
255
|
+
alignItems: "center",
|
|
256
|
+
padding: `${gap}px ${paddingX}px`,
|
|
257
|
+
}}
|
|
258
|
+
>
|
|
259
|
+
<CanvasView
|
|
260
|
+
store={store}
|
|
261
|
+
scale={scale}
|
|
262
|
+
gap={gap}
|
|
263
|
+
interactive
|
|
264
|
+
loadFont={loadEditorFont}
|
|
265
|
+
/>
|
|
266
|
+
</div>
|
|
267
|
+
|
|
268
|
+
{/* 표·차트 레일과 크기 되먹임. 이 둘만 스토어를 구조적 타입으로 받으므로
|
|
269
|
+
얼굴을 하나 씌워 넘긴다(canvas-store-facade에 이유를 적어 뒀다). */}
|
|
270
|
+
<CanvasOverlayHost store={store} containerRef={outerRef} />
|
|
271
|
+
|
|
272
|
+
{/* 말풍선 꼬리 핸들 · 우클릭 메뉴 · 레이어 트리 hover · 그룹 파고들기 · GIF 재생.
|
|
273
|
+
선택 상자와 크기 손잡이는 엔진이 직접 그린다(그룹 안 요소까지). */}
|
|
274
|
+
<BubbleTailOverlay store={store} containerRef={outerRef} />
|
|
275
|
+
<CanvasContextMenu store={store} containerRef={outerRef} />
|
|
276
|
+
<HoverHighlightOverlay
|
|
277
|
+
store={store}
|
|
278
|
+
containerRef={outerRef}
|
|
279
|
+
scrollRef={innerRef}
|
|
280
|
+
/>
|
|
281
|
+
<GroupDrillIn store={store} containerRef={outerRef} />
|
|
282
|
+
<GifAnimator store={store} />
|
|
283
|
+
{/* 활성 화면의 아래 끝을 잡아 끌어 길이를 바꾸는 손잡이. 우측 패널의 숫자와
|
|
284
|
+
같은 함수를 거친다(`section-height.ts`) — 배경 요소까지 같이 늘리고 서버
|
|
285
|
+
굽기 상한 안에 가둔다. */}
|
|
286
|
+
<CanvasSectionHeightHandle
|
|
287
|
+
store={store}
|
|
288
|
+
containerRef={outerRef}
|
|
289
|
+
scrollRef={innerRef}
|
|
290
|
+
/>
|
|
291
|
+
|
|
292
|
+
{children}
|
|
293
|
+
|
|
294
|
+
{store.activePage ? (
|
|
295
|
+
<div
|
|
296
|
+
data-dp-quicktoolbar=""
|
|
297
|
+
style={{
|
|
298
|
+
position: "absolute",
|
|
299
|
+
left: Math.min(
|
|
300
|
+
viewport.width / 2 + (pageWidth * scale) / 2 + 8,
|
|
301
|
+
viewport.width - 52,
|
|
302
|
+
),
|
|
303
|
+
top: "50%",
|
|
304
|
+
transform: "translateY(-50%)",
|
|
305
|
+
zIndex: 30,
|
|
306
|
+
}}
|
|
307
|
+
>
|
|
308
|
+
<DetailPagePageToolbar store={store} page={store.activePage} />
|
|
309
|
+
</div>
|
|
310
|
+
) : null}
|
|
311
|
+
</div>
|
|
312
|
+
);
|
|
313
|
+
}
|
|
@@ -0,0 +1,364 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
useEffect,
|
|
5
|
+
useLayoutEffect,
|
|
6
|
+
useRef,
|
|
7
|
+
useState,
|
|
8
|
+
type PointerEvent as ReactPointerEvent,
|
|
9
|
+
type RefObject,
|
|
10
|
+
} from "react";
|
|
11
|
+
import { observer } from "./canvas-observer";
|
|
12
|
+
|
|
13
|
+
import {
|
|
14
|
+
selectedElementsDeep,
|
|
15
|
+
type SelectableElement,
|
|
16
|
+
} from "./detail-page-selection";
|
|
17
|
+
import {
|
|
18
|
+
elementClientRect,
|
|
19
|
+
type RectElement,
|
|
20
|
+
} from "./element-rects";
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Real selection — move + resize — for a GROUP CHILD.
|
|
24
|
+
*
|
|
25
|
+
* the stock editor's selection model only ever holds TOP-LEVEL element ids: its canvas
|
|
26
|
+
* resolves any hit node to ``el.top`` (the outermost non-group ancestor) before
|
|
27
|
+
* selecting, and ``store.selectedShapes`` (what the Konva Transformer attaches
|
|
28
|
+
* to) derives from ``store.selectedElements``, a getter that only scans a page's
|
|
29
|
+
* direct children. So a group child gets no transformer, and the moment you press
|
|
30
|
+
* on it the canvas re-selects its group instead — you cannot drag it.
|
|
31
|
+
*
|
|
32
|
+
* Rather than fight that model, this overlay OWNS the interaction. It sits above
|
|
33
|
+
* the canvas in the DOM, so a pointerdown on the selected child is consumed here
|
|
34
|
+
* and never reaches Konva — the group can no longer steal the selection. Drag and
|
|
35
|
+
* corner-resize write straight to the element (``el.set``), which is exactly what
|
|
36
|
+
* the stock editor's own transformer would do, so undo/redo and re-render behave normally.
|
|
37
|
+
*
|
|
38
|
+
* Geometry comes from the Konva node itself (``getClientRect`` → absolute canvas
|
|
39
|
+
* px, zoom included) mapped through the stage's container rect, so the overlay is
|
|
40
|
+
* layout-independent: zoom, scroll, and the stacked workspace's per-page slots all
|
|
41
|
+
* work without re-deriving any of their math. Screen deltas convert to element
|
|
42
|
+
* deltas by dividing by ``store.scale``.
|
|
43
|
+
*/
|
|
44
|
+
|
|
45
|
+
type StoreLike = {
|
|
46
|
+
selectedElements?: SelectableElement[];
|
|
47
|
+
selectedElementsIds?: string[];
|
|
48
|
+
getElementById?: (id: string) => SelectableElement | undefined;
|
|
49
|
+
scale?: number;
|
|
50
|
+
history?: { startTransaction?: () => void; endTransaction?: () => void };
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
type Rect = { left: number; top: number; width: number; height: number };
|
|
54
|
+
type Box = { id: string; rect: Rect; isGroup: boolean };
|
|
55
|
+
type Corner = "nw" | "ne" | "sw" | "se";
|
|
56
|
+
|
|
57
|
+
/** The selected ids Canvas itself cannot draw — i.e. the ones nested in a group. */
|
|
58
|
+
export function nestedSelectedIds(store: StoreLike): string[] {
|
|
59
|
+
const deep = selectedElementsDeep(store);
|
|
60
|
+
if (!deep.length) return [];
|
|
61
|
+
// ``selectedElements`` only ever holds TOP-LEVEL elements; anything resolved
|
|
62
|
+
// deep but absent there is nested, and therefore has no Canvas transformer.
|
|
63
|
+
const topLevel = new Set((store.selectedElements ?? []).map((e) => e.id));
|
|
64
|
+
return deep.map((e) => e.id).filter((id) => !topLevel.has(id));
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Measure a selected element and return its rect in `container` space.
|
|
69
|
+
*
|
|
70
|
+
* Goes through ``elementClientRect``, which measures a GROUP from its children:
|
|
71
|
+
* Canvas never stamps the element id onto the Konva.Group it renders, so a direct
|
|
72
|
+
* `#<groupId>` lookup finds nothing — a group nested inside another group would get
|
|
73
|
+
* no selection box at all.
|
|
74
|
+
*/
|
|
75
|
+
function measure(el: SelectableElement, container: HTMLElement): Rect | null {
|
|
76
|
+
const r = elementClientRect(el as RectElement);
|
|
77
|
+
if (!r) return null;
|
|
78
|
+
const host = container.getBoundingClientRect();
|
|
79
|
+
return {
|
|
80
|
+
left: r.left - host.left,
|
|
81
|
+
top: r.top - host.top,
|
|
82
|
+
width: r.right - r.left,
|
|
83
|
+
height: r.bottom - r.top,
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const num = (v: unknown, fallback = 0): number =>
|
|
88
|
+
typeof v === "number" && Number.isFinite(v) ? v : fallback;
|
|
89
|
+
|
|
90
|
+
const HANDLE = 8;
|
|
91
|
+
const ACCENT = "rgb(0, 161, 255)";
|
|
92
|
+
const MIN_SIZE = 4;
|
|
93
|
+
|
|
94
|
+
const CURSOR: Record<Corner, string> = {
|
|
95
|
+
nw: "nwse-resize",
|
|
96
|
+
ne: "nesw-resize",
|
|
97
|
+
sw: "nesw-resize",
|
|
98
|
+
se: "nwse-resize",
|
|
99
|
+
};
|
|
100
|
+
|
|
101
|
+
export type Geom = { x: number; y: number; width: number; height: number };
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Element geometry after dragging by (dx, dy) *element* px. ``corner === null``
|
|
105
|
+
* moves; a corner resizes, anchoring the opposite corner (so a west/north grab
|
|
106
|
+
* shifts x/y as the size changes). Sizes clamp so a box never inverts.
|
|
107
|
+
*/
|
|
108
|
+
export function applyDrag(
|
|
109
|
+
start: Geom,
|
|
110
|
+
corner: Corner | null,
|
|
111
|
+
dx: number,
|
|
112
|
+
dy: number,
|
|
113
|
+
): Geom {
|
|
114
|
+
if (!corner) {
|
|
115
|
+
return { ...start, x: Math.round(start.x + dx), y: Math.round(start.y + dy) };
|
|
116
|
+
}
|
|
117
|
+
const west = corner === "nw" || corner === "sw";
|
|
118
|
+
const north = corner === "nw" || corner === "ne";
|
|
119
|
+
const width = Math.max(MIN_SIZE, west ? start.width - dx : start.width + dx);
|
|
120
|
+
const height = Math.max(MIN_SIZE, north ? start.height - dy : start.height + dy);
|
|
121
|
+
return {
|
|
122
|
+
x: Math.round(west ? start.x + (start.width - width) : start.x),
|
|
123
|
+
y: Math.round(north ? start.y + (start.height - height) : start.y),
|
|
124
|
+
width: Math.round(width),
|
|
125
|
+
height: Math.round(height),
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/** One element the drag writes to, with the geometry it started from. */
|
|
130
|
+
type DragTarget = { el: SelectableElement; start: Geom };
|
|
131
|
+
|
|
132
|
+
type DragState = {
|
|
133
|
+
pointerId: number;
|
|
134
|
+
corner: Corner | null; // null = move
|
|
135
|
+
startX: number;
|
|
136
|
+
startY: number;
|
|
137
|
+
targets: DragTarget[];
|
|
138
|
+
};
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* The elements a drag actually has to write to.
|
|
142
|
+
*
|
|
143
|
+
* A GROUP is not a transform: the stock editor's group element carries no offset (setting
|
|
144
|
+
* its x/y moves nothing — verified on the live store), the LEAVES hold the real
|
|
145
|
+
* coordinates. So moving a group means moving every leaf under it by the delta.
|
|
146
|
+
*/
|
|
147
|
+
export function dragTargets(el: SelectableElement): DragTarget[] {
|
|
148
|
+
const kids = (el as { children?: SelectableElement[] }).children;
|
|
149
|
+
if (kids?.length) return kids.flatMap(dragTargets);
|
|
150
|
+
return [
|
|
151
|
+
{
|
|
152
|
+
el,
|
|
153
|
+
start: {
|
|
154
|
+
x: num(el.x),
|
|
155
|
+
y: num(el.y),
|
|
156
|
+
width: num(el.width),
|
|
157
|
+
height: num(el.height),
|
|
158
|
+
},
|
|
159
|
+
},
|
|
160
|
+
];
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
export const NestedSelectionOverlay = observer(function NestedSelectionOverlay({
|
|
164
|
+
store,
|
|
165
|
+
containerRef,
|
|
166
|
+
scrollRef,
|
|
167
|
+
}: {
|
|
168
|
+
store: unknown;
|
|
169
|
+
containerRef: RefObject<HTMLDivElement | null>;
|
|
170
|
+
scrollRef: RefObject<HTMLDivElement | null>;
|
|
171
|
+
}) {
|
|
172
|
+
const s = store as StoreLike;
|
|
173
|
+
const ids = nestedSelectedIds(s);
|
|
174
|
+
const key = ids.join(",");
|
|
175
|
+
const scale = s.scale ?? 1;
|
|
176
|
+
|
|
177
|
+
// Reading each element's geometry here (mobx) re-renders the observer — and
|
|
178
|
+
// re-measures — as a drag/resize moves it. Read the LEAVES: a group's own x/y
|
|
179
|
+
// never change (they are not where its geometry lives), so watching them would
|
|
180
|
+
// leave the box frozen while the group is dragged.
|
|
181
|
+
const geomKey = ids
|
|
182
|
+
.map((id) => {
|
|
183
|
+
const el = s.getElementById?.(id);
|
|
184
|
+
if (!el) return "";
|
|
185
|
+
return dragTargets(el)
|
|
186
|
+
.map((t) => `${t.start.x},${t.start.y},${t.start.width},${t.start.height}`)
|
|
187
|
+
.join(";");
|
|
188
|
+
})
|
|
189
|
+
.join("|");
|
|
190
|
+
|
|
191
|
+
const [boxes, setBoxes] = useState<Box[]>([]);
|
|
192
|
+
const frame = useRef<number | null>(null);
|
|
193
|
+
const drag = useRef<DragState | null>(null);
|
|
194
|
+
|
|
195
|
+
const remeasure = () => {
|
|
196
|
+
const host = containerRef.current;
|
|
197
|
+
if (!host || !ids.length) {
|
|
198
|
+
setBoxes((prev) => (prev.length ? [] : prev));
|
|
199
|
+
return;
|
|
200
|
+
}
|
|
201
|
+
const next: Box[] = [];
|
|
202
|
+
for (const id of ids) {
|
|
203
|
+
const el = s.getElementById?.(id);
|
|
204
|
+
if (!el) continue;
|
|
205
|
+
const rect = measure(el, host);
|
|
206
|
+
// Each box carries its own id: a group whose children are all off-screen
|
|
207
|
+
// measures to nothing, and an index-aligned array would then hand the next
|
|
208
|
+
// box the wrong element to drag.
|
|
209
|
+
if (rect) next.push({ id, rect, isGroup: el.type === "group" });
|
|
210
|
+
}
|
|
211
|
+
setBoxes(next);
|
|
212
|
+
};
|
|
213
|
+
|
|
214
|
+
// Measure after Konva has redrawn for this selection / zoom / geometry change.
|
|
215
|
+
useLayoutEffect(() => {
|
|
216
|
+
const raf = requestAnimationFrame(() => {
|
|
217
|
+
remeasure();
|
|
218
|
+
requestAnimationFrame(remeasure);
|
|
219
|
+
});
|
|
220
|
+
return () => cancelAnimationFrame(raf);
|
|
221
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
222
|
+
}, [key, scale, geomKey]);
|
|
223
|
+
|
|
224
|
+
// Follow scroll / resize while a nested element stays selected.
|
|
225
|
+
useEffect(() => {
|
|
226
|
+
if (!key) return;
|
|
227
|
+
const scroller = scrollRef.current;
|
|
228
|
+
const onMove = () => {
|
|
229
|
+
if (frame.current !== null) return;
|
|
230
|
+
frame.current = requestAnimationFrame(() => {
|
|
231
|
+
frame.current = null;
|
|
232
|
+
remeasure();
|
|
233
|
+
});
|
|
234
|
+
};
|
|
235
|
+
scroller?.addEventListener("scroll", onMove, { passive: true });
|
|
236
|
+
window.addEventListener("resize", onMove);
|
|
237
|
+
return () => {
|
|
238
|
+
scroller?.removeEventListener("scroll", onMove);
|
|
239
|
+
window.removeEventListener("resize", onMove);
|
|
240
|
+
if (frame.current !== null) cancelAnimationFrame(frame.current);
|
|
241
|
+
frame.current = null;
|
|
242
|
+
};
|
|
243
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
244
|
+
}, [key, scrollRef]);
|
|
245
|
+
|
|
246
|
+
const begin = (
|
|
247
|
+
e: ReactPointerEvent<HTMLElement>,
|
|
248
|
+
id: string,
|
|
249
|
+
corner: Corner | null,
|
|
250
|
+
) => {
|
|
251
|
+
const el = s.getElementById?.(id);
|
|
252
|
+
if (!el) return;
|
|
253
|
+
// A locked layer must not move — same rule the Canvas transformer follows.
|
|
254
|
+
if (el.draggable === false && !corner) return;
|
|
255
|
+
// Consume the press so Konva never sees it: that is what stops the canvas
|
|
256
|
+
// from resolving the hit to `el.top` and re-selecting the parent group.
|
|
257
|
+
e.preventDefault();
|
|
258
|
+
e.stopPropagation();
|
|
259
|
+
(e.currentTarget as HTMLElement).setPointerCapture(e.pointerId);
|
|
260
|
+
s.history?.startTransaction?.();
|
|
261
|
+
drag.current = {
|
|
262
|
+
pointerId: e.pointerId,
|
|
263
|
+
corner,
|
|
264
|
+
startX: e.clientX,
|
|
265
|
+
startY: e.clientY,
|
|
266
|
+
targets: dragTargets(el),
|
|
267
|
+
};
|
|
268
|
+
};
|
|
269
|
+
|
|
270
|
+
const move = (e: ReactPointerEvent<HTMLElement>) => {
|
|
271
|
+
const d = drag.current;
|
|
272
|
+
if (!d || e.pointerId !== d.pointerId) return;
|
|
273
|
+
e.preventDefault();
|
|
274
|
+
const k = scale || 1;
|
|
275
|
+
// Screen px -> element px. Element coords are in the parent's space, so a
|
|
276
|
+
// relative delta is correct whether or not the group carries an offset.
|
|
277
|
+
const dx = (e.clientX - d.startX) / k;
|
|
278
|
+
const dy = (e.clientY - d.startY) / k;
|
|
279
|
+
|
|
280
|
+
for (const t of d.targets) {
|
|
281
|
+
const next = applyDrag(t.start, d.corner, dx, dy);
|
|
282
|
+
t.el.set(d.corner ? next : { x: next.x, y: next.y });
|
|
283
|
+
}
|
|
284
|
+
};
|
|
285
|
+
|
|
286
|
+
const end = (e: ReactPointerEvent<HTMLElement>) => {
|
|
287
|
+
const d = drag.current;
|
|
288
|
+
if (!d || e.pointerId !== d.pointerId) return;
|
|
289
|
+
drag.current = null;
|
|
290
|
+
s.history?.endTransaction?.();
|
|
291
|
+
};
|
|
292
|
+
|
|
293
|
+
if (!boxes.length) return null;
|
|
294
|
+
|
|
295
|
+
return (
|
|
296
|
+
<div
|
|
297
|
+
data-dp-nested-selection=""
|
|
298
|
+
style={{
|
|
299
|
+
position: "absolute",
|
|
300
|
+
inset: 0,
|
|
301
|
+
// Only the boxes themselves take pointer events; everywhere else the
|
|
302
|
+
// canvas keeps its normal click behaviour.
|
|
303
|
+
pointerEvents: "none",
|
|
304
|
+
zIndex: 20,
|
|
305
|
+
}}
|
|
306
|
+
>
|
|
307
|
+
{boxes.map(({ id, rect: r, isGroup }) => (
|
|
308
|
+
<div
|
|
309
|
+
key={id}
|
|
310
|
+
onPointerDown={(e) => begin(e, id, null)}
|
|
311
|
+
onPointerMove={move}
|
|
312
|
+
onPointerUp={end}
|
|
313
|
+
onPointerCancel={end}
|
|
314
|
+
style={{
|
|
315
|
+
position: "absolute",
|
|
316
|
+
left: r.left,
|
|
317
|
+
top: r.top,
|
|
318
|
+
width: r.width,
|
|
319
|
+
height: r.height,
|
|
320
|
+
border: `1px solid ${ACCENT}`,
|
|
321
|
+
boxSizing: "border-box",
|
|
322
|
+
pointerEvents: "auto",
|
|
323
|
+
cursor: "move",
|
|
324
|
+
touchAction: "none",
|
|
325
|
+
}}
|
|
326
|
+
>
|
|
327
|
+
{/* A group is move-only. Its width/height are not a real box (the
|
|
328
|
+
decomposer pins the wrapper to the origin and the children hold the
|
|
329
|
+
coordinates), so a corner drag would resize nothing and mangle the
|
|
330
|
+
geometry. Drag its children to resize. */}
|
|
331
|
+
{isGroup
|
|
332
|
+
? null
|
|
333
|
+
: (["nw", "ne", "sw", "se"] as const).map((corner) => (
|
|
334
|
+
<span
|
|
335
|
+
key={corner}
|
|
336
|
+
onPointerDown={(e) => begin(e, id, corner)}
|
|
337
|
+
onPointerMove={move}
|
|
338
|
+
onPointerUp={end}
|
|
339
|
+
onPointerCancel={end}
|
|
340
|
+
style={{
|
|
341
|
+
position: "absolute",
|
|
342
|
+
left:
|
|
343
|
+
(corner === "ne" || corner === "se" ? r.width : 0) -
|
|
344
|
+
HANDLE / 2,
|
|
345
|
+
top:
|
|
346
|
+
(corner === "sw" || corner === "se" ? r.height : 0) -
|
|
347
|
+
HANDLE / 2,
|
|
348
|
+
width: HANDLE,
|
|
349
|
+
height: HANDLE,
|
|
350
|
+
background: "#fff",
|
|
351
|
+
border: `1px solid ${ACCENT}`,
|
|
352
|
+
boxSizing: "border-box",
|
|
353
|
+
pointerEvents: "auto",
|
|
354
|
+
cursor: CURSOR[corner],
|
|
355
|
+
touchAction: "none",
|
|
356
|
+
}}
|
|
357
|
+
/>
|
|
358
|
+
))}
|
|
359
|
+
</div>
|
|
360
|
+
))}
|
|
361
|
+
</div>
|
|
362
|
+
);
|
|
363
|
+
});
|
|
364
|
+
NestedSelectionOverlay.displayName = "NestedSelectionOverlay";
|