diff --git a/package.json b/package.json index 915300b..edbed28 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "lms", - "version": "1.1.31", + "version": "1.1.32", "description": "Autonomy Studio LMS Electron app", "main": "./out/main/index.js", "author": { diff --git a/src/renderer/public/slam-viewer/index.html b/src/renderer/public/slam-viewer/index.html index a6dc9aa..01130e4 100644 --- a/src/renderer/public/slam-viewer/index.html +++ b/src/renderer/public/slam-viewer/index.html @@ -497,9 +497,6 @@ const rosTopicStatusList = document.getElementById('rosTopicStatusList'); if (REAL_RUNTIME) document.body.classList.add('real-runtime'); if (REAL_YAHBOOM_TELEOP && !EDITOR_MODE) { document.body.classList.add('real-yahboom-teleop'); - const hint = document.createElement('div'); - hint.textContent = '키를 누르는 동안 이동 · 놓으면 정지'; - document.getElementById('keyGuide')?.appendChild(hint); } if (EDITOR_MODE) responseWaitOverlay?.classList.add('is-hidden'); diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index df9e81b..c14ab98 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -3425,6 +3425,7 @@ function LocalPcWorkspaceMockup({
{cameraOpen && workspaceVisible && setCameraOpen(false)} diff --git a/src/renderer/src/features/curriculum/types.ts b/src/renderer/src/features/curriculum/types.ts index 495a15b..644c340 100644 --- a/src/renderer/src/features/curriculum/types.ts +++ b/src/renderer/src/features/curriculum/types.ts @@ -41,7 +41,7 @@ export type CurriculumWeek = { duration: string github: string detail: { - camera?: { url: string; password: string } | null + camera?: { url: string } | null overview: string learningSteps?: LearningStep[] topicDetails: string[] diff --git a/src/renderer/src/features/workspace/WorkspaceCameraPanel.tsx b/src/renderer/src/features/workspace/WorkspaceCameraPanel.tsx index 84fb914..d69b71b 100644 --- a/src/renderer/src/features/workspace/WorkspaceCameraPanel.tsx +++ b/src/renderer/src/features/workspace/WorkspaceCameraPanel.tsx @@ -1,27 +1,28 @@ import { Camera, CirclePlay, Maximize2, Minimize2, Square, X } from 'lucide-react' -import { useEffect, useRef, useState } from 'react' +import { useEffect, useRef, useState, type RefObject } from 'react' import { createPortal } from 'react-dom' import { useWorkspaceWindowFocus } from './useWorkspaceWindowFocus' -export function cameraViewerUrl(camera: { url: string; password: string }): string { +export function cameraViewerUrl(camera: { url: string }): string { const input = new URL(camera.url) if (input.protocol !== 'https:' || input.hostname !== 'vdo.ninja' || input.port || input.username || input.password || input.pathname !== '/') throw new Error('카메라 시청 URL이 올바르지 않습니다.') const room = input.searchParams.get('room') const view = input.searchParams.get('view') - if (!room?.trim() || !view?.trim()) throw new Error('카메라 시청 URL에 room과 view가 필요합니다.') + if (!room?.trim() || (!view?.trim() && !input.searchParams.has('scene'))) throw new Error('카메라 시청 URL에 room과 scene 또는 view가 필요합니다.') const url = new URL('https://vdo.ninja/') url.searchParams.set('room', room) - url.searchParams.set('view', view) - url.searchParams.set('scene', '') - if (camera.password) url.searchParams.set('password', camera.password) + if (view?.trim()) url.searchParams.set('view', view) + url.searchParams.set('scene', input.searchParams.get('scene') ?? '') + if (input.searchParams.has('password')) url.searchParams.set('password', input.searchParams.get('password')!) return url.toString() } -export default function WorkspaceCameraPanel({ camera, dark, onClose }: { - camera?: { url: string; password: string } | null +export default function WorkspaceCameraPanel({ camera, dark, onClose, containerRef }: { + containerRef: RefObject + camera?: { url: string } | null dark: boolean onClose: () => void -}): React.JSX.Element { +}): React.JSX.Element | null { const [viewerUrl, setViewerUrl] = useState('') const { frameRef, bringToFront, zIndex } = useWorkspaceWindowFocus('overview') const [loading, setLoading] = useState(false) @@ -29,12 +30,36 @@ export default function WorkspaceCameraPanel({ camera, dark, onClose }: { const [large, setLarge] = useState(false) const [position, setPosition] = useState({ x: 32, y: 90 }) const panelRef = useRef(null) + const [container, setContainer] = useState(null) + const [bounds, setBounds] = useState({ width: 0, height: 0 }) const drag = useRef<{ x: number; y: number; left: number; top: number } | null>(null) + useEffect(() => { + const element = containerRef.current + if (!element) return + setContainer(element) + const update = (): void => { + const width = element.clientWidth + const height = element.clientHeight + setBounds({ width, height }) + setPosition((current) => { + const panelWidth = Math.min(panelRef.current?.offsetWidth ?? 660, Math.max(0, width - 24)) + const panelHeight = Math.min(panelRef.current?.offsetHeight ?? 420, Math.max(0, height - 60)) + return { + x: Math.max(12, Math.min(width - panelWidth - 12, current.x)), + y: Math.max(48, Math.min(height - panelHeight - 12, current.y)) + } + }) + } + update() + const observer = new ResizeObserver(update) + observer.observe(element) + return () => observer.disconnect() + }, [containerRef]) useEffect(() => { setViewerUrl('') setLoading(false) setError('') - }, [camera?.url, camera?.password]) + }, [camera?.url]) const start = (): void => { try { if (!camera?.url) throw new Error('등록된 주행장 카메라가 없습니다.') @@ -46,15 +71,18 @@ export default function WorkspaceCameraPanel({ camera, dark, onClose }: { } } const buttonClass = 'grid h-7 w-7 shrink-0 cursor-pointer place-items-center rounded hover:bg-gray-500/20' + if (!container) return null + const maxWidth = Math.max(0, bounds.width - position.x - 12) + const maxHeight = Math.max(0, bounds.height - position.y - 12) return createPortal(
+ className={['absolute flex flex-col overflow-hidden rounded-md border shadow-xl', dark ? 'border-white/20 bg-[#242424] text-white' : 'border-gray-300 bg-white text-gray-800'].join(' ')} + style={large ? { left: 0, right: 0, top: 40, bottom: 0, zIndex } : { zIndex, left: position.x, top: position.y, width: 660, height: 420, minWidth: Math.min(280, maxWidth), minHeight: Math.min(220, maxHeight), maxWidth, maxHeight, resize: 'both' }}>
{ - if (large || (event.target as HTMLElement).closest('button')) return + if (large || event.button !== 0 || (event.target as HTMLElement).closest('button')) return drag.current = { x: event.clientX, y: event.clientY, left: position.x, top: position.y } event.currentTarget.setPointerCapture(event.pointerId) }} @@ -62,7 +90,7 @@ export default function WorkspaceCameraPanel({ camera, dark, onClose }: { if (!drag.current) return const width = panelRef.current?.offsetWidth ?? 660 const height = panelRef.current?.offsetHeight ?? 420 - setPosition({ x: Math.max(0, Math.min(window.innerWidth - width, drag.current.left + event.clientX - drag.current.x)), y: Math.max(0, Math.min(window.innerHeight - height, drag.current.top + event.clientY - drag.current.y)) }) + setPosition({ x: Math.max(12, Math.min(container.clientWidth - width - 12, drag.current.left + event.clientX - drag.current.x)), y: Math.max(48, Math.min(container.clientHeight - height - 12, drag.current.top + event.clientY - drag.current.y)) }) }} onPointerUp={() => { drag.current = null }} onLostPointerCapture={() => { drag.current = null }}> 오버뷰 @@ -79,10 +107,11 @@ export default function WorkspaceCameraPanel({ camera, dark, onClose }: { :
+

주행장을 외부에서 촬영하는 영상입니다.

{error &&

{error}

} {!camera?.url &&

등록된 주행장 카메라가 없습니다.

}
}
-
, document.body) +
, container) }