Skip to main content

max / audiofiles

24.5 KB · 623 lines History Blame Raw
1 //! Floating sample editor window: all edit controls visible simultaneously.
2
3 use egui;
4
5 use crate::state::{BrowserState, EditResultMode};
6 use crate::waveform;
7 use audiofiles_core::edit::FadeCurve;
8
9 use super::theme;
10 use super::widgets;
11
12 /// Draw the floating sample editor window. Call from the overlay layer.
13 pub fn draw_edit_window(ctx: &egui::Context, state: &mut BrowserState) {
14 let mut open = state.edit.show_window;
15 widgets::tool_window(ctx, "Sample Editor", &mut open, 400.0, 320.0, |ui| {
16 // In-progress bar. Drawn at the top while an edit applies; the body
17 // below stays rendered (every section greys itself out via its own
18 // `in_progress` disabled flag) so the user keeps the waveform and
19 // controls for spatial reference instead of the panel collapsing to a
20 // lone spinner.
21 if state.edit.in_progress {
22 ui.horizontal(|ui| {
23 ui.spinner();
24 ui.label("Applying edit...");
25 // M-11: best-effort cancel. Signals the worker and clears
26 // in_progress so the UI is interactive even if the worker
27 // is mid-write — the cancel is advisory, not synchronous.
28 if ui.button("Cancel").clicked() {
29 state.cancel_edit_operation();
30 }
31 });
32 ui.separator();
33 }
34
35 // Result prompt overlay
36 if state.edit.result_prompt {
37 draw_result_prompt(ui, state);
38 return;
39 }
40
41 let hash = match &state.edit.hash {
42 Some(h) => h.clone(),
43 None => return,
44 };
45
46 draw_waveform_section(ui, state, &hash);
47 draw_info_line(ui, state);
48 draw_transport_section(ui, state, &hash);
49
50 ui.separator();
51 draw_trim_section(ui, state);
52
53 ui.separator();
54 draw_levels_section(ui, state);
55
56 ui.separator();
57 draw_transform_section(ui, state);
58
59 ui.separator();
60 draw_silence_section(ui, state);
61
62 ui.separator();
63 draw_result_section(ui, state);
64
65 // Batch edit section (shown when multiple samples selected)
66 ui.separator();
67 draw_batch_section(ui, state);
68 });
69 state.edit.show_window = open;
70 }
71
72 /// Waveform display with playback cursor and click-to-seek.
73 fn draw_waveform_section(ui: &mut egui::Ui, state: &mut BrowserState, hash: &str) {
74 if let Some(ref waveform_data) = state.selected_waveform {
75 // Show the playhead whenever this sample is the active preview and a
76 // buffer is loaded — including while paused, so the user can see where
77 // playback sits before auditioning a trim.
78 let playback_pos = if state.previewing_hash.as_deref() == Some(hash) {
79 let playback = state.shared.preview.lock();
80 playback.buffer.as_ref().and_then(|buf| {
81 let total_frames = if playback.streaming {
82 playback.total_frames_estimate.unwrap_or(playback.decoded_frames)
83 } else {
84 buf.data.len() / 2
85 };
86 (total_frames > 0)
87 .then(|| (playback.position_frac / total_frames as f64) as f32)
88 })
89 } else {
90 None
91 };
92
93 // p-6: 120px matches the detail panel waveform; the edit context wants
94 // the larger surface for precise trim work (was 80px).
95 let resp = waveform::draw_waveform(ui, waveform_data, playback_pos, 120.0);
96
97 // C-1 part 1: paint the trim preview overlay. Regions outside the
98 // current [trim_start, trim_end] are dimmed so the user sees what
99 // *will be removed* before clicking Trim. Slider edges = preview
100 // edges. Updates live as the user drags.
101 let trim_start = state.edit.trim_start;
102 let trim_end = state.edit.trim_end;
103 if trim_start > 0.001 || trim_end < 0.999 {
104 let rect = resp.rect;
105 let painter = ui.painter_at(rect);
106 let overlay = theme::trim_mute_overlay();
107 if trim_start > 0.0 {
108 let x_end = rect.left() + rect.width() * trim_start;
109 painter.rect_filled(
110 egui::Rect::from_min_max(rect.min, egui::pos2(x_end, rect.max.y)),
111 0.0,
112 overlay,
113 );
114 }
115 if trim_end < 1.0 {
116 let x_start = rect.left() + rect.width() * trim_end;
117 painter.rect_filled(
118 egui::Rect::from_min_max(egui::pos2(x_start, rect.min.y), rect.max),
119 0.0,
120 overlay,
121 );
122 }
123 }
124
125 // Draggable trim handles drawn over the waveform. These are the primary
126 // way to set the cut region; the Start/End sliders below are the numeric
127 // path. Handles are always present (even at the 0.0/1.0 default) so the
128 // user can grab either edge directly.
129 {
130 let rect = resp.rect;
131 let (new_start, start_dragged) =
132 trim_handle(ui, rect, state.edit.trim_start, "edit_trim_handle_start");
133 let (new_end, end_dragged) =
134 trim_handle(ui, rect, state.edit.trim_end, "edit_trim_handle_end");
135 // Only the handle the user is actually dragging moves; it clamps
136 // against the other so the untouched handle never jumps (and a
137 // minimum region is preserved).
138 const MIN_GAP: f32 = 0.001;
139 if start_dragged {
140 state.edit.trim_start = new_start.clamp(0.0, state.edit.trim_end - MIN_GAP);
141 }
142 if end_dragged {
143 state.edit.trim_end = new_end.clamp(state.edit.trim_start + MIN_GAP, 1.0);
144 }
145 }
146
147 // Click-to-seek. If this sample isn't the active preview yet, start it
148 // first so a click in the editor auditions from the clicked point —
149 // previously the click was a silent no-op until preview was started
150 // elsewhere.
151 if resp.clicked()
152 && let Some(pos) = resp.interact_pointer_pos() {
153 let rect = resp.rect;
154 let normalized = ((pos.x - rect.left()) / rect.width()).clamp(0.0, 1.0);
155 if state.previewing_hash.as_deref() != Some(hash) {
156 state.trigger_preview(hash);
157 }
158 let mut playback = state.shared.preview.lock();
159 if let Some(ref buf) = playback.buffer {
160 let total_frames = if playback.streaming {
161 playback.total_frames_estimate.unwrap_or(playback.decoded_frames)
162 } else {
163 buf.data.len() / 2
164 };
165 playback.position_frac = (normalized as f64 * total_frames as f64)
166 .min((playback.decoded_frames.max(1) - 1) as f64);
167 }
168 }
169 }
170 }
171
172 /// Transport row: Play/Pause/Stop for the sample being edited, independent of
173 /// the main file-list selection so the user can audition before committing a
174 /// destructive edit.
175 fn draw_transport_section(ui: &mut egui::Ui, state: &mut BrowserState, hash: &str) {
176 let is_current = state.previewing_hash.as_deref() == Some(hash);
177 let playing = is_current && state.shared.preview.lock().playing;
178 ui.horizontal(|ui| {
179 if widgets::secondary_button(ui, if playing { "Pause" } else { "Play" }).clicked() {
180 if is_current {
181 // Toggle play/pause on the already-loaded buffer.
182 let mut pb = state.shared.preview.lock();
183 pb.playing = !pb.playing;
184 } else {
185 state.trigger_preview(hash);
186 }
187 }
188 if widgets::secondary_button(ui, "Stop").clicked() {
189 state.stop_preview();
190 }
191 });
192 }
193
194 /// Draw a draggable trim-boundary handle over the waveform at `frac` (0..1).
195 /// Returns `(new_frac, dragged)`. The hit area is wider than the painted line
196 /// (Fitts) so it is easy to grab, the cursor switches to a horizontal resize on
197 /// hover, and the line thickens while hovered or dragged for tactile feedback.
198 fn trim_handle(ui: &mut egui::Ui, rect: egui::Rect, frac: f32, id_salt: &str) -> (f32, bool) {
199 let frac = frac.clamp(0.0, 1.0);
200 let x = rect.left() + rect.width() * frac;
201 let hit = egui::Rect::from_min_max(
202 egui::pos2(x - 4.0, rect.top()),
203 egui::pos2(x + 4.0, rect.bottom()),
204 );
205 let resp = ui.interact(hit, ui.id().with(id_salt), egui::Sense::drag());
206 let active = resp.hovered() || resp.dragged();
207 if active {
208 ui.ctx().set_cursor_icon(egui::CursorIcon::ResizeHorizontal);
209 }
210 let mut new_frac = frac;
211 if resp.dragged()
212 && let Some(p) = resp.interact_pointer_pos() {
213 new_frac = ((p.x - rect.left()) / rect.width()).clamp(0.0, 1.0);
214 }
215 let width = if active { 2.5 } else { 1.0 };
216 ui.painter_at(rect).line_segment(
217 [egui::pos2(x, rect.top()), egui::pos2(x, rect.bottom())],
218 egui::Stroke::new(width, theme::accent_yellow()),
219 );
220 (new_frac, resp.dragged())
221 }
222
223 /// Info line: name, sample rate, duration, peak dB.
224 fn draw_info_line(ui: &mut egui::Ui, state: &BrowserState) {
225 if let Some(ref analysis) = state.selected_analysis {
226 let name = state.selected_node()
227 .map(|n| n.node.name.clone())
228 .unwrap_or_default();
229 let duration = analysis.duration;
230 let sr = analysis.sample_rate;
231 // Only append the peak segment (and its separator) when a value exists,
232 // so a missing peak_db doesn't leave a dangling " \u{00B7} ".
233 let peak_suffix = analysis.peak_db
234 .map(|p| format!(" \u{00B7} {:.1} dBFS", p))
235 .unwrap_or_default();
236
237 ui.horizontal_wrapped(|ui| {
238 ui.label(egui::RichText::new(&name).strong().size(12.0));
239 ui.label(
240 egui::RichText::new(format!("{} Hz \u{00B7} {:.3}s{}", sr, duration, peak_suffix))
241 .color(theme::text_muted())
242 .size(11.0),
243 );
244 });
245 ui.add_space(theme::space::XS);
246 }
247 }
248
249 /// Trim section with start/end sliders.
250 fn draw_trim_section(ui: &mut egui::Ui, state: &mut BrowserState) {
251 let disabled = state.edit.in_progress || state.edit.hash.is_none();
252
253 ui.label(egui::RichText::new("Trim").strong());
254
255 let sample_rate = state.selected_analysis.as_ref()
256 .map(|a| a.sample_rate)
257 .unwrap_or(44100);
258 let total = state.edit.total_frames;
259 let start_time = state.edit.trim_start as f64 * total as f64 / sample_rate as f64;
260 let end_time = state.edit.trim_end as f64 * total as f64 / sample_rate as f64;
261
262 ui.horizontal(|ui| {
263 ui.label("Start:");
264 ui.add_enabled(!disabled, egui::Slider::new(&mut state.edit.trim_start, 0.0..=1.0).show_value(false));
265 ui.label(egui::RichText::new(format!("{:.3}s", start_time)).color(theme::text_muted()));
266 });
267
268 ui.horizontal(|ui| {
269 ui.label("End:");
270 ui.add_enabled(!disabled, egui::Slider::new(&mut state.edit.trim_end, 0.0..=1.0).show_value(false));
271 ui.label(egui::RichText::new(format!("{:.3}s", end_time)).color(theme::text_muted()));
272 });
273
274 // Clamp start < end
275 if state.edit.trim_start >= state.edit.trim_end {
276 state.edit.trim_start = (state.edit.trim_end - 0.001).max(0.0);
277 }
278
279 ui.horizontal(|ui| {
280 if ui.add_enabled(!disabled, egui::Button::new("Trim")).clicked() {
281 state.apply_edit_trim();
282 }
283 });
284 }
285
286 /// Levels section: gain and normalize.
287 fn draw_levels_section(ui: &mut egui::Ui, state: &mut BrowserState) {
288 let disabled = state.edit.in_progress || state.edit.hash.is_none();
289
290 ui.label(egui::RichText::new("Levels").strong());
291
292 // Current peak display + gain clipping warning
293 let current_peak = state.selected_analysis.as_ref().and_then(|a| a.peak_db);
294 if let Some(peak) = current_peak {
295 let predicted = peak + state.edit.gain_db;
296 if predicted > 0.0 {
297 ui.colored_label(
298 theme::accent_red(),
299 format!("Peak: {:.1} dB \u{2192} {:.1} dB (clips!)", peak, predicted),
300 );
301 }
302 }
303
304 // Gain
305 ui.horizontal(|ui| {
306 ui.label("Gain:");
307 ui.add_enabled(!disabled, egui::Slider::new(&mut state.edit.gain_db, -24.0..=24.0).suffix(" dB"));
308 if ui.add_enabled(!disabled, egui::Button::new("Apply gain")).clicked() {
309 state.apply_edit_gain();
310 }
311 });
312
313 // Normalize
314 ui.horizontal(|ui| {
315 ui.label("Normalize:");
316 // M-13: switching mode resets the target to the canonical default for
317 // the new mode (Peak: -1.0 dBFS, LUFS: -14.0 LUFS). The carried-over
318 // value is meaningless across modes, so snap to a sane starting point.
319 if ui.add_enabled(!disabled, egui::RadioButton::new(state.edit.norm_peak, "Peak")).clicked() {
320 if !state.edit.norm_peak {
321 state.edit.norm_target = -1.0;
322 }
323 state.edit.norm_peak = true;
324 }
325 if ui.add_enabled(!disabled, egui::RadioButton::new(!state.edit.norm_peak, "LUFS")).clicked() {
326 if state.edit.norm_peak {
327 state.edit.norm_target = -14.0;
328 }
329 state.edit.norm_peak = false;
330 }
331 });
332
333 let norm_range = if state.edit.norm_peak { -24.0..=0.0 } else { -24.0..=-6.0 };
334 let norm_suffix = if state.edit.norm_peak { " dBFS" } else { " LUFS" };
335
336 ui.horizontal(|ui| {
337 ui.add_enabled(!disabled, egui::Slider::new(&mut state.edit.norm_target, norm_range).suffix(norm_suffix));
338 if ui.add_enabled(!disabled, egui::Button::new("Normalize")).clicked() {
339 state.apply_edit_normalize();
340 }
341 });
342 }
343
344 /// Transform section: reverse and fade.
345 fn draw_transform_section(ui: &mut egui::Ui, state: &mut BrowserState) {
346 let disabled = state.edit.in_progress || state.edit.hash.is_none();
347
348 ui.label(egui::RichText::new("Transform").strong());
349
350 // Reverse
351 if ui.add_enabled(!disabled, egui::Button::new("Reverse")).clicked() {
352 state.apply_edit_reverse();
353 }
354
355 // Fade
356 ui.horizontal(|ui| {
357 ui.label("Fade:");
358 if ui.add_enabled(!disabled, egui::RadioButton::new(state.edit.fade_in, "In")).clicked() {
359 state.edit.fade_in = true;
360 }
361 if ui.add_enabled(!disabled, egui::RadioButton::new(!state.edit.fade_in, "Out")).clicked() {
362 state.edit.fade_in = false;
363 }
364 });
365
366 ui.horizontal(|ui| {
367 // m-8: cap raised from 2000 to 10000 ms. Long pads / textures can want
368 // multi-second fades; the old 2-second ceiling was invisible until hit.
369 ui.add_enabled(
370 !disabled,
371 egui::Slider::new(&mut state.edit.fade_duration_ms, 10.0..=10000.0).suffix(" ms"),
372 )
373 .on_hover_text("Maximum fade duration 10s");
374 egui::ComboBox::from_id_salt("edit_fade_curve")
375 .selected_text(match state.edit.fade_curve {
376 FadeCurve::Linear => "Linear",
377 FadeCurve::Logarithmic => "Log",
378 FadeCurve::SCurve => "S-Curve",
379 })
380 .width(70.0)
381 .show_ui(ui, |ui| {
382 ui.selectable_value(&mut state.edit.fade_curve, FadeCurve::Linear, "Linear");
383 ui.selectable_value(&mut state.edit.fade_curve, FadeCurve::Logarithmic, "Log");
384 ui.selectable_value(&mut state.edit.fade_curve, FadeCurve::SCurve, "S-Curve");
385 });
386 if ui.add_enabled(!disabled, egui::Button::new("Apply fade")).clicked() {
387 state.apply_edit_fade();
388 }
389 });
390 }
391
392 /// Silence section: insert or remove silence.
393 fn draw_silence_section(ui: &mut egui::Ui, state: &mut BrowserState) {
394 let disabled = state.edit.in_progress || state.edit.hash.is_none();
395
396 ui.label(egui::RichText::new("Silence").strong());
397
398 // m-9: clamp Insert/Remove positions to [0, sample_duration_ms] when the
399 // sample's analysis duration is known. Falls back to the previous unbounded
400 // range only if duration is missing (un-analyzed sample). Prevents the
401 // silent-failure / undefined-behaviour case where positions exceed length.
402 let duration_ms_cap = state
403 .selected_analysis
404 .as_ref()
405 .map(|a| a.duration * 1000.0)
406 .unwrap_or(f64::MAX);
407
408 // Insert silence
409 ui.horizontal(|ui| {
410 ui.label("Insert at:");
411 ui.add_enabled(
412 !disabled,
413 egui::DragValue::new(&mut state.edit.silence_position_ms)
414 .speed(10.0)
415 .range(0.0..=duration_ms_cap)
416 .suffix(" ms"),
417 );
418 ui.label("Duration:");
419 ui.add_enabled(
420 !disabled,
421 egui::DragValue::new(&mut state.edit.silence_duration_ms)
422 .speed(10.0)
423 .range(1.0..=60000.0)
424 .suffix(" ms"),
425 );
426 if ui.add_enabled(!disabled, egui::Button::new("Insert")).clicked() {
427 state.apply_edit_insert_silence();
428 }
429 });
430
431 // Remove range
432 ui.horizontal(|ui| {
433 ui.label("Remove from:");
434 ui.add_enabled(
435 !disabled,
436 egui::DragValue::new(&mut state.edit.remove_start_ms)
437 .speed(10.0)
438 .range(0.0..=duration_ms_cap)
439 .suffix(" ms"),
440 );
441 ui.label("to:");
442 ui.add_enabled(
443 !disabled,
444 egui::DragValue::new(&mut state.edit.remove_end_ms)
445 .speed(10.0)
446 .range(0.0..=duration_ms_cap)
447 .suffix(" ms"),
448 );
449 if ui.add_enabled(!disabled, egui::Button::new("Remove")).clicked() {
450 state.apply_edit_remove_range();
451 }
452 });
453 }
454
455 /// Batch edit section: apply operations to all selected samples.
456 fn draw_batch_section(ui: &mut egui::Ui, state: &mut BrowserState) {
457 let selected_count = state.selected_sample_hashes().len();
458 if selected_count < 2 {
459 return;
460 }
461
462 // m-13: heading is plain strong (matches other section headers). The
463 // batch-vs-single distinction moves to a small muted badge so adjacent
464 // sections stay visually balanced.
465 ui.horizontal(|ui| {
466 ui.label(egui::RichText::new("Batch Edit").strong());
467 ui.label(
468 egui::RichText::new(format!("Batch \u{00B7} {} samples", selected_count))
469 .small()
470 .color(theme::text_muted()),
471 );
472 });
473 ui.label(
474 egui::RichText::new("Apply to all selected samples at once")
475 .small()
476 .color(theme::text_muted()),
477 );
478 ui.add_space(theme::space::SM);
479
480 // M-14: bake the panel's current slider values into the button labels so
481 // the broadcast nature is explicit. Removes the silent-piggyback footgun
482 // where the user couldn't tell what value the batch button would use.
483 // Hover hints are dropped — the label now carries the value.
484 let norm_target = state.edit.norm_target;
485 let gain_db = state.edit.gain_db;
486 ui.horizontal(|ui| {
487 if ui
488 .button(format!(
489 "Normalize {} samples to {:.1} dBFS",
490 selected_count, norm_target
491 ))
492 .clicked()
493 {
494 state.batch_normalize_peak(norm_target);
495 }
496 if ui
497 .button(format!(
498 "Normalize {} samples to {:.1} LUFS",
499 selected_count, norm_target
500 ))
501 .clicked()
502 {
503 state.batch_normalize_lufs(norm_target);
504 }
505 });
506 ui.horizontal(|ui| {
507 if ui
508 .button(format!("Apply {:.1} dB to {} samples", gain_db, selected_count))
509 .clicked()
510 {
511 state.batch_gain(gain_db);
512 }
513 if ui.button("Reverse").clicked() {
514 // m-16: gate large-batch Reverse behind a confirm modal. Single-
515 // sample Reverse is its own undo (click again), but on N samples
516 // the "click again to undo" trick requires remembering it ran in
517 // the first place. Threshold 10 keeps small selections frictionless.
518 if selected_count > 10 {
519 state.pending_confirm = Some(
520 crate::state::ConfirmAction::ReverseSamples { count: selected_count },
521 );
522 } else {
523 state.batch_reverse();
524 }
525 }
526 });
527 }
528
529 /// Result mode section: replace original vs create sibling.
530 fn draw_result_section(ui: &mut egui::Ui, state: &mut BrowserState) {
531 ui.label(egui::RichText::new("Result").strong());
532
533 let mut mode = state.edit.result_mode;
534 ui.horizontal(|ui| {
535 if ui.radio_value(&mut mode, Some(EditResultMode::Replace), "Replace original").changed()
536 && let Some(m) = mode {
537 state.set_edit_result_mode(m);
538 }
539 if ui.radio_value(&mut mode, Some(EditResultMode::Sibling), "Create sibling").changed()
540 && let Some(m) = mode {
541 state.set_edit_result_mode(m);
542 }
543 });
544 // Replace mode advisory. The previous "no in-app undo" copy was retired
545 // when the inline Undo affordance landed (C-1 part 2). The hint still
546 // points the user at Create sibling as the non-destructive default for
547 // workflows that want to keep the original in the VFS.
548 if matches!(state.edit.result_mode, Some(EditResultMode::Replace)) {
549 // Promoted from small yellow footnote to a framed warning banner — the
550 // consequence (original removed from the vault) is important enough that
551 // the footnote style under-sold it.
552 widgets::warning_banner(
553 ui,
554 "Replace mode: the original is removed from this vault. Use Create sibling to keep both, or click Undo on the next line within 10s to revert.",
555 );
556 }
557
558 // C-1 part 2: inline Undo for the most recent edit. The content store is
559 // content-addressed so the original sample blob is preserved on Replace —
560 // Undo only walks back the VFS work. Times out after 10s to avoid
561 // surprising the user with a stale affordance after they've moved on.
562 const UNDO_TIMEOUT_SECS: f32 = 10.0;
563 let undo_expired = state
564 .edit
565 .last_undo
566 .as_ref()
567 .map(|e| e.created_at.elapsed().as_secs_f32() > UNDO_TIMEOUT_SECS)
568 .unwrap_or(false);
569 if undo_expired {
570 state.edit.last_undo = None;
571 }
572 if let Some(ref entry) = state.edit.last_undo {
573 let op_label = entry.op_name.clone();
574 let remaining = std::time::Duration::from_secs_f32(UNDO_TIMEOUT_SECS)
575 .saturating_sub(entry.created_at.elapsed());
576 // Wake the UI when the affordance is due to expire so it disappears
577 // even if the user isn't interacting with the panel.
578 ui.ctx().request_repaint_after(remaining);
579 ui.horizontal(|ui| {
580 ui.label(
581 egui::RichText::new(format!("Last edit: {op_label}"))
582 .small()
583 .color(theme::text_muted()),
584 );
585 if ui.small_button("Undo").clicked() {
586 state.undo_last_edit();
587 }
588 });
589 }
590 }
591
592 /// Draw the "Replace or Create Sibling?" prompt after first edit.
593 fn draw_result_prompt(ui: &mut egui::Ui, state: &mut BrowserState) {
594 egui::Frame::popup(ui.style()).show(ui, |ui| {
595 ui.heading("Edit Result");
596 ui.separator();
597 ui.label("How should the edited sample be handled?");
598 ui.add_space(theme::space::MD);
599
600 // m-10: initialise from the persistent result_mode so the checkbox
601 // reflects whether the user has already locked in a default. The
602 // Result section radios are the source of truth; this checkbox mirrors
603 // their state for visibility, then writes through on submit.
604 let mut remember = state.edit.result_mode.is_some();
605 ui.checkbox(&mut remember, "Remember my choice");
606
607 ui.add_space(theme::space::SM);
608 ui.horizontal(|ui| {
609 if ui.button("Replace Original").clicked() {
610 state.confirm_edit_result(EditResultMode::Replace, remember);
611 }
612 if ui.button("Create Sibling").clicked() {
613 state.confirm_edit_result(EditResultMode::Sibling, remember);
614 }
615 // M-12: third option lets the user back out of the prompt without
616 // committing the edit. Drops the pending result file.
617 if ui.button("Discard edit").clicked() {
618 state.discard_edit_result();
619 }
620 });
621 });
622 }
623