1use edit::EditProject;
11use leptos::prelude::*;
12use ui_storybook::components::editor::{EditorShell, EditorShellView, ToolbarActionView};
13
14use crate::editor_ipc::{self, EditorStatus, TransportAction};
15
16fn default_toolbar(loaded: bool) -> Vec<ToolbarActionView> {
19 let disabled = !loaded;
20 let action = |id, label, icon, selected| ToolbarActionView {
21 id,
22 label,
23 icon,
24 selected,
25 disabled,
26 };
27 vec![
28 action("aspect", "16:9", "▭", true),
29 action("split", "Split", "✂", false),
30 action("trim", "Trim", "⇥", false),
31 action("crop", "Crop", "⛶", false),
32 action("zoom", "Zoom", "⌖", false),
33 action("annotate", "Annotate", "✎", false),
34 action("captions", "Captions", "≡", false),
35 ]
36}
37
38fn clip_title(project: &EditProject) -> String {
40 project
41 .source
42 .path
43 .file_name()
44 .and_then(|name| name.to_str())
45 .unwrap_or("recording")
46 .to_owned()
47}
48
49fn clip_subtitle(project: &EditProject) -> String {
51 let src = &project.source;
52 let secs = if src.source_fps > 0 {
53 src.frame_count / u64::from(src.source_fps)
54 } else {
55 0
56 };
57 let (mins, rem) = (secs / 60, secs % 60);
58 format!(
59 "{}×{} · {} fps · {mins}:{rem:02}",
60 src.width, src.height, src.source_fps
61 )
62}
63
64#[must_use]
66pub fn format_timecode(frame: u64, fps: u32) -> String {
67 let fps = u64::from(fps.max(1));
68 let total_secs = frame / fps;
69 let mins = total_secs / 60;
70 let secs = total_secs % 60;
71 let frames = frame % fps;
72 format!("{mins:02}:{secs:02}.{frames:02}")
73}
74
75fn shell_view_for(project: Option<&EditProject>) -> EditorShellView {
77 match project {
78 Some(p) => EditorShellView {
79 document_title: clip_title(p),
80 document_subtitle: Some(clip_subtitle(p)),
81 has_clip_loaded: true,
82 toolbar_actions: default_toolbar(true),
83 export_enabled: true,
84 share_enabled: true,
85 },
86 None => EditorShellView {
87 document_title: String::new(),
88 document_subtitle: None,
89 has_clip_loaded: false,
90 toolbar_actions: default_toolbar(false),
91 export_enabled: false,
92 share_enabled: false,
93 },
94 }
95}
96
97#[component]
101fn EditorTransportBar() -> impl IntoView {
102 let status = use_context::<RwSignal<EditorStatus>>()
103 .unwrap_or_else(|| RwSignal::new(EditorStatus::default()));
104 view! {
105 <div class="editor-transport" role="group" aria-label="Playback transport">
106 <button class="transport-btn" title="Jump to start"
107 on:click=move |_| editor_ipc::editor_transport(&TransportAction::Seek { frame: 0 })>
108 "⏮"
109 </button>
110 <button class="transport-btn" title="Step back"
111 on:click=move |_| editor_ipc::editor_transport(&TransportAction::Step { delta: -1 })>
112 "‹"
113 </button>
114 <button class="transport-btn transport-play" title="Play / pause (Space)"
115 on:click=move |_| editor_ipc::editor_transport(&TransportAction::TogglePlay)>
116 {move || if status.get().playing { "⏸" } else { "▶" }}
117 </button>
118 <button class="transport-btn" title="Step forward"
119 on:click=move |_| editor_ipc::editor_transport(&TransportAction::Step { delta: 1 })>
120 "›"
121 </button>
122 <button class="transport-btn" title="Jump to end"
123 on:click=move |_| editor_ipc::editor_transport(&TransportAction::Seek {
124 frame: status.get_untracked().duration_frames.saturating_sub(1),
125 })>
126 "⏭"
127 </button>
128 <span class="transport-timecode">
129 {move || format_timecode(status.get().current_frame, status.get().fps)}
130 " / "
131 {move || format_timecode(status.get().duration_frames.saturating_sub(1), status.get().fps)}
132 </span>
133 <input
134 type="range"
135 class="transport-scrub"
136 min="0"
137 prop:max=move || status.get().duration_frames.saturating_sub(1).to_string()
138 prop:value=move || status.get().current_frame.to_string()
139 on:input=move |ev| {
140 if let Ok(frame) = event_target_value(&ev).parse::<u64>() {
141 editor_ipc::editor_transport(&TransportAction::Seek { frame });
142 }
143 }
144 />
145 <select class="transport-rate" title="Playback speed"
146 on:change=move |ev| {
147 if let Ok(rate) = event_target_value(&ev).parse::<f32>() {
148 editor_ipc::editor_transport(&TransportAction::SetRate { rate });
149 }
150 }>
151 <option value="0.5">"0.5×"</option>
152 <option value="1" selected=true>"1×"</option>
153 <option value="2">"2×"</option>
154 </select>
155 </div>
156 }
157}
158
159fn handle_editor_keydown(
162 ev: &web_sys::KeyboardEvent,
163 project: Option<RwSignal<Option<EditProject>>>,
164 history: Option<StoredValue<Option<edit::History>>>,
165 selection: Option<RwSignal<Option<usize>>>,
166 status: RwSignal<EditorStatus>,
167) {
168 match ev.key().as_str() {
169 " " | "Spacebar" => {
170 ev.prevent_default();
171 editor_ipc::editor_transport(&TransportAction::TogglePlay);
172 }
173 "ArrowLeft" => {
174 ev.prevent_default();
175 let delta = if ev.shift_key() { -5 } else { -1 };
176 editor_ipc::editor_transport(&TransportAction::Step { delta });
177 }
178 "ArrowRight" => {
179 ev.prevent_default();
180 let delta = if ev.shift_key() { 5 } else { 1 };
181 editor_ipc::editor_transport(&TransportAction::Step { delta });
182 }
183 "i" | "I" => {
184 let s = status.get_untracked();
185 editor_ipc::editor_transport(&TransportAction::SetInOut {
186 a: s.current_frame,
187 b: s.out_frame.max(s.current_frame + 1),
188 });
189 }
190 "o" | "O" => {
191 let s = status.get_untracked();
192 editor_ipc::editor_transport(&TransportAction::SetInOut {
193 a: s.in_frame.min(s.current_frame),
194 b: s.current_frame + 1,
195 });
196 }
197 "s" | "S" => {
199 ev.prevent_default();
200 if let (Some(p), Some(h)) = (project, history) {
201 crate::editor_edits::split_at(p, h, status.get_untracked().current_frame);
202 }
203 }
204 "z" | "Z" if ev.meta_key() || ev.ctrl_key() => {
206 ev.prevent_default();
207 if let (Some(p), Some(h)) = (project, history) {
208 if ev.shift_key() {
209 crate::editor_edits::redo(p, h);
210 } else {
211 crate::editor_edits::undo(p, h);
212 }
213 }
214 }
215 "Delete" | "Backspace" => {
217 ev.prevent_default();
218 if let (Some(p), Some(h)) = (project, history) {
219 let sel = selection.and_then(|s| s.get_untracked());
220 crate::editor_edits::ripple_delete_selected(p, h, sel);
221 if let Some(s) = selection {
225 s.set(None);
226 }
227 }
228 }
229 _ => {}
230 }
231}
232
233#[component]
238pub fn EditorSurface() -> impl IntoView {
239 let project = use_context::<RwSignal<Option<EditProject>>>();
240 let status = use_context::<RwSignal<EditorStatus>>()
241 .unwrap_or_else(|| RwSignal::new(EditorStatus::default()));
242 let history = use_context::<StoredValue<Option<edit::History>>>();
243 let selection = use_context::<RwSignal<Option<usize>>>();
244 let drag_active = use_context::<RwSignal<bool>>().unwrap_or_else(|| RwSignal::new(false));
247 crate::editor_preview_canvas::install_editor_preview(project, status);
251 let on_toolbar_action = Callback::new(move |id: String| {
258 if let (Some(p), Some(h)) = (project, history) {
259 let frame = status.get_untracked().current_frame;
260 match id.as_str() {
261 "split" => crate::editor_edits::split_at(p, h, frame),
262 "zoom" => crate::editor_edits::add_zoom_at_cursor(p, h, frame),
265 _ => {}
266 }
267 }
268 });
269 view! {
270 <section
271 class="app-surface app-surface--editor"
272 tabindex="0"
273 on:keydown=move |ev| handle_editor_keydown(&ev, project, history, selection, status)
274 >
275 {move || {
276 let vm = match project {
277 Some(signal) => shell_view_for(signal.get().as_ref()),
278 None => shell_view_for(None),
279 };
280 let loaded = vm.has_clip_loaded;
281 view! {
282 <EditorShell
283 view=vm
284 on_action=on_toolbar_action
285 canvas=ToChildren::to_children(move || {
286 if loaded {
287 let (cw, ch) = project
293 .and_then(|s| s.with(|o| o.as_ref().map(EditProject::canvas_dims)))
294 .unwrap_or((1, 1));
295 view! {
296 <canvas
297 id=crate::editor_preview_canvas::EDITOR_PREVIEW_CANVAS_ID
298 class="editor-preview-canvas"
299 width=cw
300 height=ch
301 aria-label="Editor preview"
302 />
303 }
304 .into_any()
305 } else {
306 view! {
310 <div class=move || {
311 if drag_active.get() {
312 "editor-dropzone editor-dropzone--active"
313 } else {
314 "editor-dropzone"
315 }
316 }>
317 <div class="editor-dropzone-icon" aria-hidden="true">"⬇"</div>
318 <p class="editor-dropzone-title">"Drop a video to edit"</p>
319 <p class="editor-dropzone-hint">
320 "Drop a recording here, finish a new recording, or pick one from the Library."
321 </p>
322 </div>
323 }
324 .into_any()
325 }
326 })
327 inspector=ToChildren::to_children(move || view! {
328 <crate::export_bar::ExportBar />
329 <crate::style_inspector::StyleInspector />
330 <crate::cursor_inspector::CursorInspector />
331 <crate::framing_inspector::FramingInspector />
332 <crate::clip_inspector::ClipInspector />
333 })
334 timeline=ToChildren::to_children(move || view! {
335 <crate::timeline_view::TimelineRuler />
336 <crate::filmstrip::VideoFilmstrip />
337 <crate::zoom_lane::ZoomLane />
338 <crate::zoom_dopesheet::ZoomDopesheet />
339 <crate::waveform::AudioWaveform />
340 <EditorTransportBar />
341 })
342 />
343 }
344 }}
345 </section>
346 }
347}
348
349#[cfg(test)]
350mod tests {
351 use super::*;
352 use edit::ClipRef;
353 use std::path::PathBuf;
354
355 fn project() -> EditProject {
356 EditProject::from_recording(ClipRef::new(
357 PathBuf::from("/tmp/Screen-2026-05-30.mp4"),
358 1920,
359 1080,
360 30,
361 900,
362 ))
363 }
364
365 #[test]
366 fn empty_view_has_no_clip_and_disabled_actions() {
367 let v = shell_view_for(None);
368 assert!(!v.has_clip_loaded);
369 assert!(!v.export_enabled);
370 assert!(v.toolbar_actions.iter().all(|a| a.disabled));
371 assert!(v.document_subtitle.is_none());
372 }
373
374 #[test]
375 fn loaded_view_titles_and_enables() {
376 let p = project();
377 let v = shell_view_for(Some(&p));
378 assert!(v.has_clip_loaded);
379 assert!(v.export_enabled);
380 assert_eq!(v.document_title, "Screen-2026-05-30.mp4");
381 assert_eq!(
382 v.document_subtitle.as_deref(),
383 Some("1920×1080 · 30 fps · 0:30")
384 );
385 assert!(v.toolbar_actions.iter().all(|a| !a.disabled));
386 assert!(
387 v.toolbar_actions
388 .iter()
389 .any(|a| a.id == "aspect" && a.selected)
390 );
391 }
392
393 #[test]
394 fn wired_toolbar_actions_exist_as_chips() {
395 let v = shell_view_for(None);
399 for wired in ["split", "zoom"] {
400 assert!(
401 v.toolbar_actions.iter().any(|a| a.id == wired),
402 "wired toolbar action `{wired}` must exist as a chip"
403 );
404 }
405 }
406
407 #[test]
408 fn timecode_formats_mm_ss_ff() {
409 assert_eq!(format_timecode(0, 30), "00:00.00");
410 assert_eq!(format_timecode(75, 30), "00:02.15"); assert_eq!(format_timecode(1800, 30), "01:00.00"); assert_eq!(format_timecode(29, 30), "00:00.29");
413 assert_eq!(format_timecode(5, 0), "00:05.00");
415 }
416}