diff --git a/src/renderer/App.tsx b/src/renderer/App.tsx index ee6f013..3ccf1ab 100644 --- a/src/renderer/App.tsx +++ b/src/renderer/App.tsx @@ -4,6 +4,42 @@ import SettingsPanel from './components/SettingsPanel' import PDFPanel from './components/PDFPanel' import OutputPanel from './components/OutputPanel' +const SIDEBAR_WIDTH_KEY = 'paper2corecode.sidebarWidth' +const UPLOAD_HEIGHT_KEY = 'paper2corecode.uploadHeight' +const SIDEBAR_DEFAULT_WIDTH = 280 +const SIDEBAR_MIN_WIDTH = 220 +const SIDEBAR_MAX_WIDTH = 420 +const MAIN_MIN_WIDTH = 520 +const UPLOAD_DEFAULT_HEIGHT = 260 +const UPLOAD_MIN_HEIGHT = 230 +const UPLOAD_MAX_HEIGHT = 420 +const RESULT_MIN_HEIGHT = 260 +const RESIZE_HANDLE_SIZE = 16 + +function clamp(value: number, min: number, max: number): number { + if (max < min) return min + return Math.min(Math.max(value, min), max) +} + +function readStoredNumber(key: string, fallback: number): number { + let stored: string | null = null + try { + stored = window.localStorage.getItem(key) + } catch { + return fallback + } + + if (!stored) return fallback + + const parsed = Number(stored) + return Number.isFinite(parsed) ? parsed : fallback +} + +function getContentWidth(element: HTMLElement): number { + const styles = window.getComputedStyle(element) + return element.clientWidth - parseFloat(styles.paddingLeft) - parseFloat(styles.paddingRight) +} + export default function App() { const [pdfPath, setPdfPath] = useState(null) const [analyzing, setAnalyzing] = useState(false) @@ -19,7 +55,19 @@ export default function App() { const [apiKeyConfigured, setApiKeyConfigured] = useState(false) const [language, setLanguage] = useState('zh-CN') const [showCompletionDialog, setShowCompletionDialog] = useState(false) + const [sidebarWidth, setSidebarWidth] = useState(() => clamp( + readStoredNumber(SIDEBAR_WIDTH_KEY, SIDEBAR_DEFAULT_WIDTH), + SIDEBAR_MIN_WIDTH, + SIDEBAR_MAX_WIDTH + )) + const [uploadHeight, setUploadHeight] = useState(() => clamp( + readStoredNumber(UPLOAD_HEIGHT_KEY, UPLOAD_DEFAULT_HEIGHT), + UPLOAD_MIN_HEIGHT, + UPLOAD_MAX_HEIGHT + )) const cleanupRef = useRef<(() => void) | null>(null) + const appBodyRef = useRef(null) + const mainContentRef = useRef(null) useEffect(() => { window.electronAPI.getSettings().then((s) => { @@ -42,6 +90,33 @@ export default function App() { return () => window.clearInterval(timer) }, [startedAt, finishedAt]) + useEffect(() => { + const clampLayoutToViewport = () => { + const appBody = appBodyRef.current + if (appBody) { + const maxSidebarWidth = Math.max( + SIDEBAR_MIN_WIDTH, + Math.min(SIDEBAR_MAX_WIDTH, getContentWidth(appBody) - RESIZE_HANDLE_SIZE - MAIN_MIN_WIDTH) + ) + setSidebarWidth((width) => clamp(width, SIDEBAR_MIN_WIDTH, maxSidebarWidth)) + } + + const mainContent = mainContentRef.current + if (mainContent) { + const maxUploadHeight = Math.max( + UPLOAD_MIN_HEIGHT, + Math.min(UPLOAD_MAX_HEIGHT, mainContent.clientHeight - RESIZE_HANDLE_SIZE - RESULT_MIN_HEIGHT) + ) + setUploadHeight((height) => clamp(height, UPLOAD_MIN_HEIGHT, maxUploadHeight)) + } + } + + clampLayoutToViewport() + window.addEventListener('resize', clampLayoutToViewport) + + return () => window.removeEventListener('resize', clampLayoutToViewport) + }, []) + const resetAnalysisMeta = () => { setAnalysisStatus('idle') setElapsedTime(0) @@ -158,6 +233,78 @@ export default function App() { await window.electronAPI.saveSettings({ language: lang }) } + const handleSidebarResizeStart = (event: React.PointerEvent) => { + const appBody = appBodyRef.current + if (!appBody) return + + event.preventDefault() + const startX = event.clientX + const startWidth = sidebarWidth + const maxSidebarWidth = Math.max( + SIDEBAR_MIN_WIDTH, + Math.min(SIDEBAR_MAX_WIDTH, getContentWidth(appBody) - RESIZE_HANDLE_SIZE - MAIN_MIN_WIDTH) + ) + + document.body.classList.add('is-resizing-layout', 'is-resizing-column') + + const handlePointerMove = (moveEvent: PointerEvent) => { + const nextWidth = clamp(startWidth + moveEvent.clientX - startX, SIDEBAR_MIN_WIDTH, maxSidebarWidth) + setSidebarWidth(nextWidth) + } + + const handlePointerUp = (upEvent: PointerEvent) => { + const nextWidth = clamp(startWidth + upEvent.clientX - startX, SIDEBAR_MIN_WIDTH, maxSidebarWidth) + setSidebarWidth(nextWidth) + try { + window.localStorage.setItem(SIDEBAR_WIDTH_KEY, String(Math.round(nextWidth))) + } catch {} + document.body.classList.remove('is-resizing-layout', 'is-resizing-column') + window.removeEventListener('pointermove', handlePointerMove) + window.removeEventListener('pointerup', handlePointerUp) + window.removeEventListener('pointercancel', handlePointerUp) + } + + window.addEventListener('pointermove', handlePointerMove) + window.addEventListener('pointerup', handlePointerUp) + window.addEventListener('pointercancel', handlePointerUp) + } + + const handleUploadResizeStart = (event: React.PointerEvent) => { + const mainContent = mainContentRef.current + if (!mainContent) return + + event.preventDefault() + const startY = event.clientY + const startHeight = uploadHeight + const maxUploadHeight = Math.max( + UPLOAD_MIN_HEIGHT, + Math.min(UPLOAD_MAX_HEIGHT, mainContent.clientHeight - RESIZE_HANDLE_SIZE - RESULT_MIN_HEIGHT) + ) + + document.body.classList.add('is-resizing-layout', 'is-resizing-row') + + const handlePointerMove = (moveEvent: PointerEvent) => { + const nextHeight = clamp(startHeight + moveEvent.clientY - startY, UPLOAD_MIN_HEIGHT, maxUploadHeight) + setUploadHeight(nextHeight) + } + + const handlePointerUp = (upEvent: PointerEvent) => { + const nextHeight = clamp(startHeight + upEvent.clientY - startY, UPLOAD_MIN_HEIGHT, maxUploadHeight) + setUploadHeight(nextHeight) + try { + window.localStorage.setItem(UPLOAD_HEIGHT_KEY, String(Math.round(nextHeight))) + } catch {} + document.body.classList.remove('is-resizing-layout', 'is-resizing-row') + window.removeEventListener('pointermove', handlePointerMove) + window.removeEventListener('pointerup', handlePointerUp) + window.removeEventListener('pointercancel', handlePointerUp) + } + + window.addEventListener('pointermove', handlePointerMove) + window.addEventListener('pointerup', handlePointerUp) + window.addEventListener('pointercancel', handlePointerUp) + } + const progressMessages = progress.map((p) => p.message) return ( @@ -196,7 +343,11 @@ export default function App() { -
+
-
+
+ +
+
+
-
+
{!apiKeyConfigured && pdfPath && ( {t(language, 'upload.setApiKeyHint')} diff --git a/src/renderer/global.css b/src/renderer/global.css index adea0e5..693d578 100644 --- a/src/renderer/global.css +++ b/src/renderer/global.css @@ -98,9 +98,7 @@ body { flex: 1; min-height: 0; display: grid; - grid-template-columns: 280px minmax(0, 1fr); align-items: stretch; - gap: 16px; padding: 18px; overflow: hidden; } @@ -115,26 +113,90 @@ body { } .main-content { - flex: 1; min-width: 0; - display: flex; - flex-direction: column; - gap: 16px; + min-height: 0; + display: grid; overflow: hidden; } .workspace { - flex-shrink: 0; + min-height: 0; + overflow: hidden; + display: flex; + flex-direction: column; } .result-viewer { - flex: 1; min-height: 0; overflow: hidden; display: flex; flex-direction: column; } +.resize-handle { + position: relative; + flex-shrink: 0; + touch-action: none; + user-select: none; + -webkit-app-region: no-drag; +} + +.resize-handle::after { + content: ''; + position: absolute; + border-radius: 999px; + background: var(--color-border-strong); + opacity: 0; + transition: opacity 0.15s, background 0.15s; +} + +.resize-handle:hover::after, +.is-resizing-column .resize-handle-vertical::after, +.is-resizing-row .resize-handle-horizontal::after { + opacity: 1; + background: var(--color-accent); +} + +.resize-handle-vertical { + cursor: col-resize; +} + +.resize-handle-vertical::after { + top: 8px; + bottom: 8px; + left: 50%; + width: 2px; + transform: translateX(-50%); +} + +.resize-handle-horizontal { + cursor: row-resize; +} + +.resize-handle-horizontal::after { + left: 8px; + right: 8px; + top: 50%; + height: 2px; + transform: translateY(-50%); +} + +.is-resizing-layout { + user-select: none; +} + +.is-resizing-column { + cursor: col-resize; +} + +.is-resizing-row { + cursor: row-resize; +} + +.is-resizing-layout * { + user-select: none !important; +} + .markdown-table-frame { display: flex; justify-content: center;