M7: Story Editor (⌘Y) with style column, overset marker and range-preserving edits

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Brandon Thomas 2026-10-01 11:03:02 -04:00
parent 1bc6f31747
commit d3516b96cf
4 changed files with 132 additions and 0 deletions

View File

@ -55,6 +55,31 @@ pub fn specs() -> Vec<CommandSpec> {
ok()
})
}),
cmd!(
"story.replaceRange",
"Edit Story",
[],
None,
"{story, start, end, text} — replace a byte range keeping surrounding formatting",
has_doc,
|s, p| {
let sid = StoryId(p.get("story").and_then(Value::as_u64).ok_or_else(|| bad("story.replaceRange", "missing story"))?);
let a = p.get("start").and_then(Value::as_u64).unwrap_or(0) as usize;
let b = p.get("end").and_then(Value::as_u64).map(|v| v as usize).unwrap_or(a);
let text = str_param(p, "text").unwrap_or("").to_string();
s.edit(|d, sel| {
let st = d.story_mut(sid).ok_or(designcraft_doc::DocError::NoStory(sid))?;
let (a, b) = (a.min(st.len()), b.min(st.len()).max(a.min(st.len())));
st.replace(a..b, &text);
let len = st.len();
if let Some(t) = sel.text.as_mut().filter(|t| t.story == sid) {
t.anchor = t.anchor.min(len);
t.focus = t.focus.min(len);
}
Ok(json!({"length": len}))
})
}
),
cmd!(query "story.get", "Get Story", [], None, "{story? | frame?} → text, frames, paragraphs, overset", has_doc, |s, p| {
let st = s.doc()?;
let sid = story_of(s, p).ok_or_else(|| bad("story.get", "no story"))?;

View File

@ -13,6 +13,7 @@ pub mod dock;
pub mod icons;
pub mod menus;
pub mod panels;
pub mod story_editor;
pub mod theme;
pub mod toolbar;
pub mod widgets;
@ -148,6 +149,8 @@ pub struct DesignApp {
pub canvas_rect: Option<egui::Rect>,
pub perf: Perf,
pub synthetic: Vec<egui::Event>,
/// Story open in the Story Editor.
pub story_editor: Option<designcraft_doc::StoryId>,
control_rx: Option<Receiver<ControlRequest>>,
pending_shots: Vec<(u64, Option<String>, Sender<ControlResponse>, f64)>,
queued_shots: Vec<(u64, f64, u32)>,
@ -172,6 +175,7 @@ impl DesignApp {
canvas_rect: None,
perf: Perf::default(),
synthetic: vec![],
story_editor: None,
control_rx: None,
pending_shots: vec![],
queued_shots: vec![],
@ -332,6 +336,7 @@ impl DesignApp {
canvas::show(self, ui);
});
dock::flyout(self, &ctx);
story_editor::show(self, &ctx);
dialogs::show(self, &ctx);
menus::palette(self, &ctx);
self.perf.frame_ms = now_ms() - t0;

View File

@ -14,6 +14,7 @@ pub const UI_COMMANDS: &[(&str, &str, Option<&str>, &str)] = &[
("app.exportPng", "Export Page as PNG…", Some("Cmd+E"), "{}"),
("app.palette", "Command Palette…", Some("Cmd+K"), "{}"),
("app.findChange", "Find/Change…", Some("Cmd+F"), "{}"),
("app.storyEditor", "Edit in Story Editor", Some("Cmd+Y"), "{story?}"),
("view.zoomIn", "Zoom In", Some("Cmd+="), "{}"),
("view.zoomOut", "Zoom Out", Some("Cmd+-"), "{}"),
("view.fitPage", "Fit Page in Window", Some("Cmd+0"), "{}"),
@ -77,6 +78,10 @@ pub const MENUS: &[(&str, &[&str])] = &[
"cmd:edit.selectAll",
"cmd:edit.deselectAll",
"-",
"ui:app.findChange",
"cmd:find.next",
"ui:app.storyEditor",
"-",
"ui:app.palette",
],
),
@ -198,6 +203,19 @@ pub fn run_ui(app: &mut DesignApp, id: &str, p: &Value) -> Option<Result<Value,
Ok(Value::Null)
}
"app.exportPng" => export_png(app, p),
"app.storyEditor" => {
let sid = p.get("story").and_then(Value::as_u64).map(designcraft_doc::StoryId).or_else(|| {
let st = app.session.active()?;
st.selection.text.map(|t| t.story).or_else(|| st.selection.items.iter().find_map(|i| st.doc.item(*i)?.text_frame().map(|t| t.story)))
});
match sid {
Some(s) => {
app.story_editor = if app.story_editor == Some(s) { None } else { Some(s) };
Ok(Value::Null)
}
None => Err("select a text frame or place the cursor in text".into()),
}
}
"app.findChange" => {
app.ui.dialog = Some(crate::dialogs::Dialog::new("findChange", json!({})));
Ok(Value::Null)

View File

@ -0,0 +1,84 @@
//! Story Editor (Edit → Edit in Story Editor, ⌘Y): the story as plain text with each paragraph's
//! style in the left column and the overset point marked. Edits are applied as minimal range
//! replacements, so formatting around them is kept.
use egui::vec2;
use serde_json::json;
use crate::DesignApp;
use crate::theme::Tokens;
pub fn show(app: &mut DesignApp, ctx: &egui::Context) {
let Some(sid) = app.story_editor else { return };
let Some(st) = app.session.active() else {
app.story_editor = None;
return;
};
let Some(story) = st.doc.story(sid).cloned() else {
app.story_editor = None;
return;
};
let t = Tokens::get(ctx);
let cs = app.session.cache.get(&st.doc, sid, None);
let overset = cs.overset_at;
let mut open = true;
let mut edit: Option<(usize, usize, String)> = None;
egui::Window::new(format!("Story Editor — {} words", story.text.split_whitespace().count()))
.id(egui::Id::new("story_editor"))
.open(&mut open)
.default_size(vec2(560.0, 520.0))
.show(ctx, |ui| {
egui::ScrollArea::vertical().auto_shrink([false, false]).show(ui, |ui| {
ui.horizontal_top(|ui| {
// Style column.
ui.vertical(|ui| {
ui.set_width(130.0);
for (i, r) in story.para_ranges().iter().enumerate() {
let lines = (story.text[r.clone()].len() / 60).max(0) + 1;
let style = &story.paras[i].style;
ui.label(egui::RichText::new(style).size(11.0).color(t.text_dim));
for _ in 1..lines {
ui.label(egui::RichText::new(" ").size(11.0));
}
}
});
ui.separator();
let mut text = story.text.clone();
let r = ui.add(
egui::TextEdit::multiline(&mut text)
.font(egui::FontId::proportional(14.0))
.desired_width(f32::INFINITY)
.desired_rows(20)
.frame(egui::Frame::NONE),
);
if r.changed() {
// Minimal diff: common prefix and suffix.
let old = &story.text;
let pre = old.bytes().zip(text.bytes()).take_while(|(a, b)| a == b).count();
let pre = (0..=pre).rev().find(|&i| old.is_char_boundary(i) && text.is_char_boundary(i)).unwrap_or(0);
let max_suf = old.len().min(text.len()) - pre;
let suf = old.bytes().rev().zip(text.bytes().rev()).take(max_suf).take_while(|(a, b)| a == b).count();
let suf =
(0..=suf).rev().find(|&k| old.is_char_boundary(old.len() - k) && text.is_char_boundary(text.len() - k)).unwrap_or(0);
edit = Some((pre, old.len() - suf, text[pre..text.len() - suf].to_string()));
}
});
if let Some(o) = overset {
ui.add_space(6.0);
ui.horizontal(|ui| {
ui.label(egui::RichText::new("OVERSET").color(egui::Color32::from_rgb(230, 40, 40)).strong());
ui.label(
egui::RichText::new(format!("{} characters don't fit", story.text[o.min(story.text.len())..].chars().count()))
.color(t.text_dim),
);
});
}
});
});
if let Some((a, b, text)) = edit {
let _ = app.run("story.replaceRange", json!({"story": sid.0, "start": a, "end": b, "text": text}));
}
if !open {
app.story_editor = None;
}
}