@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.
@@ -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
- duplicate: Copy,
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
- return () => document.removeEventListener("keydown", onKey, { capture: true });
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
- store.selectedElementsIds.length === 0
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
- function InspectorBar({ store, inspector }: { store: unknown; inspector: ReactNode }) {
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.0",
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.0",
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",