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:
parent
1bc6f31747
commit
d3516b96cf
|
|
@ -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"))?;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue