diff --git a/DESIGN.md b/DESIGN.md index a157937..bbe9a86 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -336,17 +336,13 @@ lays the paragraph out, so a click on one lands at its edge and the caret steps over it, and a test holds the two lengths equal for every paragraph in the corpus. -**A slide's label is edited in a text box where it sits**, in edit mode, on a -click: the box shows the paragraph's flat text unformatted at the paragraph's -own font, size, colour, indent and width, on the page's paper. Escape puts -the paragraph back, Ctrl+Enter or a click elsewhere keeps what was typed, -Enter is a new paragraph once it is kept, and Backspace with the caret at the -very start joins the paragraph onto the one before it, the box reopening -there with the caret at the join. The labels are addressed from the page by -the shape's index; in edit mode a drag on a slide moves the shape and does not -select its text. What is written is the difference between what the box was -given and what it hands back, so a span or a marker outside the change is -untouched. +**A slide's label is edited the same way, on the slide.** Its paragraphs are +named by their path from the page, which begins with the shape's index, and +the caret keeps to the shape it is in: moving and joining never cross into +another shape, and a click in another moves it there. A drag on a slide moves +the shape, so a label's paragraphs take clicks alone and the caret goes down +on the click; Shift with the arrows selects. The arrows and the page keys step +through the slides only while nothing has the keyboard. **A paragraph is edited through its flat text**, built from the tree and not from the renderer's layout: a `text:s` is its spaces, a `text:tab` a tab, a diff --git a/README.md b/README.md index bcf4cff..200776e 100644 --- a/README.md +++ b/README.md @@ -109,9 +109,8 @@ spreadsheet application recalculates it. `xodp` draws each slide at the size the document sets, with the speaker's notes under it. In edit mode a click picks one of the slide's shapes, a drag moves it, -a drag on a corner resizes it, and a click on the text in one opens it as a -text box where it sits: Escape puts it back, Ctrl+Enter or a click elsewhere -keeps what you typed. +a drag on a corner resizes it, and a click on the text in one puts the caret +there to type into it as in `xodt`, within that shape. Each application opens one kind of file and says so when handed another, naming the sibling that reads it. diff --git a/crates/egui_richedit/src/editor.rs b/crates/egui_richedit/src/editor.rs index e78f5f6..1a4e7f5 100644 --- a/crates/egui_richedit/src/editor.rs +++ b/crates/egui_richedit/src/editor.rs @@ -203,7 +203,8 @@ impl RichEdit

