From 3b9724beb337cad32119ac49f71ee4c17d6fc707 Mon Sep 17 00:00:00 2001 From: iqudoo Date: Sat, 13 Jun 2026 03:41:20 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E5=A4=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/App.tsx | 176 +++++++++++------------ src/components/AnchoredMenu.tsx | 110 +++++++++++++++ src/components/ElementFloatPreview.tsx | 73 ++++++++++ src/components/LabelDesigner.tsx | 187 +++++++++++++++++++++---- src/components/MobileDesignView.tsx | 9 +- src/components/PropSidebar.tsx | 124 +++++++++++++++- src/index.css | 84 +++++++++++ 7 files changed, 634 insertions(+), 129 deletions(-) create mode 100644 src/components/AnchoredMenu.tsx create mode 100644 src/components/ElementFloatPreview.tsx diff --git a/src/App.tsx b/src/App.tsx index 69ef157..4e3fa75 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -25,6 +25,7 @@ import { exportLabelsPdfAsync, type PdfExportProgress } from './pdfExport'; import { useIsMobile } from './hooks/useIsMobile'; import { useUndoRedo } from './hooks/useUndoRedo'; import { useAppDialog } from './components/AppDialog'; +import { AnchoredMenu } from './components/AnchoredMenu'; import { FileDown, Code, @@ -153,7 +154,7 @@ export default function App() { const [editingTemplateId, setEditingTemplateId] = useState(null); const [editTmplName, setEditTmplName] = useState(''); const [templateMenuId, setTemplateMenuId] = useState(null); - const templateMenuRef = useRef(null); + const templateMenuAnchorRef = useRef(null); const [headerTemplateMenuOpen, setHeaderTemplateMenuOpen] = useState(false); const headerTemplateMenuRef = useRef(null); const importTemplateInputRef = useRef(null); @@ -390,26 +391,6 @@ export default function App() { const closeTemplateMenu = () => setTemplateMenuId(null); - useEffect(() => { - if (!templateMenuId) return; - const handlePointerDown = (e: PointerEvent) => { - if (templateMenuRef.current?.contains(e.target as Node)) return; - closeTemplateMenu(); - }; - document.addEventListener('pointerdown', handlePointerDown); - return () => document.removeEventListener('pointerdown', handlePointerDown); - }, [templateMenuId]); - - useEffect(() => { - if (!headerTemplateMenuOpen) return; - const handlePointerDown = (e: PointerEvent) => { - if (headerTemplateMenuRef.current?.contains(e.target as Node)) return; - setHeaderTemplateMenuOpen(false); - }; - document.addEventListener('pointerdown', handlePointerDown); - return () => document.removeEventListener('pointerdown', handlePointerDown); - }, [headerTemplateMenuOpen]); - const handleEditTemplateSubmit = async (e: React.FormEvent) => { e.preventDefault(); if (!editingTemplateId) return; @@ -841,37 +822,37 @@ export default function App() { > - {headerTemplateMenuOpen && ( -
setHeaderTemplateMenuOpen(false)} + anchorRef={headerTemplateMenuRef} + className="min-w-[148px] py-1 bg-white border border-gray-200 rounded-xl shadow-lg" + > + - -
- )} + + 新建模板 + + + ) : ( @@ -1067,7 +1048,7 @@ export default function App() { {isMobile ? '生成' : '排版导出'}
- {templateMenuId === tmpl.id && ( -
e.stopPropagation()} + + - - -
- )} + + 编辑 + + + +
diff --git a/src/components/AnchoredMenu.tsx b/src/components/AnchoredMenu.tsx new file mode 100644 index 0000000..74232fd --- /dev/null +++ b/src/components/AnchoredMenu.tsx @@ -0,0 +1,110 @@ +import React, { useLayoutEffect, useEffect, useRef, useState } from 'react'; +import { createPortal } from 'react-dom'; + +const MENU_GAP = 4; +const VIEWPORT_PAD = 8; + +function useAnchoredMenuPosition( + open: boolean, + anchorRef: React.RefObject, + menuRef: React.RefObject +) { + const [coords, setCoords] = useState<{ top: number; left: number } | null>(null); + + useLayoutEffect(() => { + if (!open) { + setCoords(null); + return; + } + + const update = () => { + const anchor = anchorRef.current; + const menu = menuRef.current; + if (!anchor || !menu) return; + + const anchorRect = anchor.getBoundingClientRect(); + const menuWidth = menu.offsetWidth; + const menuHeight = menu.offsetHeight; + + let top = anchorRect.bottom + MENU_GAP; + if (top + menuHeight > window.innerHeight - VIEWPORT_PAD) { + top = anchorRect.top - menuHeight - MENU_GAP; + } + top = Math.max( + VIEWPORT_PAD, + Math.min(top, window.innerHeight - menuHeight - VIEWPORT_PAD) + ); + + let left = anchorRect.right - menuWidth; + left = Math.max( + VIEWPORT_PAD, + Math.min(left, window.innerWidth - menuWidth - VIEWPORT_PAD) + ); + + setCoords({ top, left }); + }; + + update(); + const raf = requestAnimationFrame(update); + window.addEventListener('resize', update); + window.addEventListener('scroll', update, true); + return () => { + cancelAnimationFrame(raf); + window.removeEventListener('resize', update); + window.removeEventListener('scroll', update, true); + }; + }, [open, anchorRef, menuRef]); + + return coords; +} + +interface AnchoredMenuProps { + open: boolean; + onClose: () => void; + anchorRef: React.RefObject; + children: React.ReactNode; + className?: string; +} + +export const AnchoredMenu: React.FC = ({ + open, + onClose, + anchorRef, + children, + className = '', +}) => { + const menuRef = useRef(null); + const coords = useAnchoredMenuPosition(open, anchorRef, menuRef); + + useEffect(() => { + if (!open) return; + const handlePointerDown = (e: PointerEvent) => { + const target = e.target as Node; + if (menuRef.current?.contains(target) || anchorRef.current?.contains(target)) return; + onClose(); + }; + document.addEventListener('pointerdown', handlePointerDown); + return () => document.removeEventListener('pointerdown', handlePointerDown); + }, [open, onClose, anchorRef]); + + if (!open) return null; + + return createPortal( +
e.stopPropagation()} + > + {children} +
, + document.body + ); +}; diff --git a/src/components/ElementFloatPreview.tsx b/src/components/ElementFloatPreview.tsx new file mode 100644 index 0000000..cb8f675 --- /dev/null +++ b/src/components/ElementFloatPreview.tsx @@ -0,0 +1,73 @@ +import React, { useState, useEffect, useMemo } from 'react'; +import { TemplateElement } from '../types'; +import { mmToPx } from '../utils'; +import { renderLabelToDataUrl } from '../labelRenderer'; + +interface ElementFloatPreviewProps { + element: TemplateElement; + renderScale: number; + displayZoom: number; + variableDefaults?: Record | null; +} + +/** 单元素离屏绘制(不裁切到标签页边界),用于拖动/缩放浮层 */ +export const ElementFloatPreview: React.FC = ({ + element, + renderScale, + displayZoom, + variableDefaults = null, +}) => { + const [dataUrl, setDataUrl] = useState(''); + + const elementKey = useMemo(() => JSON.stringify(element), [element]); + + useEffect(() => { + let active = true; + const normalized = { ...element, x: 0, y: 0 }; + + renderLabelToDataUrl({ + widthMm: element.width, + heightMm: element.height, + elements: [normalized], + rowData: null, + rowIndex: 0, + showBorder: false, + transparentBackground: true, + variableDefaults, + mode: 'design', + scale: renderScale, + }) + .then((url) => { + if (active) setDataUrl(url); + }) + .catch((err) => { + console.error('Element float preview error:', err); + }); + + return () => { + active = false; + }; + }, [element, elementKey, renderScale, variableDefaults]); + + const left = mmToPx(element.x, displayZoom); + const top = mmToPx(element.y, displayZoom); + const width = mmToPx(element.width, displayZoom); + const height = mmToPx(element.height, displayZoom); + + if (!dataUrl) return null; + + return ( + + ); +}; diff --git a/src/components/LabelDesigner.tsx b/src/components/LabelDesigner.tsx index 0de1c95..637670a 100644 --- a/src/components/LabelDesigner.tsx +++ b/src/components/LabelDesigner.tsx @@ -3,6 +3,7 @@ import { LabelTemplate, TemplateElement } from '../types'; import { mmToPx, MM_TO_PX, shouldRenderElement } from '../utils'; import { useIsNarrowScreen } from '../hooks/useIsMobile'; import { CanvasLabelImage } from './CanvasLabelImage'; +import { ElementFloatPreview } from './ElementFloatPreview'; import { useAppDialog } from './AppDialog'; import { ZoomIn, @@ -21,6 +22,8 @@ interface LabelDesignerProps { selectedElementIds: string[]; onSelectElements: (ids: string[]) => void; variant?: 'desktop' | 'mobile'; + /** 微调等场景下隐藏选区框与缩放手柄 */ + suppressSelectionChrome?: boolean; } type ActiveTool = 'move' | 'text' | 'barcode' | 'qrcode'; @@ -187,6 +190,7 @@ export const LabelDesigner: React.FC = ({ selectedElementIds, onSelectElements, variant = 'desktop', + suppressSelectionChrome = false, }) => { const { showConfirm } = useAppDialog(); const selectedElementId = selectedElementIds[0] || null; @@ -204,12 +208,20 @@ export const LabelDesigner: React.FC = ({ [template.variableDefaults] ); - const [localElements, setLocalElements] = useState(null); const localElementsRef = useRef(null); - /** 拖拽/缩放过程中冻结底图合成,仅更新选区框位置,避免每帧重绘闪动 */ - const [frozenCanvasElements, setFrozenCanvasElements] = useState(null); + /** 交互开始时的元素快照:拖动底图分层、缩放节流重绘 */ + const [interactionSnapshot, setInteractionSnapshot] = useState( + null + ); + /** 缩放时仅重绘当前元素的浮层内容 */ + const [interactionFloatElements, setInteractionFloatElements] = useState< + TemplateElement[] | null + >(null); const dragRafRef = useRef(null); const pendingDragElementsRef = useRef(null); + const dragFloatLayerRef = useRef(null); + const interactionFloatPaintRef = useRef(0); + const interactionFloatTimerRef = useRef | null>(null); const [dragState, setDragState] = useState<{ elementId: string; @@ -466,6 +478,19 @@ export const LabelDesigner: React.FC = ({ deleteSelectedElements, ]); + const clearInteractionCanvasState = useCallback(() => { + if (interactionFloatTimerRef.current !== null) { + clearTimeout(interactionFloatTimerRef.current); + interactionFloatTimerRef.current = null; + } + interactionFloatPaintRef.current = 0; + if (dragFloatLayerRef.current) { + dragFloatLayerRef.current.style.transform = ''; + } + setInteractionSnapshot(null); + setInteractionFloatElements(null); + }, []); + const cancelActiveInteraction = useCallback(() => { if (dragRafRef.current !== null) { cancelAnimationFrame(dragRafRef.current); @@ -473,12 +498,11 @@ export const LabelDesigner: React.FC = ({ } pendingDragElementsRef.current = null; localElementsRef.current = null; - setLocalElements(null); - setFrozenCanvasElements(null); + clearInteractionCanvasState(); setDragState(null); dragMovedRef.current = false; setMarqueeState(null); - }, []); + }, [clearInteractionCanvasState]); const shouldIgnoreTouchPointer = (e: { pointerType: string; isPrimary: boolean }) => { if (multiTouchActiveRef.current) return true; @@ -720,12 +744,14 @@ export const LabelDesigner: React.FC = ({ }; }); pendingDragElementsRef.current = updated; + if (dragFloatLayerRef.current) { + dragFloatLayerRef.current.style.transform = `translate(${deltaX}px, ${deltaY}px)`; + } if (dragRafRef.current === null) { dragRafRef.current = requestAnimationFrame(() => { dragRafRef.current = null; const next = pendingDragElementsRef.current; if (next) { - setLocalElements(next); localElementsRef.current = next; } }); @@ -763,13 +789,32 @@ export const LabelDesigner: React.FC = ({ : el ); pendingDragElementsRef.current = updated; + + const paintInteractionFloat = () => { + interactionFloatPaintRef.current = performance.now(); + interactionFloatTimerRef.current = null; + const pending = pendingDragElementsRef.current?.find( + (el) => el.id === dragState.elementId + ); + if (pending) setInteractionFloatElements([pending]); + }; + if (dragRafRef.current === null) { dragRafRef.current = requestAnimationFrame(() => { dragRafRef.current = null; const next = pendingDragElementsRef.current; if (next) { - setLocalElements(next); localElementsRef.current = next; + const now = performance.now(); + const elapsed = now - interactionFloatPaintRef.current; + if (elapsed >= 66 || interactionFloatPaintRef.current === 0) { + paintInteractionFloat(); + } else if (interactionFloatTimerRef.current === null) { + interactionFloatTimerRef.current = setTimeout( + paintInteractionFloat, + 66 - elapsed + ); + } } }); } @@ -797,8 +842,7 @@ export const LabelDesigner: React.FC = ({ } } localElementsRef.current = null; - setLocalElements(null); - setFrozenCanvasElements(null); + clearInteractionCanvasState(); setDragState(null); }; @@ -810,7 +854,7 @@ export const LabelDesigner: React.FC = ({ window.removeEventListener('pointerup', handlePointerUp); window.removeEventListener('pointercancel', handlePointerUp); }; - }, [dragState, template, displayZoom, onChange, cancelActiveInteraction]); + }, [dragState, template, displayZoom, onChange, cancelActiveInteraction, clearInteractionCanvasState]); const buildGroupStartPositions = (element: TemplateElement) => { const moveIds = @@ -910,7 +954,9 @@ export const LabelDesigner: React.FC = ({ if (element.locked) return; e.currentTarget.setPointerCapture(e.pointerId); - setFrozenCanvasElements(template.elements); + setInteractionSnapshot(template.elements); + setInteractionFloatElements(null); + interactionFloatPaintRef.current = 0; setDragState({ elementId: element.id, type: actionType, @@ -936,7 +982,9 @@ export const LabelDesigner: React.FC = ({ if (element.locked) return; e.currentTarget.setPointerCapture(e.pointerId); - setFrozenCanvasElements(template.elements); + setInteractionSnapshot(template.elements); + setInteractionFloatElements([element]); + interactionFloatPaintRef.current = 0; setDragState({ elementId: element.id, type: 'resize', @@ -968,9 +1016,7 @@ export const LabelDesigner: React.FC = ({ return () => window.clearTimeout(timer); }, [isMobileLayout, template.width, template.height]); - const selectedElemObj = (localElements || template.elements).find( - (el) => el.id === selectedElementId - ); + const selectedElemObj = template.elements.find((el) => el.id === selectedElementId); const filterVisibleElements = useCallback( (elements: TemplateElement[]) => @@ -980,10 +1026,51 @@ export const LabelDesigner: React.FC = ({ [previewDefaults] ); - const canvasPreviewElements = filterVisibleElements( - frozenCanvasElements ?? template.elements - ); - const overlayElements = filterVisibleElements(localElements ?? template.elements); + const dragMovingIdSet = useMemo(() => { + if (dragState?.type !== 'drag' || !dragState.groupStartPositions) return null; + return new Set(Object.keys(dragState.groupStartPositions)); + }, [dragState]); + + const staticCanvasElements = useMemo(() => { + if (dragState?.type === 'drag' && interactionSnapshot && dragMovingIdSet) { + return filterVisibleElements( + interactionSnapshot.filter((el) => !dragMovingIdSet.has(el.id)) + ); + } + if (dragState?.type === 'resize' && interactionSnapshot) { + return filterVisibleElements( + interactionSnapshot.filter((el) => el.id !== dragState.elementId) + ); + } + return filterVisibleElements(template.elements); + }, [ + dragState?.type, + dragState?.elementId, + interactionSnapshot, + dragMovingIdSet, + template.elements, + filterVisibleElements, + ]); + + const dragFloatElements = useMemo(() => { + if (dragState?.type !== 'drag' || !interactionSnapshot || !dragMovingIdSet) { + return null; + } + const moving = interactionSnapshot.filter((el) => dragMovingIdSet.has(el.id)); + return moving.length > 0 ? filterVisibleElements(moving) : null; + }, [dragState?.type, interactionSnapshot, dragMovingIdSet, filterVisibleElements]); + + const resizeFloatElements = useMemo(() => { + if (dragState?.type !== 'resize' || !interactionFloatElements?.length) return null; + return filterVisibleElements(interactionFloatElements); + }, [dragState?.type, interactionFloatElements, filterVisibleElements]); + + const interactionFloatCanvasElements = dragFloatElements ?? resizeFloatElements; + const allowInteractionOverflow = dragState !== null; + + const hideSelectionChrome = suppressSelectionChrome || dragState !== null; + + const overlayElements = filterVisibleElements(template.elements); const tools: { id: ActiveTool; icon: React.ReactNode; label: string; shortcut?: string }[] = [ { id: 'move', icon: , label: '移动工具', shortcut: 'V' }, @@ -1069,9 +1156,12 @@ export const LabelDesigner: React.FC = ({
{!isMobileLayout && (
@@ -1084,7 +1174,9 @@ export const LabelDesigner: React.FC = ({
{ if (ignoreCanvasClickRef.current) { @@ -1097,12 +1189,16 @@ export const LabelDesigner: React.FC = ({ }} >
+
{/*
{template.width} mm @@ -1119,7 +1215,7 @@ export const LabelDesigner: React.FC = ({ = ({ const isLocked = !!element.locked; const isSelected = !isLocked && selectedElementIds.includes(element.id); const isPrimary = selectedElementIds.indexOf(element.id) === 0; + const isBeingDragged = + dragState?.type === 'drag' && !!dragMovingIdSet?.has(element.id); + const isBeingResized = + dragState?.type === 'resize' && dragState.elementId === element.id; + if (isBeingDragged || isBeingResized) { + return null; + } + + const showSelectionRing = isSelected && !hideSelectionChrome; + const showResizeHandles = + showSelectionRing && selectedElementIds.length === 1; const elemX = mmToPx(element.x, displayZoom); const elemY = mmToPx(element.y, displayZoom); const elemW = mmToPx(element.width, displayZoom); @@ -1164,8 +1271,10 @@ export const LabelDesigner: React.FC = ({ className={`absolute group touch-none ${ isLocked ? 'pointer-events-none' - : isSelected + : showSelectionRing ? 'ps-selection-ring cursor-move' + : isSelected + ? 'cursor-move' : 'cursor-move hover:outline hover:outline-1 hover:outline-[#31a8ff]/40' }`} style={{ @@ -1192,7 +1301,7 @@ export const LabelDesigner: React.FC = ({
)} - {isSelected && ( + {showResizeHandles && ( <>
= ({ ); })}
+ + {interactionFloatCanvasElements && interactionFloatCanvasElements.length > 0 && ( +
+ {interactionFloatCanvasElements.map((el) => ( + + ))} +
+ )} +
{isMobileLayout ? ( diff --git a/src/components/MobileDesignView.tsx b/src/components/MobileDesignView.tsx index e9c042b..9e34bbe 100644 --- a/src/components/MobileDesignView.tsx +++ b/src/components/MobileDesignView.tsx @@ -1,8 +1,8 @@ -import React from 'react'; +import React, { useState } from 'react'; import { ChevronLeft, Printer, Undo2, Redo2 } from 'lucide-react'; import { LabelTemplate, PaperConfig } from '../types'; import { LabelDesigner } from './LabelDesigner'; -import { PropSidebar } from './PropSidebar'; +import { PropSidebar, MobilePropModule } from './PropSidebar'; interface MobileDesignViewProps { template: LabelTemplate; @@ -31,6 +31,8 @@ export const MobileDesignView: React.FC = ({ onBack, onExport, }) => { + const [mobileModule, setMobileModule] = useState('properties'); + return (
@@ -80,6 +82,7 @@ export const MobileDesignView: React.FC = ({ onChange={onChangeTemplate} selectedElementIds={selectedElementIds} onSelectElements={onSelectElements} + suppressSelectionChrome={mobileModule === 'nudge'} />
@@ -93,6 +96,8 @@ export const MobileDesignView: React.FC = ({ activeTab="element" onChangeTab={() => {}} paper={paper} + mobileModule={mobileModule} + onMobileModuleChange={setMobileModule} />
diff --git a/src/components/PropSidebar.tsx b/src/components/PropSidebar.tsx index 963f078..c6f969d 100644 --- a/src/components/PropSidebar.tsx +++ b/src/components/PropSidebar.tsx @@ -37,6 +37,9 @@ import { Unlock, ArrowUp, ArrowDown, + ArrowLeft, + ArrowRight, + Move, Layers, Trash2, AlignHorizontalJustifyStart, @@ -148,7 +151,9 @@ const PropTextarea: React.FC = ({ value, onCommit, ...props } ); }; -type MobilePropModule = 'content' | 'properties' | 'layers'; +export type MobilePropModule = 'content' | 'properties' | 'layers' | 'nudge'; + +const MOBILE_NUDGE_STEP_MM = 0.5; interface PropSidebarProps { template: LabelTemplate; @@ -159,10 +164,13 @@ interface PropSidebarProps { paper: PaperConfig; onSelectElements?: (ids: string[]) => void; variant?: 'desktop' | 'mobile'; + mobileModule?: MobilePropModule; + onMobileModuleChange?: (module: MobilePropModule) => void; } const MOBILE_PROP_MODULES: { id: MobilePropModule; label: string; icon: React.ReactNode }[] = [ { id: 'properties', label: '属性', icon: }, + { id: 'nudge', label: '微调', icon: }, { id: 'content', label: '内容', icon: }, { id: 'layers', label: '图层', icon: }, ]; @@ -176,6 +184,8 @@ export const PropSidebar: React.FC = ({ paper, onSelectElements, variant = 'desktop', + mobileModule: mobileModuleProp, + onMobileModuleChange, }) => { const { showConfirm } = useAppDialog(); const isNarrowScreen = useIsNarrowScreen(); @@ -186,7 +196,15 @@ export const PropSidebar: React.FC = ({ { mode: 'add' } | { mode: 'edit'; index: number } | null >(null); const [panelCollapsed, setPanelCollapsed] = useState(loadPanelCollapseState); - const [mobileModule, setMobileModule] = useState('properties'); + const [internalMobileModule, setInternalMobileModule] = + useState('properties'); + const mobileModule = mobileModuleProp ?? internalMobileModule; + const setMobileModule = (module: MobilePropModule) => { + onMobileModuleChange?.(module); + if (mobileModuleProp === undefined) { + setInternalMobileModule(module); + } + }; const togglePanel = (key: keyof PanelCollapseState) => { setPanelCollapsed((prev) => { @@ -242,6 +260,29 @@ export const PropSidebar: React.FC = ({ }); }; + const nudgeableElementIds = useMemo(() => { + const idSet = new Set(selectedElementIds); + return template.elements + .filter((el) => idSet.has(el.id) && !el.locked) + .map((el) => el.id); + }, [selectedElementIds, template.elements]); + + const nudgeSelectedElements = (deltaX: number, deltaY: number) => { + if (nudgeableElementIds.length === 0) return; + const idSet = new Set(nudgeableElementIds); + onChangeTemplate({ + ...template, + elements: template.elements.map((el) => { + if (!idSet.has(el.id)) return el; + return { + ...el, + x: Math.round((el.x + deltaX) * 10) / 10, + y: Math.round((el.y + deltaY) * 10) / 10, + }; + }), + }); + }; + const applyVisibilityRules = (next: VisibilityRule[]) => { if (!selectedElem) return; handleElemChange({ @@ -1841,6 +1882,82 @@ export const PropSidebar: React.FC = ({ ); + const renderNudgePanelBody = () => { + const canNudge = nudgeableElementIds.length > 0; + const primaryElem = + selectedElem && nudgeableElementIds.includes(selectedElem.id) ? selectedElem : null; + + return ( +
+
+

微调位置

+

每次移动 {MOBILE_NUDGE_STEP_MM} mm,与桌面方向键一致

+
+ + {!canNudge ? ( +
+ +

请先选中未锁定的元素

+
+ ) : ( + <> +
+ +
+ + + +
+ + {primaryElem && ( +
+

{primaryElem.name}

+

+ X {primaryElem.x} · Y {primaryElem.y} mm +

+ {nudgeableElementIds.length > 1 && ( +

+ 已选 {nudgeableElementIds.length} 个元素,将一起移动 +

+ )} +
+ )} + + )} +
+ ); + }; + const visibilityRuleDialogNode = ( = ({ {mobileModule === 'properties' && (
{renderPropertiesPanelBody()}
)} + {mobileModule === 'nudge' && ( +
{renderNudgePanelBody()}
+ )} {mobileModule === 'layers' && (
{renderLayersPanelBody()}
)} diff --git a/src/index.css b/src/index.css index f05a6da..013b7d6 100644 --- a/src/index.css +++ b/src/index.css @@ -1317,6 +1317,11 @@ body { min-height: 0; } + .mobile-design-canvas-wrap:has([data-interaction-active]), + .mobile-design-canvas-shell-interaction { + overflow: visible; + } + .mobile-design-props-wrap { flex: 1 1 48%; min-height: 220px; @@ -1416,6 +1421,85 @@ body { min-height: 40px !important; flex-shrink: 0; } + + .mobile-nudge-panel { + display: flex; + flex-direction: column; + align-items: stretch; + min-height: 100%; + padding-bottom: 12px; + } + + .mobile-nudge-header { + width: 100%; + text-align: left; + } + + .mobile-nudge-empty { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + flex: 1; + min-height: 160px; + text-align: center; + } + + .mobile-nudge-pad { + display: flex; + flex-direction: column; + align-items: center; + align-self: center; + gap: 8px; + margin: 8px 0 20px; + } + + .mobile-nudge-pad-row { + display: flex; + align-items: center; + gap: 8px; + } + + .mobile-nudge-pad-center { + width: 52px; + height: 52px; + border-radius: 12px; + background: #333; + border: 1px dashed #4a4a4a; + } + + .mobile-nudge-btn { + display: flex; + align-items: center; + justify-content: center; + width: 52px; + height: 52px; + border: 1px solid #4a4a4a; + border-radius: 12px; + background: #383838; + color: #ddd; + cursor: pointer; + touch-action: manipulation; + -webkit-tap-highlight-color: transparent; + transition: background 0.15s, border-color 0.15s, color 0.15s; + } + + .mobile-nudge-btn:active { + background: #1e3a4f; + border-color: #31a8ff; + color: #fff; + } + + .mobile-nudge-info { + align-self: center; + width: 100%; + max-width: 240px; + padding: 12px 14px; + border-radius: 10px; + background: #222222; + /* border: 1px solid #1f1f1f; */ + text-align: center; + } } @media (min-width: 768px) {