@leviosa-ai/detail-page-editor 0.18.0 → 0.19.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.
@@ -18,6 +18,7 @@ import { CANVAS_MENU_ICONS } from "./canvas-context-menu";
18
18
  import { ImageCropOverlay, type CropElement } from "./image-crop-overlay";
19
19
  import {
20
20
  SelectionQuickToolbar,
21
+ popoverHorizontal,
21
22
  useQuickPopoverPlacement,
22
23
  type QuickToolbarItem,
23
24
  } from "./selection-quick-toolbar";
@@ -58,12 +59,17 @@ function Popover({
58
59
  children: React.ReactNode;
59
60
  width?: number;
60
61
  }) {
61
- const { side, maxHeight } = useQuickPopoverPlacement();
62
+ const { side, maxHeight, left, hostWidth } = useQuickPopoverPlacement();
63
+ const fit = popoverHorizontal(width, left, hostWidth);
62
64
  return (
63
65
  <div
64
66
  data-dp-quick-popover=""
65
67
  data-dp-quick-popover-side={side}
66
- style={{ width, maxHeight: `min(60vh, 520px, ${maxHeight}px)` }}
68
+ style={{
69
+ width: fit.width,
70
+ maxHeight: `min(60vh, 520px, ${maxHeight}px)`,
71
+ transform: fit.shift ? `translateX(${fit.shift}px)` : undefined,
72
+ }}
67
73
  className={[
68
74
  "overflow-y-auto rounded-le-xl border border-le-ink-200 bg-le-surface shadow-lg",
69
75
  side === "above" ? "absolute bottom-full left-0 mb-1.5" : "mt-1.5",
@@ -529,10 +529,12 @@ export const DetailPageDownloadDialog = observer(function DetailPageDownloadDial
529
529
  onClick={() => setOpen((v) => !v)}
530
530
  aria-haspopup="dialog"
531
531
  aria-expanded={open}
532
- className="inline-flex h-9 items-center gap-2 rounded-le-md bg-le-ink-900 px-3 text-sm font-le-semibold text-le-on-accent transition-colors hover:bg-le-ink-800"
532
+ aria-label={t("editor.download")}
533
+ className="inline-flex h-9 shrink-0 items-center gap-2 rounded-le-md bg-le-ink-900 px-3 text-sm font-le-semibold text-le-on-accent transition-colors hover:bg-le-ink-800"
533
534
  >
534
535
  <Download aria-hidden="true" size={16} />
535
- {t("editor.download")}
536
+ {/* 폰에서는 아이콘만 — 글자까지 넣으면 헤더가 넘쳐 한 글자씩 꺾인다. */}
537
+ <span className="hidden sm:inline">{t("editor.download")}</span>
536
538
  </button>
537
539
 
538
540
  {open ? (
@@ -37,6 +37,7 @@ import {
37
37
  useDetailPageHost,
38
38
  } from "./detail-page-host-context";
39
39
  import { SectionReauthorController } from "./section-reauthor-controller";
40
+ import { MobileBottomBar, useIsMobile, useKeyboardViewport } from "./mobile-editor-bars";
40
41
  import type {
41
42
  GenerateGifFn,
42
43
  GenerateImageFn,
@@ -190,6 +191,9 @@ export function DetailPageEditor({
190
191
  // 프롬프트 편집 사용량은 여기서 한 번만 조회한다 — 캔버스 위 띠와 우측 패널(표·차트)이
191
192
  // 같은 숫자를 봐야 "몇 번 남았는가"가 갈라지지 않는다.
192
193
  const { usage, applyUsage } = useDetailPageEditUsage(generatedId);
194
+ // 폰에서는 좌측 레일·우측 인스펙터를 하단 띠 하나로 접는다(mobile-editor-bars).
195
+ const mobile = useIsMobile();
196
+ const keyboard = useKeyboardViewport();
193
197
  const [saveError, setSaveError] = useState<string | null>(null);
194
198
  const [saveOk, setSaveOk] = useState(false);
195
199
  // 편집 한도 소진 시 "편집 크레딧 추가하기" → pricing 모달을 인플레이스로 연다.
@@ -330,7 +334,7 @@ export function DetailPageEditor({
330
334
  return (
331
335
  <div className="absolute inset-0 flex">
332
336
  {hotkeys}
333
- {SidebarSlot ? (
337
+ {mobile ? null : SidebarSlot ? (
334
338
  <SidebarSlot
335
339
  store={store}
336
340
  sections={sidebarSections as never}
@@ -357,7 +361,7 @@ export function DetailPageEditor({
357
361
  </div>
358
362
  </div>
359
363
  );
360
- }, [store, sidebarSections, SidebarSlot, chosenFrame, onChooseFrame, handleSave, uploadFile]);
364
+ }, [store, sidebarSections, SidebarSlot, mobile, chosenFrame, onChooseFrame, handleSave, uploadFile]);
361
365
 
362
366
  const aiValue = useMemo(
363
367
  () => ({
@@ -413,7 +417,7 @@ export function DetailPageEditor({
413
417
  <ChevronLeft aria-hidden="true" size={20} />
414
418
  </button>
415
419
  ) : null}
416
- <p className="ml-1 max-w-[280px] truncate text-sm font-le-semibold text-le-ink-900">
420
+ <p className="ml-1 min-w-0 max-w-[280px] truncate text-sm font-le-semibold text-le-ink-900">
417
421
  {productName?.trim() || t("editor.untitled")}
418
422
  </p>
419
423
 
@@ -443,10 +447,13 @@ export function DetailPageEditor({
443
447
  type="button"
444
448
  onClick={handleSave}
445
449
  disabled={saving}
446
- className="inline-flex h-9 items-center gap-2 rounded-le-md border border-le-ink-200 bg-le-surface px-3 text-sm font-le-semibold text-le-ink-900 hover:bg-le-ink-50 disabled:cursor-not-allowed disabled:opacity-50"
450
+ aria-label={saving ? t("editor.saving") : t("editor.save")}
451
+ className="inline-flex h-9 shrink-0 items-center gap-2 rounded-le-md border border-le-ink-200 bg-le-surface px-3 text-sm font-le-semibold text-le-ink-900 hover:bg-le-ink-50 disabled:cursor-not-allowed disabled:opacity-50"
447
452
  >
448
453
  <Save aria-hidden="true" size={16} />
449
- {saving ? t("editor.saving") : t("editor.save")}
454
+ <span className="hidden sm:inline">
455
+ {saving ? t("editor.saving") : t("editor.save")}
456
+ </span>
450
457
  </button>
451
458
  {downloadPart}
452
459
 
@@ -516,22 +523,43 @@ export function DetailPageEditor({
516
523
  <TooltipProvider>
517
524
  <div
518
525
  data-le-root=""
519
- className="flex h-screen min-h-[640px] flex-col bg-le-ink-100"
526
+ className="flex h-dvh min-h-[480px] flex-col md:h-screen md:min-h-[640px] bg-le-ink-100"
527
+ // 폰에서 키보드가 뜨면 보이는 자리만큼으로 줄여 띠·입력창을 키보드 위에 둔다.
528
+ style={
529
+ mobile && keyboard
530
+ ? {
531
+ height: keyboard.height,
532
+ minHeight: 0,
533
+ position: "fixed",
534
+ insetInline: 0,
535
+ top: keyboard.top,
536
+ }
537
+ : undefined
538
+ }
520
539
  >
521
540
  {/* hookable식 상단 헤더: 뒤로가기 · 상품명 · (되돌리기/다시실행) · 저장 ·
522
541
  다운로드 · 앱 공용 크롬(크레딧/알림/언어, 호스트 주입). 높이를 고정하고
523
542
  본문은 flex-1 min-h-0으로 두어, 헤더 높이가 바뀌어도 캔버스가 남는 높이를
524
543
  정확히 채워 페이지 하단이 잘리지 않는다. */}
525
544
  {header}
526
- <div className="grid min-h-0 flex-1 grid-cols-[1fr_360px]">
545
+ <div
546
+ className={`grid min-h-0 flex-1 ${mobile ? "grid-cols-1" : "grid-cols-[1fr_360px]"}`}
547
+ >
527
548
  {/* 캔버스 칸의 높이를 못 박는다. 안 그러면 그리드 행이 내용만큼 늘어나(높이
528
549
  100%가 auto로 풀린다) 작업 영역이 화면 아래로 자라고, 아래 붙는 배율·화면
529
550
  띠가 화면 밖으로 밀린다. */}
530
551
  <div data-le-part="workspace" className="relative min-w-0 overflow-hidden">
531
552
  {canvas}
532
553
  </div>
533
- {inspector}
554
+ {mobile ? null : inspector}
534
555
  </div>
556
+ {mobile ? (
557
+ <MobileBottomBar
558
+ store={store}
559
+ sections={sidebarSections}
560
+ inspector={defaultInspector}
561
+ />
562
+ ) : null}
535
563
  {/* 화면 하나만 마크업째 다시 저작 — 요청은 캔버스 옆 페이지 툴바에서 온다. */}
536
564
  <SectionReauthorController
537
565
  store={store}
@@ -191,7 +191,8 @@ function IconButton({
191
191
  onClick();
192
192
  }}
193
193
  className={[
194
- "flex h-6 w-6 items-center justify-center rounded transition-colors",
194
+ // 터치 화면(pointer: coarse)에서는 손가락 폭만큼 키운다.
195
+ "flex h-6 w-6 items-center justify-center rounded transition-colors pointer-coarse:h-9 pointer-coarse:w-9",
195
196
  active ? "text-le-ink-800" : "text-le-ink-400",
196
197
  disabled
197
198
  ? "cursor-not-allowed opacity-30"
@@ -1912,7 +1912,10 @@ function InspectorHeader({ els }: { els: ElementLike[] }) {
1912
1912
  label = t("detailPage.properties.selectionCount", { count: els.length });
1913
1913
  }
1914
1914
  return (
1915
- <div className="flex items-center gap-2 border-b border-le-ink-200 px-4 py-3 text-sm font-le-semibold text-le-ink-950">
1915
+ <div
1916
+ data-le-inspector-header=""
1917
+ className="flex items-center gap-2 border-b border-le-ink-200 px-4 py-3 text-sm font-le-semibold text-le-ink-950"
1918
+ >
1916
1919
  {icon}
1917
1920
  {label}
1918
1921
  </div>
@@ -1983,7 +1986,7 @@ export const DetailPageProperties = observer(function DetailPageProperties({
1983
1986
  return (
1984
1987
  <div className="flex h-full min-h-0 flex-col">
1985
1988
  <InspectorHeader els={els} />
1986
- <div className="min-h-0 flex-1 overflow-y-auto">
1989
+ <div data-le-inspector-body="" className="min-h-0 flex-1 overflow-y-auto">
1987
1990
  {els.length === 0 ? (
1988
1991
  <PageInspector
1989
1992
  store={s}
@@ -104,7 +104,11 @@ export function Section({
104
104
  children: React.ReactNode;
105
105
  }) {
106
106
  return (
107
- <section className="border-t border-le-ink-200 px-4 py-3 first:border-t-0">
107
+ // `data-le-section` 은 모바일 하단 띠가 섹션을 탭으로 세우는 손잡이다(mobile-editor-bars).
108
+ <section
109
+ data-le-section={title}
110
+ className="border-t border-le-ink-200 px-4 py-3 first:border-t-0"
111
+ >
108
112
  <h4 className="mb-2 text-[11px] font-le-semibold uppercase tracking-[0.06em] text-le-ink-400">
109
113
  {title}
110
114
  </h4>
@@ -20,10 +20,12 @@
20
20
  import {
21
21
  useCallback,
22
22
  useEffect,
23
+ useLayoutEffect,
23
24
  useMemo,
24
25
  useRef,
25
26
  useState,
26
27
  useSyncExternalStore,
28
+ type PointerEvent as ReactPointerEvent,
27
29
  type ReactNode,
28
30
  } from "react";
29
31
 
@@ -46,6 +48,7 @@ import {
46
48
  import { FrameDragLayer } from "./frame-drag-layer";
47
49
  import { GifAnimator } from "./gif-animator";
48
50
  import { GroupDrillIn } from "./group-drill-in";
51
+ import { useIsMobile } from "./mobile-editor-bars";
49
52
  import { HoverHighlightOverlay } from "./hover-highlight-overlay";
50
53
  import { CanvasSectionHeightHandle } from "./section-height-handle";
51
54
  import { loadEditorFont } from "../../lib/detail-page-canvas/editor-fonts";
@@ -102,6 +105,26 @@ const THUMB_PIXEL_RATIO = 0.12;
102
105
  /** 아래 띠가 가장자리·화면 목록에서 떨어지는 거리. */
103
106
  const DOCK_GAP = 16;
104
107
 
108
+ /**
109
+ * 핀치를 붙잡은 자리 — 손가락 가운데 아래 있던 판과, 그 판 안의 문서 좌표.
110
+ *
111
+ * 스크롤 양으로 셈하지 않고 판의 실제 자리(DOM)로 맞춘다. 판 둘레 여백과 가운데 정렬은
112
+ * 배율 따라 안 늘어나서, 스크롤로 비례 계산하면 수십 px 씩 밀린다.
113
+ */
114
+ type PinchAnchor = {
115
+ page: HTMLElement;
116
+ docX: number;
117
+ docY: number;
118
+ clientX: number;
119
+ clientY: number;
120
+ };
121
+
122
+ /** 관성 스크롤 한 걸음: 속도(px/ms)를 dt(ms)만큼 마찰로 줄인다. 충분히 느려지면 0. */
123
+ export function flingStep(velocity: number, dt: number): number {
124
+ const next = velocity * Math.pow(0.997, dt);
125
+ return Math.abs(next) < 0.02 ? 0 : next;
126
+ }
127
+
105
128
  export function LeviosaCanvasWorkspace({
106
129
  store,
107
130
  gap = 4,
@@ -205,7 +228,35 @@ export function LeviosaCanvasWorkspace({
205
228
  y: number;
206
229
  left: number;
207
230
  top: number;
231
+ /** 손을 뗄 때 관성으로 넘길 속도(px/ms) — 최근 움직임에 가중을 둔다. */
232
+ vx: number;
233
+ vy: number;
234
+ lastX: number;
235
+ lastY: number;
236
+ at: number;
208
237
  } | null>(null);
238
+ const fling = useRef(0);
239
+ const stopFling = useCallback(() => {
240
+ cancelAnimationFrame(fling.current);
241
+ fling.current = 0;
242
+ }, []);
243
+ useEffect(() => stopFling, [stopFling]);
244
+ const startFling = (el: HTMLElement, vx: number, vy: number) => {
245
+ // rAF 시각은 프레임 시작 시각이라 performance.now() 보다 이를 수 있다 — 첫 프레임을
246
+ // 기준으로 잡고, 탭이 멈췄다 깨어난 긴 간격은 한 프레임 남짓으로 자른다.
247
+ let last: number | null = null;
248
+ const step = (now: number) => {
249
+ const dt = last === null ? 16 : Math.min(64, Math.max(0, now - last));
250
+ last = now;
251
+ vx = flingStep(vx, dt);
252
+ vy = flingStep(vy, dt);
253
+ if (!vx && !vy) return void (fling.current = 0);
254
+ el.scrollLeft += vx * dt;
255
+ el.scrollTop += vy * dt;
256
+ fling.current = requestAnimationFrame(step);
257
+ };
258
+ fling.current = requestAnimationFrame(step);
259
+ };
209
260
  // 배율은 **스토어**에 산다. 확대 버튼도, 여기 휠도 같은 자리를 만져야 한 쪽이
210
261
  // 다른 쪽을 되돌려 놓지 않는다.
211
262
  const scale = store.scale;
@@ -230,6 +281,7 @@ export function LeviosaCanvasWorkspace({
230
281
  // 너무 크다(맨 처음 붙였을 때 134%가 나왔다). 그래서 **사용자가 배율을 만지기
231
282
  // 전까지는** 영역이 바뀔 때마다 다시 맞춘다. 손을 대는 순간 주인이 바뀐다.
232
283
  const fittedScale = useRef<number | null>(null);
284
+ const mobile = useIsMobile();
233
285
  useEffect(() => {
234
286
  if (!viewport.width || !viewport.height || store.pages.length === 0) return;
235
287
  // 우리가 맞춰 놓은 값과 다르면 사용자가 만진 것이다.
@@ -252,14 +304,19 @@ export function LeviosaCanvasWorkspace({
252
304
  ) +
253
305
  (frames.length - 1) * FRAME_GAP_DOC
254
306
  : page.width;
307
+ // 폰에서는 지금 보는 판 하나의 **폭**에 맞춘다. 높이까지 넣으면 긴 상세페이지가
308
+ // 10%대로 쪼그라들어 글자를 못 읽는다 — 아래는 손가락으로 내려 보면 된다.
255
309
  const next = clamp(
256
- Math.min(usableW / spread, usableH / page.height) * 0.94,
310
+ mobile
311
+ ? // 벌이 여럿이면 판이 흰 바탕(안쪽 여백 8 + 테두리 1)에 싸여 있다.
312
+ (usableW - (frames.length > 1 ? 18 : 0)) / page.width
313
+ : Math.min(usableW / spread, usableH / page.height) * 0.94,
257
314
  MIN_SCALE,
258
315
  MAX_SCALE,
259
316
  );
260
317
  fittedScale.current = next;
261
318
  setScale(next);
262
- }, [viewport, paddingX, gap, store, setScale]);
319
+ }, [viewport, paddingX, gap, store, setScale, mobile]);
263
320
 
264
321
  // ⌘/ctrl+휠(맥 트랙패드 핀치가 이 모양으로 온다)로 커서 자리를 붙잡고 확대.
265
322
  // 그냥 휠은 브라우저 스크롤 그대로 둔다.
@@ -267,6 +324,7 @@ export function LeviosaCanvasWorkspace({
267
324
  const inner = innerRef.current;
268
325
  if (!inner) return;
269
326
  const onWheel = (event: WheelEvent) => {
327
+ stopFling();
270
328
  if (!event.ctrlKey && !event.metaKey) return;
271
329
  event.preventDefault();
272
330
  const current = store.scale;
@@ -286,7 +344,136 @@ export function LeviosaCanvasWorkspace({
286
344
  };
287
345
  inner.addEventListener("wheel", onWheel, { passive: false });
288
346
  return () => inner.removeEventListener("wheel", onWheel);
347
+ }, [store, stopFling]);
348
+
349
+ const startPan = (event: ReactPointerEvent<HTMLDivElement>) => {
350
+ pan.current = {
351
+ pointerId: event.pointerId,
352
+ x: event.clientX,
353
+ y: event.clientY,
354
+ left: event.currentTarget.scrollLeft,
355
+ top: event.currentTarget.scrollTop,
356
+ vx: 0,
357
+ vy: 0,
358
+ lastX: event.clientX,
359
+ lastY: event.clientY,
360
+ at: event.timeStamp,
361
+ };
362
+ event.currentTarget.setPointerCapture?.(event.pointerId);
363
+ setPanning(true);
364
+ event.preventDefault();
365
+ };
366
+
367
+ // 두 손가락: 벌린 만큼 확대하고, 두 손가락 가운데를 따라 화면을 옮긴다. 두 번째
368
+ // 손가락이 닿는 순간부터 캔버스(요소 끌기·Konva)에는 아무것도 안 보낸다 — 첫 손가락이
369
+ // 요소를 집고 있었으면 그 자리에서 멈춘다.
370
+ const pinching = useRef<{ distance: number; scale: number } | null>(null);
371
+ const anchor = useRef<PinchAnchor | null>(null);
372
+ // 붙잡은 문서 좌표가 손가락 가운데 아래 오도록 스크롤을 옮긴다. 배율이 바뀌었으면
373
+ // 새 크기가 그려진 직후(레이아웃 효과)에 한다 — 먼저 하면 옛 크기에 잘린다.
374
+ const settleAnchor = useCallback(() => {
375
+ const inner = innerRef.current;
376
+ const at = anchor.current;
377
+ if (!inner || !at) return;
378
+ const rect = at.page.getBoundingClientRect();
379
+ inner.scrollLeft += rect.left + at.docX * store.scale - at.clientX;
380
+ inner.scrollTop += rect.top + at.docY * store.scale - at.clientY;
289
381
  }, [store]);
382
+ useLayoutEffect(() => {
383
+ if (pinching.current) settleAnchor();
384
+ }, [scale, settleAnchor]);
385
+ useEffect(() => {
386
+ const inner = innerRef.current;
387
+ if (!inner) return;
388
+ const points = (event: TouchEvent) => {
389
+ const [a, b] = [event.touches[0], event.touches[1]];
390
+ return {
391
+ x: (a.clientX + b.clientX) / 2,
392
+ y: (a.clientY + b.clientY) / 2,
393
+ distance: Math.hypot(a.clientX - b.clientX, a.clientY - b.clientY),
394
+ };
395
+ };
396
+ const onStart = (event: TouchEvent) => {
397
+ if (event.touches.length !== 2) return;
398
+ event.stopPropagation();
399
+ const at = points(event);
400
+ const page =
401
+ document.elementFromPoint(at.x, at.y)?.closest<HTMLElement>("[data-lc-page]") ??
402
+ inner.querySelector<HTMLElement>("[data-lc-page]");
403
+ pan.current = null;
404
+ setPanning(false);
405
+ pinching.current = { distance: at.distance, scale: store.scale };
406
+ if (!page) return;
407
+ const rect = page.getBoundingClientRect();
408
+ anchor.current = {
409
+ page,
410
+ docX: (at.x - rect.left) / store.scale,
411
+ docY: (at.y - rect.top) / store.scale,
412
+ clientX: at.x,
413
+ clientY: at.y,
414
+ };
415
+ };
416
+ const onMove = (event: TouchEvent) => {
417
+ const start = pinching.current;
418
+ if (!start) return;
419
+ event.stopPropagation();
420
+ event.preventDefault();
421
+ if (event.touches.length < 2) return;
422
+ const at = points(event);
423
+ if (anchor.current) {
424
+ anchor.current.clientX = at.x;
425
+ anchor.current.clientY = at.y;
426
+ }
427
+ const next = clamp(
428
+ start.scale * (at.distance / Math.max(1, start.distance)),
429
+ MIN_SCALE,
430
+ MAX_SCALE,
431
+ );
432
+ if (next !== store.scale) store.setScale(next);
433
+ else settleAnchor();
434
+ };
435
+ const onEnd = (event: TouchEvent) => {
436
+ // 끝 이벤트는 흘려보낸다 — Konva 가 window 에서 받아 진행 중이던 드래그를 닫는다.
437
+ if (!pinching.current) return;
438
+ if (event.touches.length === 0) {
439
+ pinching.current = null;
440
+ anchor.current = null;
441
+ }
442
+ };
443
+ // 캡처로 건다 — 캔버스(안쪽)보다 먼저 받아야 끊을 수 있다.
444
+ const opts = { capture: true, passive: false } as const;
445
+ inner.addEventListener("touchstart", onStart, opts);
446
+ inner.addEventListener("touchmove", onMove, opts);
447
+ inner.addEventListener("touchend", onEnd, opts);
448
+ inner.addEventListener("touchcancel", onEnd, opts);
449
+ // Konva 는 포인터 이벤트도 듣는다. 포인터 이벤트는 touchstart 보다 먼저 오므로
450
+ // 두 번째 손가락은 여기서 따로 센다 — 안 그러면 그 손가락이 다른 요소를 골라 버린다.
451
+ const down = new Set<number>();
452
+ const blockPointer = (event: PointerEvent) => {
453
+ if (event.type === "pointerdown") stopFling();
454
+ if (event.pointerType !== "touch") return;
455
+ if (event.type === "pointerdown") {
456
+ const second = down.size > 0;
457
+ down.add(event.pointerId);
458
+ if (second) event.stopPropagation();
459
+ } else if (event.type === "pointermove") {
460
+ if (pinching.current || down.size > 1) event.stopPropagation();
461
+ } else {
462
+ down.delete(event.pointerId);
463
+ }
464
+ };
465
+ const pointerTypes = ["pointerdown", "pointermove", "pointerup", "pointercancel"];
466
+ for (const type of pointerTypes) inner.addEventListener(type, blockPointer as EventListener, true);
467
+ return () => {
468
+ inner.removeEventListener("touchstart", onStart, opts);
469
+ inner.removeEventListener("touchmove", onMove, opts);
470
+ inner.removeEventListener("touchend", onEnd, opts);
471
+ inner.removeEventListener("touchcancel", onEnd, opts);
472
+ for (const type of pointerTypes) {
473
+ inner.removeEventListener(type, blockPointer as EventListener, true);
474
+ }
475
+ };
476
+ }, [store, settleAnchor, stopFling]);
290
477
 
291
478
  /** 스크롤 때문에 우리가 바꾼 활성 화면 — 바깥에서 바꾼 것과 구분해 되울림을 막는다. */
292
479
  const scrollSetId = useRef<string | null>(null);
@@ -517,6 +704,17 @@ export function LeviosaCanvasWorkspace({
517
704
  onPointerDown={(event) => {
518
705
  // 페이지 바깥의 빈 자리를 누르면 선택 해제. 페이지 안은 캔버스가 처리한다.
519
706
  const target = event.target as HTMLElement;
707
+ // 손가락으로 판 위 빈 곳(또는 잠긴 배경)을 누르면 화면을 옮긴다. 캔버스가 먼저
708
+ // 받아서 요소를 짚었으면 골랐고, 빈 곳이면 선택을 비웠다 — 그걸 보고 가른다.
709
+ // 판 위의 터치는 브라우저 스크롤을 꺼 뒀다(touch-action) — 요소 끌기와 겹친다.
710
+ if (
711
+ event.pointerType === "touch" &&
712
+ target.closest("[data-lc-page]") &&
713
+ store.selectedElementsIds.length === 0
714
+ ) {
715
+ startPan(event);
716
+ return;
717
+ }
520
718
  if (
521
719
  target.closest("[data-lc-page]") ||
522
720
  target.closest("[data-dp-quicktoolbar]") ||
@@ -536,28 +734,32 @@ export function LeviosaCanvasWorkspace({
536
734
  if (first) store.selectPage(first.id);
537
735
  }
538
736
  if (event.button !== 0) return;
539
- pan.current = {
540
- pointerId: event.pointerId,
541
- x: event.clientX,
542
- y: event.clientY,
543
- left: event.currentTarget.scrollLeft,
544
- top: event.currentTarget.scrollTop,
545
- };
546
- event.currentTarget.setPointerCapture?.(event.pointerId);
547
- setPanning(true);
548
- event.preventDefault();
737
+ startPan(event);
549
738
  }}
550
739
  onPointerMove={(event) => {
551
740
  const start = pan.current;
552
- if (!start || start.pointerId !== event.pointerId) return;
741
+ if (!start || start.pointerId !== event.pointerId || pinching.current) return;
553
742
  event.currentTarget.scrollLeft = start.left + start.x - event.clientX;
554
743
  event.currentTarget.scrollTop = start.top + start.y - event.clientY;
744
+ const dt = event.timeStamp - start.at;
745
+ if (dt > 0) {
746
+ start.vx = 0.8 * ((start.lastX - event.clientX) / dt) + 0.2 * start.vx;
747
+ start.vy = 0.8 * ((start.lastY - event.clientY) / dt) + 0.2 * start.vy;
748
+ }
749
+ start.lastX = event.clientX;
750
+ start.lastY = event.clientY;
751
+ start.at = event.timeStamp;
555
752
  }}
556
753
  onPointerUp={(event) => {
557
- if (pan.current?.pointerId !== event.pointerId) return;
754
+ const start = pan.current;
755
+ if (start?.pointerId !== event.pointerId) return;
558
756
  pan.current = null;
559
757
  event.currentTarget.releasePointerCapture?.(event.pointerId);
560
758
  setPanning(false);
759
+ // 손가락만 관성으로 미끄러진다. 멈췄다 뗐으면(80ms 넘게 안 움직임) 그대로 선다.
760
+ if (event.pointerType === "touch" && event.timeStamp - start.at < 80) {
761
+ startFling(event.currentTarget, start.vx, start.vy);
762
+ }
561
763
  }}
562
764
  onPointerCancel={(event) => {
563
765
  if (pan.current?.pointerId !== event.pointerId) return;
@@ -574,6 +776,9 @@ export function LeviosaCanvasWorkspace({
574
776
  flexDirection: "column",
575
777
  alignItems: "flex-start",
576
778
  cursor: panning ? "grabbing" : "default",
779
+ // 손가락 제스처(옮기기·핀치)는 아래에서 직접 받는다. 브라우저에 맡기면 요소
780
+ // 끌기와 화면 스크롤이 한 손가락을 두고 다툰다.
781
+ touchAction: "none",
577
782
  padding: `${frameCount > 1 ? gap + FRAME_HEAD : gap}px ${paddingX}px ${gap}px`,
578
783
  }}
579
784
  >
@@ -0,0 +1,382 @@
1
+ // Copyright © 2026 주식회사레비오사에이아이. All rights reserved. See LICENSE.
2
+ "use client";
3
+
4
+ /**
5
+ * 좁은 화면(폰)의 하단 띠 — 좌측 레일과 우측 인스펙터가 들어갈 자리가 없어서 둘 다
6
+ * 화면 아래 한 줄로 내린다(Canva 모바일과 같은 모양).
7
+ *
8
+ * - 아무것도 안 골랐으면 좌측 레일의 탭을 가로로 흘린다. 누르면 패널이 아래에서
9
+ * 올라오는 시트로 열린다.
10
+ * - 뭔가 골랐으면 인스펙터의 섹션(`Section` 제목)을 탭으로 세운다. 누르면 그 섹션
11
+ * 하나만 띠 바로 위에 뜬다.
12
+ *
13
+ * 인스펙터를 섹션별로 다시 짜지 않는다. 인스펙터를 통째로 그려 두고, `Section` 이 다는
14
+ * `data-le-section` 을 읽어 탭을 세우고 고른 것 하나만 CSS 로 보인다 — 인스펙터가 늘어도
15
+ * 여기는 안 고친다.
16
+ */
17
+
18
+ import {
19
+ useEffect,
20
+ useLayoutEffect,
21
+ useMemo,
22
+ useRef,
23
+ useState,
24
+ useSyncExternalStore,
25
+ type ReactElement,
26
+ type PointerEvent as ReactPointerEvent,
27
+ type ReactNode,
28
+ } from "react";
29
+ import { Check, Settings2 } from "lucide-react";
30
+ import { useTranslation } from "react-i18next";
31
+ import { SectionTab } from "@leviosa-ai/canvas";
32
+
33
+ import { observer } from "./canvas-observer";
34
+ import { selectedElementsDeep } from "./detail-page-selection";
35
+
36
+ const MOBILE_QUERY = "(max-width: 767px)";
37
+
38
+ function subscribeMobile(onChange: () => void) {
39
+ const mq = window.matchMedia(MOBILE_QUERY);
40
+ mq.addEventListener("change", onChange);
41
+ return () => mq.removeEventListener("change", onChange);
42
+ }
43
+
44
+ export function useIsMobile(): boolean {
45
+ return useSyncExternalStore(
46
+ subscribeMobile,
47
+ () => window.matchMedia(MOBILE_QUERY).matches,
48
+ () => false,
49
+ );
50
+ }
51
+
52
+ /**
53
+ * 키보드가 올라와 눈에 보이는 높이가 줄었으면 그 높이(px), 아니면 null.
54
+ *
55
+ * iOS 는 키보드가 떠도 레이아웃 높이(`100dvh`)를 안 줄이고 그 위를 덮는다 — 그러면
56
+ * 하단 띠와 고치는 글자가 키보드 밑에 깔린다. 편집기를 보이는 높이로 줄여 띠를 키보드
57
+ * 바로 위로 올린다.
58
+ */
59
+ function subscribeViewport(onChange: () => void) {
60
+ const vv = window.visualViewport;
61
+ vv?.addEventListener("resize", onChange);
62
+ vv?.addEventListener("scroll", onChange);
63
+ return () => {
64
+ vv?.removeEventListener("resize", onChange);
65
+ vv?.removeEventListener("scroll", onChange);
66
+ };
67
+ }
68
+ // ponytail: 120px 넘게 줄면 키보드로 본다 — 주소창이 접히고 펴지는 정도는 안 넘는다.
69
+ const KEYBOARD_PX = 120;
70
+ export function useKeyboardViewport(): { height: number; top: number } | null {
71
+ const key = useSyncExternalStore(
72
+ subscribeViewport,
73
+ () => {
74
+ const vv = window.visualViewport;
75
+ if (!vv || window.innerHeight - vv.height < KEYBOARD_PX) return "";
76
+ return `${Math.round(vv.height)}:${Math.round(vv.offsetTop)}`;
77
+ },
78
+ () => "",
79
+ );
80
+ if (!key) return null;
81
+ const [height, top] = key.split(":").map(Number);
82
+ return { height, top };
83
+ }
84
+
85
+ type MobileSection = {
86
+ name: string;
87
+ Tab: (props: Record<string, unknown>) => ReactElement | null;
88
+ Panel: (props: { store: unknown }) => ReactElement | null;
89
+ visibleInList?: boolean;
90
+ };
91
+
92
+ type StoreLike = {
93
+ pages: { children: { id: string }[] }[];
94
+ openedSidePanel: string;
95
+ openSidePanel: (name: string) => void;
96
+ selectElements: (ids: string[]) => void;
97
+ };
98
+
99
+ const BAR_CLASS =
100
+ "flex shrink-0 items-stretch [scrollbar-width:none] border-t border-le-ink-200 bg-le-surface pb-[env(safe-area-inset-bottom)]";
101
+
102
+ // 레일 탭은 세로 레일 폭을 채우게 짜여 있다 — 가로로 흘릴 때는 칸 폭을 못 박고,
103
+ // 브랜드 구역 앞에 긋는 가로 구분선은 숨긴다.
104
+ const SHEET_CSS = `
105
+ [data-le-mobile-tabs] > div > span[aria-hidden="true"] { display: none; }
106
+ [data-le-mobile-tabs] > div > button { padding: 10px 4px !important; font-size: 11px !important; }
107
+ @keyframes le-sheet-up { from { transform: translateY(100%); } to { transform: none; } }
108
+ @keyframes le-fade-in { from { opacity: 0; } to { opacity: 1; } }
109
+ `;
110
+
111
+ /** 아무것도 안 골랐을 때 — 좌측 레일 탭 띠 + 아래서 올라오는 시트. */
112
+ const SectionBar = observer(function SectionBar({
113
+ store,
114
+ sections,
115
+ }: {
116
+ store: unknown;
117
+ sections: ReadonlyArray<MobileSection>;
118
+ }) {
119
+ const s = store as StoreLike;
120
+ const opened = s.openedSidePanel;
121
+ const Panel = sections.find((section) => section.name === opened)?.Panel;
122
+ const close = () => s.openSidePanel("");
123
+ // 시트에서 뭔가 넣으면 시트를 닫고 넣은 것을 고른다 — 폰에서는 시트가 캔버스를 가리고
124
+ // 있어서, 안 그러면 뭐가 들어갔는지 안 보인다. 하단 띠도 그걸로 인스펙터로 넘어간다.
125
+ // (편집기의 `addElement` 는 선택을 안 옮긴다 — store.ts.)
126
+ const ids = s.pages.flatMap((page) => page.children.map((el) => el.id));
127
+ // 화면을 넣거나 복제한 것(페이지 패널)은 요소를 넣은 것이 아니다 — 화면 수가 바뀌면
128
+ // 기준만 새로 잡는다.
129
+ const pageCount = s.pages.length;
130
+ const seen = useRef<{ ids: Set<string>; pages: number } | null>(null);
131
+ useEffect(() => {
132
+ if (!Panel) {
133
+ seen.current = null;
134
+ return;
135
+ }
136
+ if (!seen.current || seen.current.pages !== pageCount) {
137
+ seen.current = { ids: new Set(ids), pages: pageCount };
138
+ return;
139
+ }
140
+ const known = seen.current.ids;
141
+ const added = ids.filter((id) => !known.has(id));
142
+ if (added.length) {
143
+ s.openSidePanel("");
144
+ s.selectElements(added);
145
+ }
146
+ });
147
+
148
+ // 끌어내리기. 시트 높이의 1/4 을 넘기거나 빠르게 튕기면 닫고, 아니면 제자리로.
149
+ const sheetRef = useRef<HTMLDivElement>(null);
150
+ const [dragY, setDragY] = useState<number | null>(null);
151
+ // 한 번 끌었으면 놓을 때 여는 애니메이션을 다시 틀지 않고 제자리로 미끄러진다.
152
+ const [dragged, setDragged] = useState(false);
153
+ const startDrag = (down: ReactPointerEvent<HTMLDivElement>) => {
154
+ const handle = down.currentTarget as HTMLDivElement;
155
+ handle.setPointerCapture(down.pointerId);
156
+ const y0 = down.clientY;
157
+ const t0 = down.timeStamp;
158
+ setDragged(true);
159
+ const move = (e: PointerEvent) => setDragY(Math.max(0, e.clientY - y0));
160
+ const up = (e: PointerEvent) => {
161
+ handle.removeEventListener("pointermove", move);
162
+ handle.removeEventListener("pointerup", up);
163
+ handle.removeEventListener("pointercancel", up);
164
+ const dy = Math.max(0, e.clientY - y0);
165
+ const height = sheetRef.current?.offsetHeight ?? 1;
166
+ const flick = dy / Math.max(1, e.timeStamp - t0) > 0.5;
167
+ setDragY(null);
168
+ if (e.type === "pointerup" && (dy < 4 || dy > height / 4 || flick)) close();
169
+ };
170
+ handle.addEventListener("pointermove", move);
171
+ handle.addEventListener("pointerup", up);
172
+ handle.addEventListener("pointercancel", up);
173
+ };
174
+
175
+ // 시트는 띠 위에서 멈춘다 — 띠가 보여야 다른 탭으로 바로 옮겨 간다(Canva 와 같다).
176
+ const navRef = useRef<HTMLElement>(null);
177
+ const [barHeight, setBarHeight] = useState(0);
178
+ useLayoutEffect(() => {
179
+ if (Panel) setBarHeight(navRef.current?.offsetHeight ?? 0);
180
+ else setDragged(false);
181
+ }, [Panel]);
182
+
183
+ return (
184
+ <>
185
+ <style>{SHEET_CSS}</style>
186
+ <nav
187
+ ref={navRef}
188
+ data-le-part="mobile-section-bar"
189
+ data-le-mobile-tabs=""
190
+ className={`${BAR_CLASS} overflow-x-auto`}
191
+ >
192
+ {sections
193
+ .filter((section) => section.visibleInList !== false)
194
+ .map(({ name, Tab }) => (
195
+ <div key={name} className="w-[72px] shrink-0">
196
+ <Tab
197
+ active={name === opened}
198
+ onClick={() => s.openSidePanel(name === opened ? "" : name)}
199
+ />
200
+ </div>
201
+ ))}
202
+ </nav>
203
+ {Panel ? (
204
+ <div
205
+ className="fixed inset-x-0 top-0 z-40"
206
+ style={{ bottom: barHeight }}
207
+ data-le-part="mobile-sheet"
208
+ >
209
+ <div
210
+ className="absolute inset-0 bg-le-scrim/60"
211
+ style={{ animation: "le-fade-in 160ms ease-out" }}
212
+ onClick={close}
213
+ aria-hidden="true"
214
+ />
215
+ <div
216
+ ref={sheetRef}
217
+ role="dialog"
218
+ aria-modal="true"
219
+ className="absolute inset-x-0 bottom-0 flex h-[75%] flex-col overflow-hidden rounded-t-le-xl bg-le-surface shadow-2xl"
220
+ style={
221
+ dragY !== null
222
+ ? { transform: `translateY(${dragY}px)` }
223
+ : dragged
224
+ ? { transition: "transform 200ms ease-out" }
225
+ : { animation: "le-sheet-up 220ms cubic-bezier(0.2, 0.8, 0.2, 1)" }
226
+ }
227
+ >
228
+ {/* 손잡이를 끌어내리면 닫힌다. 그냥 누르기만 해도 닫힌다. */}
229
+ <div
230
+ role="button"
231
+ aria-label="close"
232
+ tabIndex={0}
233
+ onKeyDown={(e) => (e.key === "Enter" || e.key === " ") && close()}
234
+ onPointerDown={startDrag}
235
+ className="flex h-7 shrink-0 cursor-grab touch-none items-center justify-center"
236
+ >
237
+ <span className="h-1 w-10 rounded-full bg-le-ink-300" />
238
+ </div>
239
+ <div className="flex min-h-0 flex-1 flex-col overflow-hidden">
240
+ <Panel store={store} />
241
+ </div>
242
+ </div>
243
+ </div>
244
+ ) : null}
245
+ </>
246
+ );
247
+ });
248
+
249
+ /** 뭔가 골랐을 때 — 인스펙터 섹션 탭 띠 + 띠 바로 위에 뜨는 섹션 하나. */
250
+ function InspectorBar({ store, inspector }: { store: unknown; inspector: ReactNode }) {
251
+ const s = store as StoreLike;
252
+ const hostRef = useRef<HTMLDivElement>(null);
253
+ const [titles, setTitles] = useState<string[]>([]);
254
+ const [active, setActive] = useState<string | null>(null);
255
+
256
+ // 인스펙터가 지금 그린 섹션 제목을 읽는다. 선택이 바뀌면 섹션도 바뀐다.
257
+ useEffect(() => {
258
+ const host = hostRef.current;
259
+ if (!host) return;
260
+ const read = () => {
261
+ const next = [
262
+ ...new Set(
263
+ Array.from(host.querySelectorAll<HTMLElement>("[data-le-section]"), (el) =>
264
+ el.getAttribute("data-le-section") ?? "",
265
+ ),
266
+ ),
267
+ ];
268
+ setTitles((prev) => (prev.join("\n") === next.join("\n") ? prev : next));
269
+ };
270
+ read();
271
+ const mo = new MutationObserver(read);
272
+ // 제목만 바뀌는 경우(언어 전환)도 있다 — 속성도 본다.
273
+ mo.observe(host, {
274
+ childList: true,
275
+ subtree: true,
276
+ attributes: true,
277
+ attributeFilter: ["data-le-section"],
278
+ });
279
+ return () => mo.disconnect();
280
+ }, []);
281
+
282
+ const shown = active !== null && titles.includes(active) ? active : null;
283
+
284
+ return (
285
+ <div className="relative shrink-0" data-le-part="mobile-inspector-bar">
286
+ <style>{`
287
+ [data-le-mobile-inspector] [data-le-inspector-header] { display: none; }
288
+ [data-le-mobile-inspector] [data-le-inspector-body] > :not([data-le-section]):not(:has([data-le-section])) { display: none; }
289
+ [data-le-mobile-inspector] [data-le-section]:not([data-le-section="${shown ? CSS.escape(shown) : ""}"]) { display: none; }
290
+ [data-le-mobile-inspector] [data-le-section] { border-top: 0; }
291
+ `}</style>
292
+ <div
293
+ ref={hostRef}
294
+ data-le-mobile-inspector=""
295
+ className="absolute inset-x-2 bottom-full z-50 mb-2 max-h-[45dvh] overflow-y-auto rounded-le-xl border border-le-ink-200 bg-le-surface shadow-lg"
296
+ style={{ display: shown ? undefined : "none" }}
297
+ >
298
+ {inspector}
299
+ </div>
300
+ <div className={BAR_CLASS}>
301
+ <div className="flex min-w-0 flex-1 overflow-x-auto [scrollbar-width:none]">
302
+ {titles.map((title) => (
303
+ <button
304
+ key={title}
305
+ type="button"
306
+ aria-pressed={title === shown}
307
+ onClick={() => setActive(title === shown ? null : title)}
308
+ className={`shrink-0 whitespace-nowrap px-3.5 py-3.5 text-xs font-le-medium ${
309
+ title === shown ? "bg-le-ink-100 text-le-ink-950" : "text-le-ink-600"
310
+ }`}
311
+ >
312
+ {title}
313
+ </button>
314
+ ))}
315
+ </div>
316
+ <button
317
+ type="button"
318
+ aria-label="done"
319
+ onClick={() => s.selectElements([])}
320
+ className="m-1.5 flex h-10 w-10 shrink-0 items-center justify-center rounded-full border border-le-ink-200 text-le-ink-900 shadow-sm"
321
+ >
322
+ <Check aria-hidden="true" size={18} />
323
+ </button>
324
+ </div>
325
+ </div>
326
+ );
327
+ }
328
+
329
+ export const MobileBottomBar = observer(function MobileBottomBar({
330
+ store,
331
+ sections,
332
+ inspector,
333
+ }: {
334
+ store: unknown;
335
+ sections: ReadonlyArray<MobileSection>;
336
+ inspector: ReactNode;
337
+ }) {
338
+ const { t } = useTranslation("branding");
339
+ const s = store as StoreLike;
340
+ const selected = selectedElementsDeep(store as never).length > 0;
341
+ // 시트(레이어 등)에서 골랐으면 시트는 닫는다. 안 닫으면 띠가 인스펙터로 바뀌어 시트가
342
+ // 숨었다가, ✓ 로 선택을 풀 때 다시 튀어나온다.
343
+ useEffect(() => {
344
+ if (selected && s.openedSidePanel) s.openSidePanel("");
345
+ }, [selected, s]);
346
+
347
+ // 아무것도 안 골랐을 때의 인스펙터(화면 배경·높이·복제/삭제)는 데스크톱에서는 늘
348
+ // 보이는 우측 패널이다. 폰에서는 띠 끝에 탭 하나로 둔다.
349
+ const withPage = useMemo<MobileSection[]>(() => {
350
+ const label = t("detailPage.properties.pageActions");
351
+ return [
352
+ ...sections,
353
+ {
354
+ name: "mobile-page-settings",
355
+ Tab: (props) => (
356
+ <SectionTab name={label} {...props}>
357
+ <Settings2 size={18} />
358
+ </SectionTab>
359
+ ),
360
+ Panel: () => (
361
+ <div data-le-mobile-page="" className="min-h-0 flex-1 overflow-y-auto">
362
+ {inspector}
363
+ </div>
364
+ ),
365
+ },
366
+ ];
367
+ }, [sections, inspector, t]);
368
+
369
+ return (
370
+ <>
371
+ {/* 캔버스 아래 삽입 띠(글상자·도형)는 하단 띠의 텍스트·요소 탭과 겹친다. */}
372
+ <style>
373
+ {"[data-dp-insert-dock], [data-le-mobile-page] [data-le-inspector-header] { display: none; }"}
374
+ </style>
375
+ {selected ? (
376
+ <InspectorBar store={store} inspector={inspector} />
377
+ ) : (
378
+ <SectionBar store={store} sections={withPage} />
379
+ )}
380
+ </>
381
+ );
382
+ });
@@ -92,13 +92,33 @@ export function popoverPlacement(
92
92
  };
93
93
  }
94
94
 
95
- export type QuickPopoverPlacement = ReturnType<typeof popoverPlacement>;
95
+ export type QuickPopoverPlacement = ReturnType<typeof popoverPlacement> & {
96
+ /** 띠 왼쪽 끝이 작업 영역 안에서 놓인 자리와 작업 영역 폭 — 창이 옆으로 넘치지 않게. */
97
+ left: number;
98
+ hostWidth: number;
99
+ };
96
100
 
97
101
  const QuickPopoverContext = createContext<QuickPopoverPlacement>({
98
102
  side: "below",
99
103
  maxHeight: 520,
104
+ left: 0,
105
+ hostWidth: Infinity,
100
106
  });
101
107
 
108
+ /**
109
+ * 창의 폭과 띠 기준 가로 밀기. 폰처럼 좁으면 폭을 줄이고, 오른쪽 끝 요소에서 열어도
110
+ * 작업 영역 안에 들어오게 왼쪽으로 민다(작업 영역이 넘침을 잘라 낸다).
111
+ */
112
+ export function popoverHorizontal(
113
+ width: number,
114
+ left: number,
115
+ hostWidth: number,
116
+ ): { width: number; shift: number } {
117
+ const fit = Math.min(width, Math.max(0, hostWidth - 2 * EDGE));
118
+ const shift = Math.max(EDGE - left, Math.min(0, hostWidth - EDGE - fit - left));
119
+ return { width: fit, shift };
120
+ }
121
+
102
122
  /** 창을 그리는 쪽이 자기 자리를 집는 곳. 띠 밖에서 부르면 기본값(아래)이다. */
103
123
  export function useQuickPopoverPlacement(): QuickPopoverPlacement {
104
124
  return useContext(QuickPopoverContext);
@@ -259,7 +279,9 @@ export function SelectionQuickToolbar({
259
279
  </div>
260
280
  ))}
261
281
  </div>
262
- <QuickPopoverContext.Provider value={placement}>
282
+ <QuickPopoverContext.Provider
283
+ value={{ ...placement, left, hostWidth: host.clientWidth }}
284
+ >
263
285
  {children}
264
286
  </QuickPopoverContext.Provider>
265
287
  </div>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@leviosa-ai/detail-page-editor",
3
- "version": "0.18.0",
3
+ "version": "0.19.0",
4
4
  "description": "Leviosa detail-page editor shell. Source-only: consumers compile it through Next transpilePackages. Every app coupling (sourcing server, brand store, cache keys, toast) arrives through the DetailPageHost interface, and every URL it fetches comes from configureDetailPageEditor.",
5
5
  "type": "module",
6
6
  "license": "SEE LICENSE IN LICENSE",
@@ -68,7 +68,7 @@
68
68
  "woff2-encoder": "2.0.0"
69
69
  },
70
70
  "peerDependencies": {
71
- "@leviosa-ai/canvas": "0.10.0",
71
+ "@leviosa-ai/canvas": "0.11.0",
72
72
  "@tanstack/react-query": "^5.100.3",
73
73
  "konva": "^10.0.0",
74
74
  "lucide-react": "^0.563.0 || ^1.0.0",