@leviosa-ai/detail-page-editor 0.19.0 → 0.19.1
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/canvas-context-menu.tsx +5 -1
- package/components/detail-page/detail-page-history-buttons.tsx +15 -1
- package/components/detail-page/find-replace-panel.tsx +12 -1
- package/components/detail-page/leviosa-canvas-workspace.tsx +5 -4
- package/components/detail-page/mobile-editor-bars.tsx +28 -3
- package/i18n/en.json +6 -0
- package/i18n/ko.json +6 -0
- package/lib/detail-page/canvas-menu.ts +18 -1
- package/package.json +2 -2
|
@@ -10,7 +10,9 @@ import {
|
|
|
10
10
|
ChevronUp,
|
|
11
11
|
ChevronsDown,
|
|
12
12
|
ChevronsUp,
|
|
13
|
+
ClipboardPaste,
|
|
13
14
|
Copy,
|
|
15
|
+
CopyPlus,
|
|
14
16
|
Group,
|
|
15
17
|
Lock,
|
|
16
18
|
Paintbrush,
|
|
@@ -52,7 +54,9 @@ type MenuStore = CanvasMenuStore & {
|
|
|
52
54
|
|
|
53
55
|
/** 항목 → 아이콘. 우클릭 메뉴와 캔버스 위 띠의 "더보기"가 같은 그림을 쓴다. */
|
|
54
56
|
export const CANVAS_MENU_ICONS: Record<CanvasMenuAction, LucideIcon> = {
|
|
55
|
-
|
|
57
|
+
copy: Copy,
|
|
58
|
+
paste: ClipboardPaste,
|
|
59
|
+
duplicate: CopyPlus,
|
|
56
60
|
lock: Lock,
|
|
57
61
|
unlock: Unlock,
|
|
58
62
|
delete: Trash2,
|
|
@@ -3,7 +3,10 @@
|
|
|
3
3
|
|
|
4
4
|
import { observer } from "./canvas-observer";
|
|
5
5
|
import { useTranslation } from "react-i18next";
|
|
6
|
-
import { Redo2, Undo2 } from "lucide-react";
|
|
6
|
+
import { Redo2, Search, Undo2 } from "lucide-react";
|
|
7
|
+
|
|
8
|
+
import { openFindReplace } from "./find-replace-panel";
|
|
9
|
+
import { useIsMobile } from "./mobile-editor-bars";
|
|
7
10
|
|
|
8
11
|
import {
|
|
9
12
|
safeRedo,
|
|
@@ -66,8 +69,19 @@ export const DetailPageHistoryButtons = observer(function DetailPageHistoryButto
|
|
|
66
69
|
const { t } = useTranslation("branding");
|
|
67
70
|
const { history } = store as HistoryStoreLike;
|
|
68
71
|
const s = store as HistoryStore;
|
|
72
|
+
// 폰에는 ⌘F 가 없다. 헤더는 셸마다 따로 짜도 이 부품은 다 쓰니 여기 단다.
|
|
73
|
+
const mobile = useIsMobile();
|
|
69
74
|
return (
|
|
70
75
|
<div className="flex items-center">
|
|
76
|
+
{mobile ? (
|
|
77
|
+
<HistoryButton
|
|
78
|
+
label={t("detailPage.mobile.find")}
|
|
79
|
+
onClick={openFindReplace}
|
|
80
|
+
disabled={false}
|
|
81
|
+
>
|
|
82
|
+
<Search size={17} />
|
|
83
|
+
</HistoryButton>
|
|
84
|
+
) : null}
|
|
71
85
|
<HistoryButton
|
|
72
86
|
label={t("editor.undo")}
|
|
73
87
|
onClick={() => safeUndo(s)}
|
|
@@ -46,6 +46,12 @@ export function revealMatch(store: PanelStore, match: TextMatch): void {
|
|
|
46
46
|
store.selectElements?.([match.elementId]);
|
|
47
47
|
}
|
|
48
48
|
|
|
49
|
+
/** 버튼으로 찾기·바꾸기를 연다 — 폰에는 ⌘F 가 없다. */
|
|
50
|
+
const OPEN_EVENT = "le-find-replace-open";
|
|
51
|
+
export function openFindReplace(): void {
|
|
52
|
+
document.dispatchEvent(new Event(OPEN_EVENT));
|
|
53
|
+
}
|
|
54
|
+
|
|
49
55
|
export const FindReplacePanel = observer(function FindReplacePanel({
|
|
50
56
|
store,
|
|
51
57
|
}: {
|
|
@@ -79,8 +85,13 @@ export const FindReplacePanel = observer(function FindReplacePanel({
|
|
|
79
85
|
e.stopPropagation();
|
|
80
86
|
setOpen(true);
|
|
81
87
|
};
|
|
88
|
+
const onOpen = () => setOpen(true);
|
|
82
89
|
document.addEventListener("keydown", onKey, { capture: true });
|
|
83
|
-
|
|
90
|
+
document.addEventListener(OPEN_EVENT, onOpen);
|
|
91
|
+
return () => {
|
|
92
|
+
document.removeEventListener("keydown", onKey, { capture: true });
|
|
93
|
+
document.removeEventListener(OPEN_EVENT, onOpen);
|
|
94
|
+
};
|
|
84
95
|
}, []);
|
|
85
96
|
|
|
86
97
|
const step = useCallback(
|
|
@@ -704,13 +704,14 @@ export function LeviosaCanvasWorkspace({
|
|
|
704
704
|
onPointerDown={(event) => {
|
|
705
705
|
// 페이지 바깥의 빈 자리를 누르면 선택 해제. 페이지 안은 캔버스가 처리한다.
|
|
706
706
|
const target = event.target as HTMLElement;
|
|
707
|
-
// 손가락으로 판
|
|
708
|
-
//
|
|
709
|
-
// 판 위의 터치는 브라우저 스크롤을 꺼 뒀다(touch-action)
|
|
707
|
+
// 손가락으로 판 위를 끌면 화면을 옮긴다 — 캔버스가 먼저 받아서 고른 요소나 손잡이를
|
|
708
|
+
// 잡았으면 preventDefault 로 표시해 둔다(그때만 요소가 끌린다). 고르기는 캔버스가
|
|
709
|
+
// 손을 뗄 때 한다. 판 위의 터치는 브라우저 스크롤을 꺼 뒀다(touch-action).
|
|
710
710
|
if (
|
|
711
711
|
event.pointerType === "touch" &&
|
|
712
712
|
target.closest("[data-lc-page]") &&
|
|
713
|
-
|
|
713
|
+
!target.closest("button, a, input, textarea, select, [contenteditable='true']") &&
|
|
714
|
+
!event.nativeEvent.defaultPrevented
|
|
714
715
|
) {
|
|
715
716
|
startPan(event);
|
|
716
717
|
return;
|
|
@@ -26,7 +26,7 @@ import {
|
|
|
26
26
|
type PointerEvent as ReactPointerEvent,
|
|
27
27
|
type ReactNode,
|
|
28
28
|
} from "react";
|
|
29
|
-
import { Check, Settings2 } from "lucide-react";
|
|
29
|
+
import { Check, ListChecks, Settings2 } from "lucide-react";
|
|
30
30
|
import { useTranslation } from "react-i18next";
|
|
31
31
|
import { SectionTab } from "@leviosa-ai/canvas";
|
|
32
32
|
|
|
@@ -94,6 +94,8 @@ type StoreLike = {
|
|
|
94
94
|
openedSidePanel: string;
|
|
95
95
|
openSidePanel: (name: string) => void;
|
|
96
96
|
selectElements: (ids: string[]) => void;
|
|
97
|
+
multiSelect?: boolean;
|
|
98
|
+
setMultiSelect?: (on: boolean) => void;
|
|
97
99
|
};
|
|
98
100
|
|
|
99
101
|
const BAR_CLASS =
|
|
@@ -247,7 +249,14 @@ const SectionBar = observer(function SectionBar({
|
|
|
247
249
|
});
|
|
248
250
|
|
|
249
251
|
/** 뭔가 골랐을 때 — 인스펙터 섹션 탭 띠 + 띠 바로 위에 뜨는 섹션 하나. */
|
|
250
|
-
|
|
252
|
+
const InspectorBar = observer(function InspectorBar({
|
|
253
|
+
store,
|
|
254
|
+
inspector,
|
|
255
|
+
}: {
|
|
256
|
+
store: unknown;
|
|
257
|
+
inspector: ReactNode;
|
|
258
|
+
}) {
|
|
259
|
+
const { t } = useTranslation("branding");
|
|
251
260
|
const s = store as StoreLike;
|
|
252
261
|
const hostRef = useRef<HTMLDivElement>(null);
|
|
253
262
|
const [titles, setTitles] = useState<string[]>([]);
|
|
@@ -313,6 +322,20 @@ function InspectorBar({ store, inspector }: { store: unknown; inspector: ReactNo
|
|
|
313
322
|
</button>
|
|
314
323
|
))}
|
|
315
324
|
</div>
|
|
325
|
+
{/* 폰에는 시프트가 없다 — 켜 두면 탭할 때마다 선택에 더하거나 뺀다. */}
|
|
326
|
+
<button
|
|
327
|
+
type="button"
|
|
328
|
+
aria-label={t("detailPage.mobile.multiSelect")}
|
|
329
|
+
title={t("detailPage.mobile.multiSelect")}
|
|
330
|
+
aria-pressed={!!s.multiSelect}
|
|
331
|
+
data-le-mobile-multi=""
|
|
332
|
+
onClick={() => s.setMultiSelect?.(!s.multiSelect)}
|
|
333
|
+
className={`my-1.5 flex h-10 w-10 shrink-0 items-center justify-center rounded-full ${
|
|
334
|
+
s.multiSelect ? "bg-le-ink-900 text-le-surface" : "text-le-ink-600"
|
|
335
|
+
}`}
|
|
336
|
+
>
|
|
337
|
+
<ListChecks aria-hidden="true" size={18} />
|
|
338
|
+
</button>
|
|
316
339
|
<button
|
|
317
340
|
type="button"
|
|
318
341
|
aria-label="done"
|
|
@@ -324,7 +347,7 @@ function InspectorBar({ store, inspector }: { store: unknown; inspector: ReactNo
|
|
|
324
347
|
</div>
|
|
325
348
|
</div>
|
|
326
349
|
);
|
|
327
|
-
}
|
|
350
|
+
});
|
|
328
351
|
|
|
329
352
|
export const MobileBottomBar = observer(function MobileBottomBar({
|
|
330
353
|
store,
|
|
@@ -342,6 +365,8 @@ export const MobileBottomBar = observer(function MobileBottomBar({
|
|
|
342
365
|
// 숨었다가, ✓ 로 선택을 풀 때 다시 튀어나온다.
|
|
343
366
|
useEffect(() => {
|
|
344
367
|
if (selected && s.openedSidePanel) s.openSidePanel("");
|
|
368
|
+
// 다 풀었으면 여러 개 고르기도 끝난다 — 다음 탭은 다시 하나만 고른다.
|
|
369
|
+
if (!selected && s.multiSelect) s.setMultiSelect?.(false);
|
|
345
370
|
}, [selected, s]);
|
|
346
371
|
|
|
347
372
|
// 아무것도 안 골랐을 때의 인스펙터(화면 배경·높이·복제/삭제)는 데스크톱에서는 늘
|
package/i18n/en.json
CHANGED
|
@@ -865,7 +865,13 @@
|
|
|
865
865
|
"bubbleTail": {
|
|
866
866
|
"handle": "Drag the tail tip to move it (drag inside the body to remove the tail)"
|
|
867
867
|
},
|
|
868
|
+
"mobile": {
|
|
869
|
+
"multiSelect": "Select multiple",
|
|
870
|
+
"find": "Find and replace"
|
|
871
|
+
},
|
|
868
872
|
"canvasMenu": {
|
|
873
|
+
"copy": "Copy",
|
|
874
|
+
"paste": "Paste",
|
|
869
875
|
"duplicate": "Duplicate",
|
|
870
876
|
"lock": "Lock",
|
|
871
877
|
"unlock": "Unlock",
|
package/i18n/ko.json
CHANGED
|
@@ -865,7 +865,13 @@
|
|
|
865
865
|
"bubbleTail": {
|
|
866
866
|
"handle": "꼬리 끝을 끌어 위치를 바꿉니다 (몸통 안으로 끌면 꼬리가 사라집니다)"
|
|
867
867
|
},
|
|
868
|
+
"mobile": {
|
|
869
|
+
"multiSelect": "여러 개 선택",
|
|
870
|
+
"find": "찾기·바꾸기"
|
|
871
|
+
},
|
|
868
872
|
"canvasMenu": {
|
|
873
|
+
"copy": "복사",
|
|
874
|
+
"paste": "붙여넣기",
|
|
869
875
|
"duplicate": "복제",
|
|
870
876
|
"lock": "잠그기",
|
|
871
877
|
"unlock": "잠금 해제",
|
|
@@ -11,6 +11,10 @@
|
|
|
11
11
|
* 쓰지 않는다(죽은 코드). 우리 문서는 대부분이 그룹이라 그룹 해제가 꼭 필요하다.
|
|
12
12
|
*/
|
|
13
13
|
|
|
14
|
+
import { copyElements, hasClip, pasteElements } from "@leviosa-ai/canvas/edit/commands";
|
|
15
|
+
import type { CanvasStore } from "@leviosa-ai/canvas/store";
|
|
16
|
+
|
|
17
|
+
import { markCanvasCopy } from "./canvas-paste";
|
|
14
18
|
import { groupAction } from "./group-action";
|
|
15
19
|
import {
|
|
16
20
|
applyFormat,
|
|
@@ -23,6 +27,8 @@ import {
|
|
|
23
27
|
import { canMoveZ, moveZ, zOrderOf, type ZOrderElement } from "./z-order";
|
|
24
28
|
|
|
25
29
|
export type CanvasMenuAction =
|
|
30
|
+
| "copy"
|
|
31
|
+
| "paste"
|
|
26
32
|
| "duplicate"
|
|
27
33
|
| "delete"
|
|
28
34
|
| "lock"
|
|
@@ -83,8 +89,11 @@ export function canvasMenuItems(store: CanvasMenuStore): CanvasMenuItem[] {
|
|
|
83
89
|
const ungroup = groupAction(store, true);
|
|
84
90
|
|
|
85
91
|
const items: CanvasMenuItem[] = [
|
|
92
|
+
// 폰에는 ⌘C·⌘V 가 없다 — 다른 화면으로 옮겨 붙이려면 메뉴에 있어야 한다.
|
|
93
|
+
{ action: "copy", disabled: false },
|
|
94
|
+
{ action: "paste", disabled: !hasClip() },
|
|
86
95
|
// 잠긴 요소를 복제하면 잠긴 사본이 생겨 만질 수도 지울 수도 없다.
|
|
87
|
-
{ action: "duplicate", disabled: anyLocked },
|
|
96
|
+
{ action: "duplicate", disabled: anyLocked, separated: true },
|
|
88
97
|
{ action: locked ? "unlock" : "lock", disabled: false },
|
|
89
98
|
{ action: "delete", disabled: anyLocked },
|
|
90
99
|
// 서식은 하나에서 떠서 여럿에 먹인다 — 복사는 단일 선택일 때만.
|
|
@@ -137,6 +146,14 @@ export function runCanvasMenuAction(
|
|
|
137
146
|
};
|
|
138
147
|
|
|
139
148
|
switch (action) {
|
|
149
|
+
case "copy":
|
|
150
|
+
copyElements(store as unknown as CanvasStore);
|
|
151
|
+
// OS 클립보드에도 표식을 남긴다 — 안 남기면 ⌘V 가 옛 OS 내용을 붙인다.
|
|
152
|
+
markCanvasCopy();
|
|
153
|
+
return;
|
|
154
|
+
case "paste":
|
|
155
|
+
pasteElements(store as unknown as CanvasStore);
|
|
156
|
+
return;
|
|
140
157
|
case "duplicate":
|
|
141
158
|
// clone()은 항상 요소의 **페이지**에 추가한다(node-model: e.page.addElement).
|
|
142
159
|
// 그룹 자식의 x/y도 페이지 좌표라, 그룹 안 도형을 복제하면 제자리에 사본이
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@leviosa-ai/detail-page-editor",
|
|
3
|
-
"version": "0.19.
|
|
3
|
+
"version": "0.19.1",
|
|
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.11.
|
|
71
|
+
"@leviosa-ai/canvas": "0.11.1",
|
|
72
72
|
"@tanstack/react-query": "^5.100.3",
|
|
73
73
|
"konva": "^10.0.0",
|
|
74
74
|
"lucide-react": "^0.563.0 || ^1.0.0",
|