{ /// what the pointer does on it. Called for every editable paragraph in /// document order, on screen or not, after [`Self::input`]. /// - /// `response` is the paragraph's, allocated to sense clicks and drags. + /// `response` is the paragraph's, allocated to sense clicks and, for a + /// drag to select, drags. pub fn paragraph(&mut self, ui: &Ui, response: &Response, paragraph: &P, laid: Laid) { let Laid { mut galley, @@ -714,11 +715,15 @@ impl RichEdit

{ // A press and its release can arrive in one frame, a quick click on a // slow frame, and then the button is no longer down on anything; the - // click says where it was. - if pressed - && (response.is_pointer_button_down_on() || response.clicked()) - && let Some(pos) = pos - { + // click says where it was. A paragraph that leaves drags to what is + // under it takes the click alone, so a press that becomes a drag puts + // no caret down. + let placed = if response.sense.senses_drag() { + pressed && (response.is_pointer_button_down_on() || response.clicked()) + } else { + response.clicked() + }; + if placed && let Some(pos) = pos { let cursor = at_pointer(pos); let to = Position::new(paragraph.clone(), map.to_model(cursor.index.0)); match &mut self.selection { @@ -728,7 +733,9 @@ impl RichEdit

{ self.next_row = cursor.prefer_next_row; self.group = None; self.column = None; - self.dragging = true; + // A paragraph that does not sense drags leaves them to what is + // under it, as a slide does to move the shape. + self.dragging = response.sense.senses_drag(); self.last_interaction = ui.input(|input| input.time); ui.memory_mut(|memory| memory.request_focus(self.id)); } else if self.dragging && !down { diff --git a/crates/egui_richedit/tests/editing.rs b/crates/egui_richedit/tests/editing.rs index ab6f7b0..3881f40 100644 --- a/crates/egui_richedit/tests/editing.rs +++ b/crates/egui_richedit/tests/editing.rs @@ -513,3 +513,55 @@ fn page_down_moves_a_view_s_height_and_page_up_comes_back() { h.key(Key::PageUp); assert_eq!(h.focus(), Position::new(0, 2)); } + +#[test] +fn a_paragraph_that_leaves_drags_takes_a_caret_on_a_click_and_not_on_a_drag() { + let ctx = Context::default(); + let mut editor = RichEdit::new(Id::new("editor")); + let mut model = Plain::new(&["a label on a slide"]); + let mut frame = |events: Vec| { + let input = RawInput { + events, + screen_rect: Some(Rect::from_min_size(Pos2::ZERO, vec2(400.0, 400.0))), + ..RawInput::default() + }; + let mut output = ctx.run_ui(input, |ui| { + editor.input(ui, &mut model); + let mut job = ParagraphJob::new(LayoutJob::default()); + job.text(&model.paragraphs[0], TextFormat::default()); + let (job, map) = job.into_parts(); + let galley = ui.fonts_mut(|fonts| fonts.layout_job(job)); + let (rect, response) = + ui.allocate_exact_size(vec2(300.0, galley.size().y), Sense::click()); + let laid = Laid { + galley, + map, + origin: rect.min, + }; + editor.paragraph(ui, &response, &0, laid); + }); + output.textures_delta.clear(); + editor.selection().cloned() + }; + let button = |x: f32, pressed| Event::PointerButton { + pos: Pos2::new(x, 5.0), + button: egui::PointerButton::Primary, + pressed, + modifiers: Modifiers::NONE, + }; + frame(vec![Event::PointerMoved(Pos2::new(5.0, 5.0))]); + frame(vec![button(5.0, true)]); + for x in [40.0, 80.0, 120.0] { + frame(vec![Event::PointerMoved(Pos2::new(x, 5.0))]); + } + assert_eq!( + frame(vec![button(120.0, false)]), + None, + "a drag is not a click" + ); + + frame(vec![Event::PointerMoved(Pos2::new(5.0, 5.0))]); + frame(vec![button(5.0, true)]); + let clicked = frame(vec![button(5.0, false)]).expect("a click puts a caret down"); + assert_eq!(clicked.focus.paragraph, 0); +} diff --git a/crates/odox-core/src/doc/mod.rs b/crates/odox-core/src/doc/mod.rs index 7dace55..e952a39 100644 --- a/crates/odox-core/src/doc/mod.rs +++ b/crates/odox-core/src/doc/mod.rs @@ -72,6 +72,19 @@ impl Document { self.body()?.child(&Ns::Office, local) } + /// Where [`Self::body_of`] is under the content root, as a path of child + /// indices: what an editor addresses the body's paragraphs from. + pub fn body_path(&self, local: &str) -> Option> { + let (at_body, body) = self + .content + .elements_indexed() + .find(|(_, e)| e.is(&Ns::Office, "body"))?; + let (at_part, _) = body + .elements_indexed() + .find(|(_, e)| e.is(&Ns::Office, local))?; + Some(vec![at_body, at_part]) + } + /// A name for writing into the document, in the prefix the document /// declares for the namespace on its content root, or the conventional one /// where it declares none. diff --git a/crates/odox-core/src/doc/pres.rs b/crates/odox-core/src/doc/pres.rs index 99bfcbb..23d5972 100644 --- a/crates/odox-core/src/doc/pres.rs +++ b/crates/odox-core/src/doc/pres.rs @@ -151,6 +151,15 @@ impl Presentation { .at_mut(&[position]) } + /// Where the slide at a position among the body's children is under the + /// content root, as a path of child indices: what an editor addresses its + /// labels' paragraphs from. + pub fn page_path(&self, position: usize) -> Option> { + let mut path = self.document.body_path("presentation")?; + path.push(position); + Some(path) + } + /// The master page a slide names. pub fn master(&self, slide: &Slide<'_>) -> Option<&Element> { self.document.styles.master_page(slide.master_page?) diff --git a/crates/odox-core/src/doc/text.rs b/crates/odox-core/src/doc/text.rs index a2fed94..a44a15b 100644 --- a/crates/odox-core/src/doc/text.rs +++ b/crates/odox-core/src/doc/text.rs @@ -50,15 +50,7 @@ impl TextDocument { /// Where [`Self::body`] is under the content root, as a path of child /// indices: what an editor addresses the body's paragraphs from. pub fn body_path(&self) -> Option> { - let (at_body, body) = self - .document - .content - .elements_indexed() - .find(|(_, e)| e.is(&Ns::Office, "body"))?; - let (at_text, _) = body - .elements_indexed() - .find(|(_, e)| e.is(&Ns::Office, "text"))?; - Some(vec![at_body, at_text]) + self.document.body_path("text") } /// The page layout the document's first master page points at. diff --git a/crates/odox-ui/src/flow.rs b/crates/odox-ui/src/flow.rs index a518499..f89f004 100644 --- a/crates/odox-ui/src/flow.rs +++ b/crates/odox-ui/src/flow.rs @@ -16,11 +16,8 @@ use std::collections::HashMap; use eframe::egui::{ - Align, ColorImage, Context, Id, Key, Pos2, Rect, Sense, Stroke, StrokeKind, TextEdit, - TextFormat, TextureHandle, TextureOptions, Ui, pos2, - text::{CCursor, CCursorRange, CharIndex, LayoutJob}, - text_selection::LabelSelectionState, - vec2, + Align, ColorImage, Context, Pos2, Rect, Sense, Stroke, StrokeKind, TextFormat, TextureHandle, + TextureOptions, Ui, pos2, text::LayoutJob, text_selection::LabelSelectionState, vec2, }; use egui_richedit::{Laid, ParagraphJob}; use odox_core::{ @@ -87,16 +84,6 @@ pub struct Flow<'a> { pub scroll_to_heading: Option, /// How many headings have been drawn this pass. headings_seen: usize, - /// Whether a click on a paragraph opens it for editing. - pub edit_mode: bool, - /// The paragraph being edited, where it is under the root this flow - /// draws. - pub editor: Option<&'a mut Editor>, - /// The paragraph a person clicked this frame in edit mode, as a path from - /// the root. - pub clicked: Option>, - /// What the editor asked for this frame, if it closed. - pub outcome: Option, /// The page editor, which puts a caret in every paragraph under the root /// and paints them. Outside edit mode there is none. pub page: Option<&'a mut PageEditor>, @@ -121,10 +108,6 @@ impl<'a> Flow<'a> { palette: format::Palette::default(), scroll_to_heading: None, headings_seen: 0, - edit_mode: false, - editor: None, - clicked: None, - outcome: None, page: None, selectable: true, path: Vec::new(), @@ -140,57 +123,6 @@ impl<'a> Flow<'a> { } } -/// A paragraph being typed into: which one, as a path of child indices from -/// the root the flow draws, and what the text box holds. -/// -/// The box shows the paragraph's text as [`odox_core::edit::text`] gives it, -/// unformatted, at the paragraph's own font, size and width, and hands it -/// back whole; the difference between what went in and what comes out is -/// what is written. DESIGN.md §11. -pub struct Editor { - /// The paragraph, from the root the flow was asked to draw. - pub path: Vec, - /// What the box holds. - pub text: String, - /// What the paragraph held when the box opened, so that leaving it as it - /// was is not an edit. - pub original: String, - /// The box was opened this frame and has yet to take the focus. - pub opened: bool, - /// Where the caret goes when the box opens: at an offset, or at the end. - pub caret: Option, -} - -impl Editor { - /// A box on the paragraph at a path, holding its text. - pub fn open(path: Vec, text: String, caret: Option) -> Self { - Self { - path, - original: text.clone(), - text, - opened: true, - caret, - } - } - - /// Whether what the box holds differs from what the paragraph held. - pub fn changed(&self) -> bool { - self.text != self.original - } -} - -/// What the editor asked for when it closed. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum Outcome { - /// Keep what was typed. - Commit, - /// Put the paragraph back as it was. - Cancel, - /// Backspace at the start: keep what was typed, then join this paragraph - /// onto the one before it. - JoinPrevious, -} - /// How far each list level is indented, in ODF points. const LIST_STEP: f32 = 18.0; /// The space a list label is drawn in, left of its item's text. @@ -350,27 +282,6 @@ impl Flow<'_> { let base = format::text_format(&properties.text, DEFAULT_SIZE, zoom, self.palette); - if !self.detached && self.editor.as_ref().is_some_and(|e| e.path == self.path) { - // The box takes the paragraph's first run's format, so a title - // whose size lives on its span opens at the title's size. - let (job, _) = self.layout_job(element, &properties, &base, size, wrap); - let (job, _) = job.into_parts(); - let first = job - .sections - .first() - .map_or_else(|| base.clone(), |section| section.format.clone()); - // Taken out and put back, because the box is drawn by a method of - // this flow and the editor is borrowed from outside it. - let mut editor = self.editor.take(); - if let Some(editor) = editor.as_deref_mut() { - self.edit_box(ui, editor, &first, left, wrap); - } - self.editor = editor; - if after > 0.0 { - ui.add_space(after); - } - return; - } let (job, frames) = self.layout_job(element, &properties, &base, size, wrap); let (job, map) = job.into_parts(); @@ -383,18 +294,15 @@ impl Flow<'_> { // what lets a double-click take a word and a triple-click a line. // Without the drag the pointer reaches the scroll area instead and // nothing is selected — measured, not read. The page editor selects - // the same way. - let sense = if self.selectable || edited { + // the same way, and on a slide in edit mode, where a drag moves the + // shape, it takes clicks alone. + let sense = if self.selectable { Sense::click_and_drag() } else { Sense::click() }; let (rect, response) = ui.allocate_exact_size(vec2(width, height), sense); - if self.edit_mode && !self.detached && response.clicked() { - self.clicked = Some(self.path.clone()); - } - if element.is(&Ns::Text, "h") { if self.scroll_to_heading == Some(self.headings_seen) { ui.scroll_to_rect(rect, Some(Align::TOP)); @@ -518,94 +426,6 @@ impl Flow<'_> { (job, frames) } - /// The text box a paragraph becomes while it is being typed into, at the - /// paragraph's own font, colour, indent and width, on the page's paper. - /// - /// Escape puts the paragraph back, Ctrl+Enter or a click elsewhere keeps - /// what was typed, Enter is a new paragraph once it is kept, and Backspace - /// with the caret at the very start joins the paragraph onto the one - /// before it. Read before the box takes the keys, because the box - /// consumes what it handles. - fn edit_box( - &mut self, - ui: &mut Ui, - editor: &mut Editor, - base: &TextFormat, - left: f32, - wrap: f32, - ) { - let id = Id::new("paragraph-editor"); - let at_start = TextEdit::load_state(ui.ctx(), id) - .and_then(|state| state.cursor.char_range()) - .is_some_and(|range| { - range.primary.index == CharIndex(0) && range.secondary.index == CharIndex(0) - }); - let (backspace, keep) = ui.input(|input| { - ( - input.key_pressed(Key::Backspace), - input.modifiers.command && input.key_pressed(Key::Enter), - ) - }); - if !editor.opened && at_start && backspace && self.outcome.is_none() { - self.outcome = Some(Outcome::JoinPrevious); - } - - // A sizing pass is an invisible ui, and an invisible ui is disabled: a - // text box added in one surrenders the focus the real one just took. - // So the pass gets the box's height and no box. - if ui.is_sizing_pass() { - let galley = ui.fonts_mut(|fonts| { - fonts.layout(editor.text.clone(), base.font_id.clone(), base.color, wrap) - }); - ui.horizontal_top(|ui| { - ui.add_space(left); - ui.allocate_exact_size(vec2(wrap, galley.size().y + 4.0), Sense::hover()); - }); - return; - } - - let response = ui - .horizontal_top(|ui| { - ui.add_space(left); - ui.add( - TextEdit::multiline(&mut editor.text) - .id(id) - .font(base.font_id.clone()) - .text_color(base.color) - .background_color(self.palette.paper) - .desired_width(wrap) - .desired_rows(1) - .margin(vec2(2.0, 2.0)), - ) - }) - .inner; - - if editor.opened { - editor.opened = false; - response.request_focus(); - let mut state = TextEdit::load_state(ui.ctx(), id).unwrap_or_default(); - let at = editor - .caret - .unwrap_or_else(|| editor.text.chars().count()) - .min(editor.text.chars().count()); - state - .cursor - .set_char_range(Some(CCursorRange::one(CCursor::new(at)))); - TextEdit::store_state(ui.ctx(), id, state); - return; - } - if keep && response.has_focus() { - response.surrender_focus(); - self.outcome = Some(Outcome::Commit); - } else if response.lost_focus() && self.outcome.is_none() { - self.outcome = Some(if ui.input(|input| input.key_pressed(Key::Escape)) { - Outcome::Cancel - } else { - Outcome::Commit - }); - } - } - /// Append the text of a paragraph's children to a layout job. /// /// Anything that is not text is either resolved into characters — ODF spells diff --git a/crates/odox-ui/src/flow_model.rs b/crates/odox-ui/src/flow_model.rs index 5e5ab13..816837b 100644 --- a/crates/odox-ui/src/flow_model.rs +++ b/crates/odox-ui/src/flow_model.rs @@ -38,6 +38,9 @@ pub struct FlowModel<'a> { /// Where the flow's root is under the content root. root: Vec, editing: &'a mut Editing, + /// The part of the root the paragraphs are kept to, as a path under it: + /// on a slide, the one shape being typed into. + scope: Vec, /// Every editable paragraph in drawing order, found when first asked for /// and forgotten when an edit changes it. order: OnceCell>>, @@ -50,10 +53,20 @@ impl<'a> FlowModel<'a> { content, root, editing, + scope: Vec::new(), order: OnceCell::new(), } } + /// Keep to the paragraphs under one element beneath the root, so that + /// moving and joining never leave it: a slide's label does not run on + /// into the next shape. + #[must_use] + pub fn within(mut self, scope: Vec) -> Self { + self.scope = scope; + self + } + fn root(&self) -> Option<&Element> { self.content.at(&self.root) } @@ -71,6 +84,7 @@ impl<'a> FlowModel<'a> { if let Some(root) = self.root() { blocks(root, &mut Vec::new(), &mut out); } + out.retain(|path| path.starts_with(&self.scope)); out }) } @@ -248,6 +262,10 @@ fn block(element: &Element, path: &mut Vec, out: &mut Vec>) { blocks(text_box, path, out); path.pop(); } + } else if element.name.ns == Ns::Draw { + // A drawing shape keeps its label as paragraphs of its own, where a + // frame keeps them in a text box. + blocks(element, path, out); } else if is_block_container(element) { blocks(element, path, out); } diff --git a/crates/odox-ui/src/lib.rs b/crates/odox-ui/src/lib.rs index 1cca320..ba65888 100644 --- a/crates/odox-ui/src/lib.rs +++ b/crates/odox-ui/src/lib.rs @@ -38,7 +38,6 @@ pub mod shell; pub mod system_theme; pub use edit::{Caret, Editing}; -pub use flow::{Editor as ParagraphEditor, Outcome as ParagraphOutcome}; pub use flow::{Flow, Pictures}; pub use flow_model::{FlowModel, PageEditor, page_editor}; pub use i18n::mark; diff --git a/crates/odox-ui/src/shapes.rs b/crates/odox-ui/src/shapes.rs index e1e199d..a07b1ab 100644 --- a/crates/odox-ui/src/shapes.rs +++ b/crates/odox-ui/src/shapes.rs @@ -25,7 +25,8 @@ use odox_core::{ Properties, Transform, }; -use crate::flow::{Editor, Flow, Outcome, Pictures}; +use crate::flow::{Flow, Pictures}; +use crate::flow_model::PageEditor; use crate::format::{self, Palette}; /// A page, and where on screen it is being drawn. @@ -40,16 +41,10 @@ pub struct Canvas<'a> { pub scale: f32, /// The colours to draw in where the document names none. pub palette: Palette, - /// Whether a click on a label's paragraph opens it for editing. - pub edit_mode: bool, - /// The paragraph being edited, as a path from the page: the shape's index - /// among the page's children, then the way down to the paragraph. - pub editor: Option<&'a mut Editor>, - /// The paragraph a person clicked this frame in edit mode, as a path from - /// the page. - pub clicked: Option>, - /// What the editor asked for this frame, if it closed. - pub outcome: Option, + /// The page editor, in edit mode: the slide's own labels are typed into + /// through it, each paragraph named by its path from the page, which is + /// the shape's index among the page's children and then the way down. + pub page_editor: Option<&'a mut PageEditor>, /// The index among the page's children of the shape being drawn, where it /// is one of the slide's own; a master page's decoration has none and /// nothing in it is edited. @@ -125,10 +120,7 @@ impl<'a> Canvas<'a> { page, scale, palette, - edit_mode: false, - editor: None, - clicked: None, - outcome: None, + page_editor: None, at: None, } } @@ -522,44 +514,35 @@ impl Canvas<'_> { let document = self.document; let pictures = &mut *self.pictures; // Editable where the label belongs to one of the slide's own shapes, - // which is what a prefix says. - let edit_mode = self.edit_mode && prefix.is_some(); - let editor = self.editor.as_deref_mut().filter(|_| edit_mode); + // which is what a prefix says, and in the pass that draws it: the + // measuring pass draws nothing, and the editor would take its clicks. + let edit_mode = self.page_editor.is_some() && prefix.is_some(); + let page_editor = self + .page_editor + .as_deref_mut() + .filter(|_| edit_mode && !measuring); let mut builder = UiBuilder::new().max_rect(rect); if measuring { builder = builder.sizing_pass().invisible(); } - let mut clicked = None; - let mut outcome = None; - let height = ui - .scope_builder(builder, |ui| { - ui.set_clip_rect(rect.intersect(page)); - let mut flow = Flow::new(document, pictures, scale); - flow.palette = palette; - flow.edit_mode = edit_mode; - flow.selectable = !edit_mode; - flow.editor = editor; - if let Some(prefix) = prefix { - flow.start_at(prefix.clone()); - } - if picture { - flow.frame(ui, content, rect.width()); - } else { - flow.blocks(ui, content, rect.width()); - } - clicked = flow.clicked.take(); - outcome = flow.outcome.take(); - }) - .response - .rect - .height(); - if clicked.is_some() { - self.clicked = clicked; - } - if outcome.is_some() { - self.outcome = outcome; - } - height + ui.scope_builder(builder, |ui| { + ui.set_clip_rect(rect.intersect(page)); + let mut flow = Flow::new(document, pictures, scale); + flow.palette = palette; + flow.selectable = !edit_mode; + flow.page = page_editor; + if let Some(prefix) = prefix { + flow.start_at(prefix.clone()); + } + if picture { + flow.frame(ui, content, rect.width()); + } else { + flow.blocks(ui, content, rect.width()); + } + }) + .response + .rect + .height() } /// The style a shape names, resolved. diff --git a/crates/xodp/src/view.rs b/crates/xodp/src/view.rs index 92f77ed..757e121 100644 --- a/crates/xodp/src/view.rs +++ b/crates/xodp/src/view.rs @@ -13,12 +13,11 @@ use std::path::Path; use eframe::egui::{self, Key, Pos2, Rect, Sense, Stroke, StrokeKind, Ui, pos2, vec2}; use odox_core::doc::Presentation; -use odox_core::{Document, Element, Length, Ns, edit}; +use odox_core::{Document, Element, Length, Ns}; use odox_ui::i18n::{fill, t}; -use odox_ui::{Canvas, Editing, Flow, ParagraphEditor, ParagraphOutcome, Pictures, View, fonts}; +use odox_ui::{Canvas, Editing, Flow, FlowModel, PageEditor, Pictures, View, fonts, page_editor}; /// A presentation, open or not. -#[derive(Default)] pub struct SlideView { document: Option, slide: usize, @@ -28,8 +27,23 @@ pub struct SlideView { picked: Option, /// A drag in progress on the picked shape. drag: Option, - /// The label paragraph being typed into, as a path from the page. - label: Option, + /// The caret in a label, in edit mode, its paragraphs named by their + /// path from the page. + page_editor: PageEditor, +} + +impl Default for SlideView { + fn default() -> Self { + Self { + document: None, + slide: 0, + pictures: Pictures::default(), + show_notes: false, + picked: None, + drag: None, + page_editor: page_editor(), + } + } } /// A shape's box on the page, in ODF points. @@ -96,7 +110,7 @@ impl View for SlideView { self.slide = 0; self.picked = None; self.drag = None; - self.label = None; + self.page_editor.clear(); Ok(()) } @@ -105,7 +119,7 @@ impl View for SlideView { self.pictures.clear(); self.picked = None; self.drag = None; - self.label = None; + self.page_editor.clear(); } fn is_open(&self) -> bool { @@ -122,7 +136,7 @@ impl View for SlideView { fn reindex(&mut self) { self.drag = None; - self.label = None; + self.page_editor.document_replaced(); } fn central(&mut self, ui: &mut Ui, zoom: f32, editing: &mut Editing) { @@ -136,10 +150,18 @@ impl View for SlideView { }); return; } - self.step_keys(ui, count); - if ui.input(|input| input.key_pressed(Key::Escape)) && self.label.is_none() { + // The arrows and the page keys step through the slides unless + // something has the keyboard, as the caret in a label does. + if !ui.ctx().egui_wants_keyboard_input() { + self.step_keys(ui, count); + } + if ui.input(|input| input.key_pressed(Key::Escape)) { self.picked = None; self.drag = None; + self.page_editor.clear(); + } + if !editing.on { + self.page_editor.clear(); } // The document and the picture cache are taken as separate borrows of @@ -151,13 +173,30 @@ impl View for SlideView { let picked = self.picked; let dragging = self.drag.is_some(); let mut action = None; - let mut clicked = None; - let mut outcome = None; + + // What was typed goes into the tree before it is drawn, kept to the + // one shape the caret is in. + if edit_mode + && let Some(document) = &mut self.document + && let Some(position) = document.slides().get(slide_index).map(|s| s.position) + && let Some(root) = document.page_path(position) + { + let scope = self + .page_editor + .selection() + .and_then(|selection| selection.focus.paragraph.first().copied()) + .into_iter() + .collect(); + let mut model = + FlowModel::new(&mut document.document.content, root, editing).within(scope); + self.page_editor.input(ui, &mut model); + } + let Some(document) = &self.document else { return; }; let pictures = &mut self.pictures; - let label = self.label.as_mut(); + let page_editor = edit_mode.then_some(&mut self.page_editor); let slides = document.slides(); let Some(slide) = slides.get(slide_index) else { return; @@ -197,8 +236,7 @@ impl View for SlideView { ui.painter().rect_filled(page, 2.0, palette.paper); let mut canvas = Canvas::new(&document.document, pictures, page, fit, palette); - canvas.edit_mode = edit_mode; - canvas.editor = label; + canvas.page_editor = page_editor; // Back to front: the ground, then what the master page draws on // every slide, then the slide's own. canvas.background(ui, &background); @@ -208,8 +246,6 @@ impl View for SlideView { for (index, shape) in slide.shapes_indexed() { canvas.slide_shape(ui, index, shape); } - clicked = canvas.clicked.take(); - outcome = canvas.outcome.take(); // The page's edge last, so a decoration running to the bleed // does not paint over it. @@ -226,13 +262,7 @@ impl View for SlideView { }); }); - if let Some(outcome) = outcome { - self.finish_label(outcome, editing); - } else if let Some(path) = clicked - && self.label.is_none() - { - self.begin_label(path); - } else if let Some(action) = action { + if let Some(action) = action { self.act(action, fit, editing); } } @@ -260,6 +290,7 @@ impl View for SlideView { self.slide = index; self.picked = None; self.drag = None; + self.page_editor.clear(); } } true @@ -272,61 +303,6 @@ impl View for SlideView { } impl SlideView { - /// Open the text box on a label's paragraph, by its path from the page. - fn begin_label(&mut self, path: Vec) { - let Some(document) = &self.document else { - return; - }; - let Some(paragraph) = document - .slides() - .get(self.slide) - .and_then(|slide| slide.element.at(&path)) - else { - return; - }; - self.drag = None; - self.label = Some(ParagraphEditor::open(path, edit::text(paragraph), None)); - } - - /// Close the text box on a label, writing what was typed where it was - /// kept; a join first keeps what was typed, then joins the paragraph onto - /// the one before it in the same label and opens the box again there. - fn finish_label(&mut self, outcome: ParagraphOutcome, editing: &mut Editing) { - let Some(editor) = self.label.take() else { - return; - }; - if outcome == ParagraphOutcome::Cancel { - return; - } - let Some(document) = &mut self.document else { - return; - }; - let Some(position) = document.slides().get(self.slide).map(|s| s.position) else { - return; - }; - if editor.changed() || outcome == ParagraphOutcome::JoinPrevious { - editing.record(&document.document.content); - } - let Some(page) = document.page_mut(position) else { - return; - }; - if editor.changed() { - let _ = edit::apply(page, &editor.path, &editor.text); - } - if outcome == ParagraphOutcome::JoinPrevious { - let previous_len = previous_paragraph_len(page, &editor.path); - if let Ok(joined) = edit::join_with_previous(page, &editor.path) - && let Some(paragraph) = page.at(&joined) - { - self.label = Some(ParagraphEditor::open( - joined, - edit::text(paragraph), - previous_len, - )); - } - } - } - /// Apply what the slide asked for. fn act(&mut self, action: Action, scale: f32, editing: &mut Editing) { match action { @@ -425,6 +401,7 @@ impl SlideView { self.slide = slide; self.picked = None; self.drag = None; + self.page_editor.clear(); } } } @@ -550,21 +527,6 @@ fn notes_panel( }); } -/// How long the paragraph before the one at a path is, which is where the -/// caret goes once the two are joined. -fn previous_paragraph_len(root: &Element, path: &[usize]) -> Option { - let (last, above) = path.split_last()?; - root.at(above)?.children[..*last] - .iter() - .rev() - .find_map(|node| match node { - odox_core::Node::Element(e) if e.is(&Ns::Text, "p") || e.is(&Ns::Text, "h") => { - Some(edit::text(e).chars().count()) - } - _ => None, - }) -} - /// A shape's box in the page's points, where it states one: a corner and a /// size and no transform. A shape placed by `draw:transform` states its place /// as operations and is not moved by writing a corner.