diff --git a/src/app/globals.css b/src/app/globals.css index 4240bd32..3d8b5f84 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -142,14 +142,15 @@ textarea:focus { } :focus-visible { - outline: 0; - box-shadow: 0 0 0 3px var(--accent-muted); - border-radius: var(--radius); + outline: 2px solid var(--accent); + outline-offset: 2px; + box-shadow: 0 0 0 4px var(--accent-muted); } -detail > summary { +detail>summary { list-style: none; } -details > summary::-webkit-details-marker { + +details>summary::-webkit-details-marker { display: none; } \ No newline at end of file diff --git a/src/app/layout.tsx b/src/app/layout.tsx index f16cedb2..a689051d 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -6,11 +6,12 @@ import { ThemeProvider } from "@/components/ThemeProvider"; import { ThemeToggle } from "@/components/ThemeToggle"; import ScrollToTop from "@/components/ScrollToTop"; import BrandLogo from "@/components/BrandLogo"; +import Link from "next/link"; export const metadata: Metadata = { title: "Reframe — Resize, trim, and export videos in your browser", description: "Free, open-source video editor that runs entirely in your browser. No login, no uploads, no ads. Resize for any platform, trim, rotate, adjust speed, and export.", - keywords: [ + keywords: [ "video editor", "browser video editor", "open source video editor", @@ -68,9 +69,9 @@ export default function RootLayout({ - - Skip to main content @@ -80,10 +81,14 @@ export default function RootLayout({ role="banner" className="sticky top-0 z-50 flex items-center justify-between border-b border-[var(--border)] bg-[var(--bg)]/95 px-6 py-3 backdrop-blur" > -
+ -

Reframe

-
+ Reframe +
diff --git a/src/components/VideoEditor.tsx b/src/components/VideoEditor.tsx index b1d2e574..8b423dc7 100644 --- a/src/components/VideoEditor.tsx +++ b/src/components/VideoEditor.tsx @@ -122,37 +122,37 @@ function KeyboardShortcutsPanel() { const [open, setOpen] = useState(false); const shortcuts: { keys: React.ReactNode[]; label: string }[] = [ - { - keys: [ - Ctrl, - +, - Shift, - +, - E - ], - label: "Export video", - }, - { - keys: [M], - label: "Toggle audio mute", - }, - { - keys: [R], - label: "Reset all settings", - }, - { - keys: [Esc], - label: "Cancel export", - }, - { - keys: [1, , 9], - label: "Switch preset by index", - }, - { - keys: [?], - label: "Toggle this panel", - }, -]; + { + keys: [ + Ctrl, + +, + Shift, + +, + E + ], + label: "Export video", + }, + { + keys: [M], + label: "Toggle audio mute", + }, + { + keys: [R], + label: "Reset all settings", + }, + { + keys: [Esc], + label: "Cancel export", + }, + { + keys: [1, , 9], + label: "Switch preset by index", + }, + { + keys: [?], + label: "Toggle this panel", + }, + ]; return (
@@ -220,7 +220,7 @@ export default function VideoEditor() { handleExport, status, cancelExport, - onToggleShortcutsModal: () => {}, + onToggleShortcutsModal: () => { }, }); const [copied, setCopied] = useState(false); @@ -238,6 +238,53 @@ export default function VideoEditor() { const toggleSection = (key: keyof typeof openSections) => setOpenSections((prev) => ({ ...prev, [key]: !prev[key] })); const downloadRef = useRef(null); + const parentRef = useRef(null); + const resizeRef = useRef(null); + const exportRef = useRef(null); + const [resizeHeight, setResizeHeight] = useState(0); + const [exportHeight, setExportHeight] = useState(0); + const [stickyOffset, setStickyOffset] = useState(0); + + useEffect(() => { + if (typeof window === "undefined" || typeof ResizeObserver === "undefined") return; + const observer = new ResizeObserver((entries) => { + for (let entry of entries) { + if (entry.target === resizeRef.current) { + setResizeHeight(entry.target.getBoundingClientRect().height); + } else if (entry.target === exportRef.current) { + setExportHeight(entry.target.getBoundingClientRect().height); + } + } + }); + if (resizeRef.current) observer.observe(resizeRef.current); + if (exportRef.current) observer.observe(exportRef.current); + return () => observer.disconnect(); + }, [file]); + + useEffect(() => { + if (typeof window === "undefined") return; + const handleScroll = () => { + if (!parentRef.current) return; + const parentRect = parentRef.current.getBoundingClientRect(); + const parentTop = parentRect.top; + const parentHeight = parentRect.height; + const rightColumnHeight = resizeHeight + exportHeight + 40; + const maxOffset = Math.max(0, parentHeight - rightColumnHeight); + const currentOffset = -parentTop + 60; + if (parentTop < 60) { + setStickyOffset(Math.min(maxOffset, currentOffset)); + } else { + setStickyOffset(0); + } + }; + window.addEventListener("scroll", handleScroll); + window.addEventListener("resize", handleScroll); + handleScroll(); + return () => { + window.removeEventListener("scroll", handleScroll); + window.removeEventListener("resize", handleScroll); + }; + }, [file, resizeHeight, exportHeight]); /** * Updates a text overlay property and syncs with recipe. @@ -296,8 +343,8 @@ export default function VideoEditor() { const qualityLabel = recipe.quality <= 21 ? "High" : recipe.quality <= 25 - ? "Balanced" - : "Small file"; + ? "Balanced" + : "Small file"; return `Exporting to ${width}×${height} ${recipe.format.toUpperCase()} • ${framingLabel} • ${speedLabel} • Quality: ${qualityLabel}`; }, [recipe]); @@ -326,372 +373,113 @@ export default function VideoEditor() {
+
+

+ REFRAME +

+

+ Your video, any format +

+
+ + No login. No ads. 100% private. +
+
+
+ + No login. No ads. 100% private - your video never leaves your device. +
+
0 || exportHeight > 0 ? `${resizeHeight + exportHeight + 40}px` : undefined }} > -

- REFRAME -

-

- Your video, any format -

-
- - No login. No ads. 100% private. -
-
-
- - No login. No ads. 100% private - your video never leaves your device. -
- -
- -
-
- - - {!file && ( -
-

Upload a video to get started

-

Supports MP4, MOV, WebM and more

-
- )} - - {file && ( -
- + {/* + LAYOUT: Floated & Sticky columns to maintain the identical two-column UI layout + on desktop screens while preserving the exact logical DOM/tab order: + Upload -> Preset -> Framing -> Trim -> Rotate -> Audio -> Quality -> Export + */} -
- -
-
- )} -
+ {/* BLOCK 1: Upload & Video Preview (Left Column) */} +
+ - {file && file.size > 100 * 1024 * 1024 && ( -

- ⚠️ Large file - processing may take several minutes -

- )} - {file && ( -
-
- } - title="Trim" - isOpen={openSections.trim} - onToggle={() => toggleSection("trim")} - delay={50} - > - - - - } - title="Rotation" - isOpen={openSections.rotation} - onToggle={() => toggleSection("rotation")} - delay={100} - > - - - - } - title="Text Overlay" - isOpen={openSections.text} - onToggle={() => toggleSection("text")} - delay={110} - > - - -
- - - Advanced settings -
-
- -
- } - title="Rotation" - isOpen={openSections.rotation} - onToggle={() => toggleSection("rotation")} - > - - - - } - title="Export" - isOpen={openSections.export} - onToggle={() => toggleSection("export")} - > - - -
-
-
-
- } - title="Audio & Speed" - isOpen={openSections.audio} - onToggle={() => toggleSection("audio")} - delay={150} - > - - -
} - title="Adjustments" - delay={175} - > -
- {/* Brightness */} -
-
- - -
- updateRecipe({ brightness: Number(e.target.value) })} - aria-label="Adjust brightness" - className="w-full accent-film-600" - /> -
- {/* Contrast */} -
-
- - -
- updateRecipe({ contrast: Number(e.target.value) })} - aria-label="Adjust contrast" - className="w-full accent-film-600" - /> -
- {/* Saturation */} -
-
- - -
- updateRecipe({ saturation: Number(e.target.value) })} - aria-label="Adjust saturation" - className="w-full accent-film-600" - /> -
-
-
-
} title="Output format" delay={190}> - -
- } - title="Export" - isOpen={openSections.export} - onToggle={() => toggleSection("export")} - delay={200} - > - - -
} title="Image overlay" delay={120}> - -
-
- - - Advanced settings -
-
- -
- } - title="Rotation" - isOpen={openSections.rotation} - onToggle={() => toggleSection("rotation")} - > - - - - } - title="Export" - isOpen={openSections.export} - onToggle={() => toggleSection("export")} - > - - -
-
-
+ {!file && ( +
+

Upload a video to get started

+

Supports MP4, MOV, WebM and more

)} - {status === "error" && error && ( -
- -
-

Error

-

{error}

+ {file && ( +
+ + +
+
- - {!error.includes("Validation Failed") && ( - - )} -
- )} - - {status === "done" && result && ( -
-
)}
-
+ {file && file.size > 100 * 1024 * 1024 && ( +
+

+ ⚠️ Large file - processing may take several minutes +

+
+ )} + + {/* BLOCK 2: Preset & Framing (Resize & Aspect Ratio) (Right Column) */} +
0 || exportHeight > 0 ? `${stickyOffset}px` : undefined, + animationDelay: "50ms" + }} + > {!file && ( -
+

Getting Started

@@ -700,7 +488,11 @@ export default function VideoEditor() {

)} -
+ +
} @@ -740,7 +532,309 @@ export default function VideoEditor() {
+
+ {/* BLOCK 3: Trim & Rotate (Left Column) */} + {file && ( +
+
+ } + title="Trim" + isOpen={openSections.trim} + onToggle={() => toggleSection("trim")} + delay={50} + > + + + + } + title="Rotation" + isOpen={openSections.rotation} + onToggle={() => toggleSection("rotation")} + delay={100} + > + + + + } + title="Text Overlay" + isOpen={openSections.text} + onToggle={() => toggleSection("text")} + delay={110} + > + + +
+ + + Advanced settings +
+
+ +
+ } + title="Rotation" + isOpen={openSections.rotation} + onToggle={() => toggleSection("rotation")} + > + + + + } + title="Export" + isOpen={openSections.export} + onToggle={() => toggleSection("export")} + > + + +
+
+
+
+ )} + + {/* BLOCK 4: Audio & Quality/Export Settings (Left Column) */} + {file && ( +
+
+ } + title="Audio & Speed" + isOpen={openSections.audio} + onToggle={() => toggleSection("audio")} + delay={150} + > + + +
} + title="Adjustments" + delay={175} + > +
+ {/* Brightness */} +
+
+ + +
+ updateRecipe({ brightness: Number(e.target.value) })} + aria-label="Adjust brightness" + className="w-full accent-film-600" + /> +
+ {/* Contrast */} +
+
+ + +
+ updateRecipe({ contrast: Number(e.target.value) })} + aria-label="Adjust contrast" + className="w-full accent-film-600" + /> +
+ {/* Saturation */} +
+
+ + +
+ updateRecipe({ saturation: Number(e.target.value) })} + aria-label="Adjust saturation" + className="w-full accent-film-600" + /> +
+
+
+
} title="Output format" delay={190}> + +
+ } + title="Export" + isOpen={openSections.export} + onToggle={() => toggleSection("export")} + delay={200} + > + + +
} title="Image overlay" delay={120}> + +
+
+ + + Advanced settings +
+
+ +
+ } + title="Rotation" + isOpen={openSections.rotation} + onToggle={() => toggleSection("rotation")} + > + + + + } + title="Export" + isOpen={openSections.export} + onToggle={() => toggleSection("export")} + > + + +
+
+
+
+ )} + + {/* STATUS & RESULTS: (Left Column) */} + {status === "error" && error && ( +
+ +
+

Error

+

{error}

+
+ + {!error.includes("Validation Failed") && ( + + )} +
+ )} + + {status === "done" && result && ( +
+ +
+ )} + + {/* BLOCK 5: Keyboard Shortcuts & Export Button (Right Column) */} +
0 ? `${stickyOffset + resizeHeight + 20}px` : undefined + }} + > {file && ( @@ -753,10 +847,10 @@ export default function VideoEditor() { id="export-button" type="button" onClick={handleExport} - disabled={!file || isProcessing} - aria-label='Export video' - aria-disabled={!file || isProcessing ? "true" : undefined} - title={!file ? "Upload a video to enable export" : undefined} + disabled={!file || isProcessing} + aria-label='Export video' + aria-disabled={!file || isProcessing ? "true" : undefined} + title={!file ? "Upload a video to enable export" : undefined} className={cn( "w-full flex items-center justify-center gap-3 py-5 min-h-[44px] rounded-xl", "font-display text-2xl tracking-widest transition-all duration-200", @@ -765,16 +859,19 @@ export default function VideoEditor() { : "bg-[var(--border)] text-[var(--muted)] cursor-not-allowed" )} > - + {isProcessing ? "PROCESSING" : "EXPORT"} - {file && !isProcessing && ( + {!isProcessing && (

{isMac ? "⌘" : "Ctrl"} + Enter to export

)}
+ + {/* CLEAR-FIX element to wrap floats inside parent */} +
diff --git a/src/components/__tests__/VideoEditorTabOrder.test.tsx b/src/components/__tests__/VideoEditorTabOrder.test.tsx new file mode 100644 index 00000000..7f0a69af --- /dev/null +++ b/src/components/__tests__/VideoEditorTabOrder.test.tsx @@ -0,0 +1,111 @@ +import React from 'react' +import { describe, it, expect, vi } from 'vitest' +import '@testing-library/jest-dom/vitest' +import { render } from '@testing-library/react' +import VideoEditor from '../VideoEditor' + +// Mock sub-hooks and sub-components that are not necessary for testing DOM layout structure +vi.mock('@/hooks/useVideoEditor', () => ({ + useVideoEditor: () => ({ + file: new File([""], "test.mp4", { type: "video/mp4" }), + duration: 60, + recipe: { + preset: 'vertical-9-16', + customWidth: 1080, + customHeight: 1920, + framing: 'fill', + trimStart: 0, + trimEnd: null, + rotate: 0, + speed: 1, + quality: 23, + format: 'mp4', + brightness: 0, + contrast: 1, + saturation: 1, + soundOnCompletion: true, + }, + status: 'idle', + progress: 0, + result: null, + error: null, + exportStartedAt: null, + updateRecipe: vi.fn(), + handleFileSelect: vi.fn(), + fileError: '', + handleExport: vi.fn(), + cancelExport: vi.fn(), + reset: vi.fn(), + resetSettings: vi.fn(), + videoRef: { current: null }, + seekTo: vi.fn(), + overlayFile: null, + setOverlayFile: vi.fn(), + overlayPosition: 'bottom-right', + setOverlayPosition: vi.fn(), + overlaySize: 150, + setOverlaySize: vi.fn(), + overlayOpacity: 100, + setOverlayOpacity: vi.fn(), + recommendedPreset: null, + currentTime: 0, + toggleSound: vi.fn(), + }) +})) + +vi.mock('@/hooks/useKeyboardShortcuts', () => ({ + useKeyboardShortcuts: vi.fn() +})) + +vi.mock('../LottiePlayer', () => ({ + default: () =>
+})) + +describe('VideoEditor Tab Order & DOM Restructure', () => { + it('reorders DOM elements in logical reading/tab order: Upload -> Preset -> Framing -> Trim -> Rotate -> Audio -> Quality -> Export', () => { + const { container } = render(React.createElement(VideoEditor)) + + // Query for key elements representing each step + const fileUpload = container.querySelector('[id="upload-zone"]') || container.querySelector('button.text-film-600'); + const resizeSection = container.querySelector('[aria-controls="resize-panel"]'); + const trimSection = container.querySelector('[aria-controls="trim-panel"]'); + const rotateSection = Array.from(container.querySelectorAll('[aria-controls="rotation-panel"]')).find(el => !el.closest('details')); + const audioSection = container.querySelector('[aria-controls="audio-panel"]'); + const qualitySection = Array.from(container.querySelectorAll('[aria-controls="export-panel"]')).find(el => !el.closest('details')); + const exportButton = container.querySelector('#export-button'); + + // All controls must exist + expect(fileUpload).toBeTruthy(); + expect(resizeSection).toBeTruthy(); + expect(trimSection).toBeTruthy(); + expect(rotateSection).toBeTruthy(); + expect(audioSection).toBeTruthy(); + expect(qualitySection).toBeTruthy(); + expect(exportButton).toBeTruthy(); + + // Verify sequential DOM positions to guarantee logical focus order + const elementsInDOMOrder = [ + fileUpload, + resizeSection, + trimSection, + rotateSection, + audioSection, + qualitySection, + exportButton + ]; + + // Find all focusable elements in the rendered container to verify their raw DOM order + const allRenderedElements = Array.from(container.querySelectorAll('*')); + + const indices = elementsInDOMOrder.map(el => allRenderedElements.indexOf(el!)); + + // Check that each index is strictly greater than the previous one + for (let i = 0; i < indices.length - 1; i++) { + const current = indices[i]; + const next = indices[i + 1]; + expect(current).toBeDefined(); + expect(next).toBeDefined(); + expect(current!).toBeLessThan(next!); + } + }) +}) diff --git a/vitest.config.ts b/vitest.config.ts index 220799df..968b8956 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -1,4 +1,5 @@ import { defineConfig } from 'vitest/config' +import path from 'path' export default defineConfig({ oxc: { @@ -6,6 +7,11 @@ export default defineConfig({ runtime: 'automatic', }, }, + resolve: { + alias: { + '@': path.resolve(__dirname, './src'), + }, + }, test: { environment: 'jsdom', globals: true,