mirror of
https://github.com/storytold/wordcraft.git
synced 2026-11-07 10:49:47 -06:00
Compare commits
7
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
97c72d9063 | ||
|
|
23e40916dd | ||
|
|
8f9400ce78 | ||
|
|
5c537a6cf4 | ||
|
|
749a0b22be | ||
|
|
8661fa6c66 | ||
|
|
e11bdc5e7b |
@@ -155,6 +155,7 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm
|
||||
- 2026-10-29: Page drawing finds text-box warps without rescanning the paragraph for every box (#3614). Only requested offsets are indexed, and each paragraph walk stops after its last requested character. Original Linux discovery, complete display-output and CLI/MCP save/reopen controls; native Word comparison untested. Readiness estimates unchanged.
|
||||
- 2026-10-28: Protected typing boundary lookup counts preceding objects once and indexes editable permission IDs (#3614); caret movement and enforcement policy are unchanged. Existing mutation and protection checks retain their costs; this does not close the broader performance gap.
|
||||
|
||||
- 2026-11-04: Ctrl/Cmd+Shift+V pastes the clipboard as Keep Text Only when Ctrl/Cmd+Shift+C hasn't picked up any formatting, as in Word for Windows; after Ctrl/Cmd+Shift+C it pastes the formatting, as often as asked until the next Ctrl/Cmd+Shift+C or Format Painter pick replaces it, as in Word, and Ctrl/Cmd+Shift+C no longer arms the Format Painter brush (#1537, #4943; takes over #1562).
|
||||
- 2026-11-04: OpenDocument files have no display settings any more (gap 19): File › Options › Advanced › OpenDocument files, `file.options` `odf`, `WORDCRAFT_ODF`, the LibreOffice style and custom values are gone. WordCraft makes one choice for each case the standard leaves open, the one the default made, and still follows what a file states ([docs/odf-interpretation.md](docs/odf-interpretation.md)).
|
||||
- 2026-10-31: Dialogs are modal, as in Word (#3859): while one is open, the ribbon, File, the page and the panes of its document window take no clicks, and typing and shortcuts (Ctrl+O…) do nothing until it closes; WordCraft's own Open/Save As window is modal too. A press beside a modal dialog shakes it a little, so it is clear why nothing behind it reacts (Word beeps); with animation effects off, its border flashes instead. Find and Replace, Symbol, Mark Index Entry, Cross-reference and Insert Merge Field stay modeless, as in Word for Mac 16.114.1 (checked on a newer build than the 16.113.4 the parity target names).
|
||||
- 2026-11-04: OpenDocument files are shown in one of two styles (gap 19): Microsoft Word style, the default, or LibreOffice style (File › Options › Advanced › OpenDocument files; `file.options` `{"odf": {"preset": "word"}}` or `"libreoffice"`; `WORDCRAFT_ODF=word` or `WORDCRAFT_ODF=libreoffice`). Custom stays for one's own value per case; `wordcraft`, the earlier id of Word's style, is still taken.
|
||||
|
||||
@@ -163,7 +163,9 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
.pure(),
|
||||
CommandSpec::new("edit.formatPainter", "Format Painter", "Home › Clipboard", painter).params(r#"{"sticky"?: bool}"#).pure(),
|
||||
CommandSpec::new("edit.copyFormat", "Copy Formatting", "Home › Clipboard", |s, _| {
|
||||
painter_pick(s);
|
||||
// One format buffer, as in Word: Copy Formatting replaces an armed Format Painter.
|
||||
s.painter = None;
|
||||
s.copied_format = Some(picked_format(s));
|
||||
sel_result(s)
|
||||
})
|
||||
.key("Mod+Shift+C")
|
||||
@@ -819,11 +821,21 @@ fn goto(s: &mut Session, v: &Value) -> CmdResult {
|
||||
sel_result(s)
|
||||
}
|
||||
|
||||
fn painter_pick(s: &mut Session) {
|
||||
/// The character and paragraph formatting at the start of the selection.
|
||||
fn picked_format(s: &Session) -> (wordcraft_doc::props::CharProps, wordcraft_doc::props::ParaProps) {
|
||||
let f = s.sel.ordered().0;
|
||||
// A symbol's own font isn't formatting to paint onto text.
|
||||
let chr = s.doc.para_at(&f).map(|p| p.props_of_char(f.off).clone().without_symbol()).unwrap_or_default();
|
||||
let para = s.doc.para_at(&f).map(|p| p.props.clone()).unwrap_or_default();
|
||||
// Formatting only: not the source's hyperlink or revision marks (#5616).
|
||||
let chr = s.doc.para_at(&f).map(|p| p.props_of_char(f.off).clone().without_symbol().formatting()).unwrap_or_default();
|
||||
let para = s.doc.para_at(&f).map(|p| p.props.clone().formatting()).unwrap_or_default();
|
||||
(chr, para)
|
||||
}
|
||||
|
||||
fn painter_pick(s: &mut Session) {
|
||||
let (chr, para) = picked_format(s);
|
||||
// One format buffer, as in Word: the pick is also what the next Paste Formatting applies,
|
||||
// and stays after the brush is used up or cancelled.
|
||||
s.copied_format = Some((chr.clone(), para.clone()));
|
||||
s.painter = Some((chr, para, false));
|
||||
}
|
||||
|
||||
@@ -839,24 +851,25 @@ fn painter(s: &mut Session, v: &Value) -> CmdResult {
|
||||
Ok(json!({"active": true}))
|
||||
}
|
||||
|
||||
/// Apply the format painter's formatting to the selection.
|
||||
/// Apply the format painter's formatting to the selection, or else what Copy Formatting picked
|
||||
/// up (which stays for the next Paste Formatting).
|
||||
fn paste_format(s: &mut Session, _: &Value) -> CmdResult {
|
||||
let Some((chr, para, sticky)) = s.painter.clone() else { return Err(CmdError::Failed("no formatting copied".into())) };
|
||||
let (chr, para, sticky) = match (s.painter.clone(), s.copied_format.clone()) {
|
||||
(Some(brush), _) => brush,
|
||||
(None, Some((chr, para))) => (chr, para, true),
|
||||
(None, None) => return Err(CmdError::Failed("no formatting copied".into())),
|
||||
};
|
||||
let (a, b) = s.sel.ordered();
|
||||
if a == b {
|
||||
// No selection: apply paragraph formatting (and word formatting like Word).
|
||||
let f = s.sel.focus.clone();
|
||||
if let Some(pp) = s.doc.para_at(&f) {
|
||||
let (x, y) = pp.word_at(f.off);
|
||||
s.doc.format_range(&Pos { off: x, ..f.clone() }, &Pos { off: y, ..f.clone() }, &|c| *c = chr.cleared().overlaid(&chr))?;
|
||||
s.doc.format_range(&Pos { off: x, ..f.clone() }, &Pos { off: y, ..f.clone() }, &|c| *c = c.cleared().overlaid(&chr))?;
|
||||
}
|
||||
s.doc.format_paragraphs(&a, &b, &|p| *p = para.clone())?;
|
||||
} else {
|
||||
s.doc.format_range(&a, &b, &|c| {
|
||||
let link = c.link.clone();
|
||||
*c = c.cleared().overlaid(&chr);
|
||||
c.link = link;
|
||||
})?;
|
||||
s.doc.format_range(&a, &b, &|c| *c = c.cleared().overlaid(&chr))?;
|
||||
if a.path != b.path || a.off == 0 {
|
||||
s.doc.format_paragraphs(&a, &b, &|p| *p = para.clone())?;
|
||||
}
|
||||
|
||||
@@ -88,6 +88,9 @@ pub struct Session {
|
||||
pub initials: String,
|
||||
/// Format painter: copied formatting waiting to be applied (and whether it stays on).
|
||||
pub painter: Option<(CharProps, wordcraft_doc::props::ParaProps, bool)>,
|
||||
/// Formatting picked up by Copy Formatting (Mod+Shift+C): Paste Formatting applies it as often
|
||||
/// as asked, as in Word, until the next Copy Formatting. The Format Painter brush is separate.
|
||||
pub copied_format: Option<(CharProps, wordcraft_doc::props::ParaProps)>,
|
||||
/// Last message for the status bar / agents.
|
||||
pub status: String,
|
||||
/// Headers/footers `insert.createHeader`/`insert.createFooter` made for a page that had none
|
||||
|
||||
@@ -250,6 +250,7 @@ impl Session {
|
||||
author: _,
|
||||
initials: _,
|
||||
painter: _,
|
||||
copied_format: _,
|
||||
status: _,
|
||||
last_command: _,
|
||||
recording: _,
|
||||
|
||||
@@ -107,6 +107,7 @@ impl Session {
|
||||
author: "WordCraft User".into(),
|
||||
initials: String::new(),
|
||||
painter: None,
|
||||
copied_format: None,
|
||||
status: String::new(),
|
||||
new_header_parts,
|
||||
hf_return,
|
||||
|
||||
@@ -90,6 +90,7 @@ impl Session {
|
||||
author: _,
|
||||
initials: _,
|
||||
painter: _,
|
||||
copied_format: _,
|
||||
status: _,
|
||||
// Headers just created to edit: closing checks they still exist and are empty.
|
||||
new_header_parts: _,
|
||||
|
||||
@@ -12497,3 +12497,140 @@ fn page_down_extends_a_selection_that_ends_in_collapsed_outline_text() {
|
||||
assert!(s.sel.focus.path.0[0] > 6, "Page Down extends the selection a page on: {:?}", s.sel.focus);
|
||||
assert!(s.layout().caret(&s.sel.focus).is_some(), "to a place the outline shows");
|
||||
}
|
||||
|
||||
/// #5616: Copy Formatting's formatting stays for every Paste Formatting, as in Word; it doesn't
|
||||
/// arm the one-shot Format Painter brush.
|
||||
#[test]
|
||||
fn copied_formatting_pastes_again_and_again() {
|
||||
let mut s = s();
|
||||
run(&mut s, "document.setText", json!({"text": "Bold one two"}));
|
||||
run(&mut s, "select.text", json!({"text": "Bold"}));
|
||||
run(&mut s, "format.bold", json!({}));
|
||||
run(&mut s, "edit.copyFormat", json!({}));
|
||||
assert!(s.painter.is_none());
|
||||
for word in ["one", "two"] {
|
||||
run(&mut s, "select.text", json!({"text": word}));
|
||||
run(&mut s, "edit.pasteFormat", json!({}));
|
||||
assert_eq!(s.run("format.state", &json!({})).unwrap()["bold"], json!(true), "{word}");
|
||||
}
|
||||
assert_eq!(s.doc.plain_text(wordcraft_doc::StoryRef::Body), "Bold one two");
|
||||
}
|
||||
|
||||
/// #5616: Copy Formatting and the Format Painter share one format buffer: each replaces the other,
|
||||
/// so a used-up brush never hands over to older copied formatting.
|
||||
#[test]
|
||||
fn copy_formatting_and_the_format_painter_replace_each_other() {
|
||||
let mut s = s();
|
||||
run(&mut s, "document.setText", json!({"text": "Bold one two"}));
|
||||
run(&mut s, "select.text", json!({"text": "Bold"}));
|
||||
run(&mut s, "format.bold", json!({}));
|
||||
run(&mut s, "edit.formatPainter", json!({}));
|
||||
assert!(s.painter.is_some());
|
||||
run(&mut s, "edit.copyFormat", json!({}));
|
||||
assert!(s.painter.is_none(), "Copy Formatting clears an armed brush");
|
||||
assert!(s.copied_format.is_some());
|
||||
run(&mut s, "edit.formatPainter", json!({}));
|
||||
assert!(s.painter.is_some());
|
||||
run(&mut s, "select.text", json!({"text": "one"}));
|
||||
run(&mut s, "edit.pasteFormat", json!({}));
|
||||
assert!(s.painter.is_none(), "a used-up brush is gone");
|
||||
assert!(s.copied_format.is_some(), "its pick stays for Paste Formatting");
|
||||
}
|
||||
|
||||
/// #5616: arming and cancelling the Format Painter leaves Copy Formatting's pick; a brush's pick
|
||||
/// is what Paste Formatting applies next.
|
||||
#[test]
|
||||
fn cancelling_the_format_painter_keeps_the_copied_formatting() {
|
||||
let mut s = s();
|
||||
run(&mut s, "document.setText", json!({"text": "Bold one two"}));
|
||||
run(&mut s, "select.text", json!({"text": "Bold"}));
|
||||
run(&mut s, "format.bold", json!({}));
|
||||
run(&mut s, "edit.copyFormat", json!({}));
|
||||
run(&mut s, "edit.formatPainter", json!({}));
|
||||
run(&mut s, "edit.formatPainter", json!({}));
|
||||
assert!(s.painter.is_none());
|
||||
run(&mut s, "select.text", json!({"text": "one"}));
|
||||
run(&mut s, "edit.pasteFormat", json!({}));
|
||||
assert_eq!(s.run("format.state", &json!({})).unwrap()["bold"], json!(true));
|
||||
// A brush picked from plain text replaces the bold pick.
|
||||
run(&mut s, "select.text", json!({"text": "two"}));
|
||||
run(&mut s, "edit.formatPainter", json!({}));
|
||||
run(&mut s, "select.text", json!({"text": "Bold"}));
|
||||
run(&mut s, "edit.pasteFormat", json!({}));
|
||||
assert_ne!(s.run("format.state", &json!({})).unwrap()["bold"], json!(true));
|
||||
}
|
||||
|
||||
/// #5616: copied formatting carries no tracked paragraph formatting or numbering change.
|
||||
#[test]
|
||||
fn copied_paragraph_formatting_carries_no_tracked_changes() {
|
||||
let mut s = s();
|
||||
run(&mut s, "document.setText", json!({"text": "one\ntwo"}));
|
||||
run(&mut s, "review.trackChanges", json!({"value": true}));
|
||||
run(&mut s, "select.text", json!({"text": "one"}));
|
||||
run(&mut s, "para.alignCenter", json!({}));
|
||||
run(&mut s, "review.trackChanges", json!({"value": false}));
|
||||
run(&mut s, "select.text", json!({"text": "one"}));
|
||||
let f = s.sel.focus.clone();
|
||||
assert!(s.doc.para_at(&f).unwrap().props.fmt_change.is_some(), "the source paragraph has a tracked formatting change");
|
||||
run(&mut s, "edit.copyFormat", json!({}));
|
||||
let (_, para) = s.copied_format.clone().unwrap();
|
||||
assert!(para.fmt_change.is_none() && para.num_change.is_none());
|
||||
run(&mut s, "select.text", json!({"text": "two"}));
|
||||
run(&mut s, "edit.pasteFormat", json!({}));
|
||||
let f = s.sel.focus.clone();
|
||||
let p = s.doc.para_at(&f).unwrap().props.clone();
|
||||
assert!(p.fmt_change.is_none() && p.num_change.is_none());
|
||||
}
|
||||
|
||||
/// #5616: copied formatting carries no hyperlink or revision marks, even into a collapsed caret.
|
||||
#[test]
|
||||
fn copied_formatting_carries_no_hyperlink() {
|
||||
let mut s = s();
|
||||
run(&mut s, "document.setText", json!({"text": "source target"}));
|
||||
run(&mut s, "select.text", json!({"text": "source"}));
|
||||
run(&mut s, "format.bold", json!({}));
|
||||
run(&mut s, "insert.link", json!({"url": "https://example.com/"}));
|
||||
run(&mut s, "select.text", json!({"text": "so"}));
|
||||
run(&mut s, "edit.copyFormat", json!({}));
|
||||
let (chr, _) = s.copied_format.clone().unwrap();
|
||||
assert_eq!(chr.bold, Some(true));
|
||||
assert!(chr.link.is_none() && chr.ins.is_none() && chr.del.is_none() && chr.fmt_change.is_none());
|
||||
run(&mut s, "select.text", json!({"text": "target"}));
|
||||
run(&mut s, "select.collapse", json!({}));
|
||||
run(&mut s, "caret.left", json!({}));
|
||||
run(&mut s, "edit.pasteFormat", json!({}));
|
||||
let f = s.sel.focus.clone();
|
||||
let link = s.doc.para_at(&f).map(|p| p.props_of_char(f.off).link.clone());
|
||||
assert_eq!(link, Some(None), "the target didn't become a link");
|
||||
}
|
||||
|
||||
/// #5616: Paste Formatting at a bare caret keeps the word's own hyperlink and tracked insertion.
|
||||
#[test]
|
||||
fn a_caret_paste_formatting_keeps_the_targets_link_and_tracked_marks() {
|
||||
let mut s = s();
|
||||
run(&mut s, "document.setText", json!({"text": "source target"}));
|
||||
run(&mut s, "select.text", json!({"text": "source"}));
|
||||
run(&mut s, "format.bold", json!({}));
|
||||
run(&mut s, "edit.copyFormat", json!({}));
|
||||
run(&mut s, "select.text", json!({"text": "target"}));
|
||||
run(&mut s, "insert.link", json!({"url": "https://example.com/"}));
|
||||
run(&mut s, "select.collapse", json!({}));
|
||||
run(&mut s, "caret.right", json!({}));
|
||||
run(&mut s, "edit.pasteFormat", json!({}));
|
||||
let at_caret = |s: &Session| {
|
||||
let f = s.sel.focus.clone();
|
||||
s.doc.para_at(&f).map(|p| p.props_of_char(f.off).clone()).unwrap_or_default()
|
||||
};
|
||||
let c = at_caret(&s);
|
||||
assert!(c.link.is_some(), "the target kept its hyperlink");
|
||||
assert_eq!(c.bold, Some(true));
|
||||
// A tracked insertion stays tracked.
|
||||
run(&mut s, "caret.docEnd", json!({}));
|
||||
run(&mut s, "review.trackChanges", json!({"value": true}));
|
||||
run(&mut s, "text.insert", json!({"text": " added"}));
|
||||
run(&mut s, "review.trackChanges", json!({"value": false}));
|
||||
run(&mut s, "caret.left", json!({}));
|
||||
assert!(at_caret(&s).ins.is_some(), "the insertion is tracked before");
|
||||
run(&mut s, "edit.pasteFormat", json!({}));
|
||||
assert!(at_caret(&s).ins.is_some(), "Paste Formatting kept the insertion mark");
|
||||
}
|
||||
|
||||
@@ -222,6 +222,15 @@ pub struct CanvasState {
|
||||
pub(crate) v_pressed: bool,
|
||||
/// The modifiers last held with Mod, for a Mod+V whose Mod is released before V.
|
||||
pub(crate) paste_mods: Option<egui::Modifiers>,
|
||||
/// The modifiers a paste event is read with: those of Mod+V's press or a Mod change, until an
|
||||
/// unrelated key, a pointer press or a focus change. (`paste_mods` also serves the release path.)
|
||||
pub(crate) paste_event_mods: Option<egui::Modifiers>,
|
||||
/// On the web, Mod+Shift+V pasted formatting from its key press: the browser's paste event
|
||||
/// that follows is the same keystroke and pastes nothing (#5616). Any other key press clears it.
|
||||
pub(crate) ignore_next_paste: bool,
|
||||
/// The platform sends a paste event after Mod+Shift+V's key (a browser does; a desktop shell
|
||||
/// doesn't). Only then does the key leave a paste event to ignore or to let paste text.
|
||||
pub(crate) paste_event_follows_chord: bool,
|
||||
/// Draw Table or Eraser is on (`table_pen`): presses draw instead of moving the caret.
|
||||
pub table_tool: Option<crate::table_pen::TableTool>,
|
||||
/// The Draw Table stroke (or eraser press) in progress.
|
||||
@@ -403,6 +412,9 @@ impl Default for CanvasState {
|
||||
pasted: false,
|
||||
v_pressed: false,
|
||||
paste_mods: None,
|
||||
paste_event_mods: None,
|
||||
ignore_next_paste: false,
|
||||
paste_event_follows_chord: cfg!(target_arch = "wasm32"),
|
||||
swallow_text: false,
|
||||
ime_join_text: None,
|
||||
ime_reset: false,
|
||||
|
||||
@@ -60,7 +60,8 @@ pub fn canvas_events(app: &mut WordApp, ctx: &egui::Context) {
|
||||
continue;
|
||||
}
|
||||
note_key_state(app, &e);
|
||||
if drop_refused_replacement(app, &e, &mut batch)
|
||||
if ignored_paste(app, &e)
|
||||
|| drop_refused_replacement(app, &e, &mut batch)
|
||||
|| prefilter(app, ctx, &e, alt_codes)
|
||||
|| paste_release(app, &e)
|
||||
|| equation_event(app, ctx, &e, &mut batch)
|
||||
@@ -158,20 +159,48 @@ fn stale_composition(app: &mut WordApp, e: &egui::Event, b: &mut Batch) -> bool
|
||||
fn note_key_state(app: &mut WordApp, e: &egui::Event) {
|
||||
// Which presses reached the canvas, and the modifiers held with Mod, in event order: Mod
|
||||
// can go down and up within one frame, leaving the frame's modifiers without it.
|
||||
if matches!(e, egui::Event::Key { pressed: true, key, .. } if !is_modifier_key(*key))
|
||||
|| matches!(e, egui::Event::WindowFocused(_) | egui::Event::PointerButton { pressed: true, .. })
|
||||
{
|
||||
app.canvas.ignore_next_paste = false;
|
||||
}
|
||||
match &e {
|
||||
egui::Event::ModifiersChanged(m) if m.command => app.canvas.paste_mods = Some(*m),
|
||||
egui::Event::Key { key: Key::V | Key::Paste, pressed: true, .. } => app.canvas.v_pressed = true,
|
||||
egui::Event::ModifiersChanged(m) => {
|
||||
app.canvas.paste_event_mods = m.command.then_some(*m);
|
||||
if m.command {
|
||||
app.canvas.paste_mods = Some(*m);
|
||||
}
|
||||
}
|
||||
egui::Event::Key { key: Key::V | Key::Paste, pressed: true, modifiers, .. } => {
|
||||
app.canvas.v_pressed = true;
|
||||
app.canvas.paste_event_mods = modifiers.command.then_some(*modifiers);
|
||||
}
|
||||
egui::Event::Key { key, pressed: true, modifiers, .. } if !is_modifier_key(*key) => {
|
||||
app.canvas.paste_mods = modifiers.command.then_some(*modifiers);
|
||||
// An unrelated key's modifiers mustn't steer a later keyless paste.
|
||||
app.canvas.paste_event_mods = None;
|
||||
}
|
||||
egui::Event::PointerButton { pressed: true, .. } => app.canvas.paste_event_mods = None,
|
||||
egui::Event::WindowFocused(_) => {
|
||||
app.canvas.v_pressed = false;
|
||||
app.canvas.paste_mods = None;
|
||||
app.canvas.paste_event_mods = None;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
/// The browser's paste event that follows a Mod+Shift+V that pasted formatting is the same
|
||||
/// keystroke: ignored before an equation or a picture-file paste can take it (#5616).
|
||||
fn ignored_paste(app: &mut WordApp, e: &egui::Event) -> bool {
|
||||
if matches!(e, egui::Event::Paste(_)) && std::mem::take(&mut app.canvas.ignore_next_paste) {
|
||||
// Not a Mod+V with no text either: the release mustn't paste a picture.
|
||||
app.canvas.pasted = true;
|
||||
return true;
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
/// The replacement paired with a refused equation deletion is dropped.
|
||||
fn drop_refused_replacement(app: &mut WordApp, e: &egui::Event, b: &mut Batch) -> bool {
|
||||
// The replacement is the event immediately after the refused deletion: one `Commit`,
|
||||
@@ -509,10 +538,15 @@ fn handle_event(app: &mut WordApp, ctx: &egui::Context, e: egui::Event) {
|
||||
egui::Event::Ime(egui::ImeEvent::Commit(text)) => ime_commit(app, text),
|
||||
egui::Event::Paste(t) => {
|
||||
// Mod+Alt+V arrives as a paste too (the shell reads the clipboard): Paste Special.
|
||||
let m = ctx.input(|i| i.modifiers);
|
||||
// The modifiers held with Mod in event order: Shift may be up by the frame's end.
|
||||
app.canvas.paste_mods = None;
|
||||
let m = app.canvas.paste_event_mods.take().unwrap_or_else(|| ctx.input(|i| i.modifiers));
|
||||
// A browser sends Mod+Shift+V as a paste too: Keep Text Only, as the key does on the
|
||||
// desktop (#1537). (egui-winit sends that chord as a key, and Shift+Insert has no Mod.)
|
||||
let id = match (m.alt, m.shift) {
|
||||
(true, true) => "edit.pasteText",
|
||||
(true, false) => "edit.pasteSpecial",
|
||||
(false, true) if m.command => "edit.pasteText",
|
||||
_ => "edit.paste",
|
||||
};
|
||||
let _ = app.run(id, json!({"text": t}));
|
||||
|
||||
@@ -132,7 +132,25 @@ pub(super) fn dispatch(app: &mut WordApp, key: Key, m: Modifiers) -> bool {
|
||||
if m.alt && !crate::widgets::enabled(app, spec.id) {
|
||||
return false;
|
||||
}
|
||||
let _ = app.run(spec.id, json!({}));
|
||||
// Mod+Shift+V pastes the formatting Mod+Shift+C (or the Format Painter) picked up; with
|
||||
// none picked up it pastes the clipboard as text only, as Word for Windows does (#1537,
|
||||
// #4943). Another key bound to Paste Formatting only pastes formatting.
|
||||
let id = if spec.id == "edit.pasteFormat" {
|
||||
let formatting = app.session.painter.is_some() || app.session.copied_format.is_some();
|
||||
let chord = key == Key::V && m.command && m.shift && !m.alt;
|
||||
// A browser sends a paste event after that chord (the desktop doesn't): after
|
||||
// pasting formatting it's ignored; with none, it pastes the text there, once. A
|
||||
// rebinding to another key makes no paste event.
|
||||
let paste_follows = chord && app.canvas.paste_event_follows_chord;
|
||||
app.canvas.ignore_next_paste = formatting && paste_follows;
|
||||
if !formatting && paste_follows {
|
||||
return true;
|
||||
}
|
||||
if !formatting && chord { "edit.pasteText" } else { spec.id }
|
||||
} else {
|
||||
spec.id
|
||||
};
|
||||
let _ = app.run(id, json!({}));
|
||||
return true;
|
||||
}
|
||||
let Some(name) = name else { return false };
|
||||
|
||||
@@ -133,6 +133,44 @@ fn only_plain_paste_releases_paste_a_clipboard_picture() {
|
||||
assert!(!is_paste_special_release(Key::V, m(true, true, true)) && !is_paste_special_release(Key::V, m(true, false, false)));
|
||||
}
|
||||
|
||||
/// #1537: a browser sends Mod+Shift+V as a paste event; it pastes text only, in the destination's
|
||||
/// formatting, though the clipboard also holds bold HTML. Plain Mod+V keeps the HTML's bold.
|
||||
#[test]
|
||||
fn a_mod_shift_paste_event_keeps_text_only() {
|
||||
for (modifiers, bold) in [(Modifiers::COMMAND | Modifiers::SHIFT, false), (Modifiers::COMMAND, true)] {
|
||||
let services = crate::Services { clipboard_html: Some(Box::new(|| Some("<p><b>external</b></p>".into()))), ..Default::default() };
|
||||
let mut a = WordApp::new(wordcraft_engine::Session::new(wordcraft_doc::Document::new()), services);
|
||||
let ctx = egui::Context::default();
|
||||
mods_frame(&ctx, &mut a, modifiers, vec![egui::Event::Paste("external".into())]);
|
||||
assert_eq!(a.session.doc.plain_text(wordcraft_doc::StoryRef::Body), "external", "{modifiers:?}");
|
||||
let state = a.session.run("format.state", &json!({})).unwrap();
|
||||
assert_eq!(state.get("bold").and_then(serde_json::Value::as_bool) == Some(true), bold, "{modifiers:?}: {state}");
|
||||
}
|
||||
}
|
||||
|
||||
/// #5616: a browser sends Mod+Shift+V as its key and then a paste event. After Mod+Shift+C the
|
||||
/// key pastes the formatting and the paste event that follows doesn't replace the text; a later
|
||||
/// Mod+V still pastes.
|
||||
#[test]
|
||||
fn a_paste_event_after_mod_shift_v_pasted_formatting_is_ignored() {
|
||||
let mut a = WordApp::new(wordcraft_engine::Session::new(wordcraft_doc::Document::new()), Default::default());
|
||||
a.canvas.paste_event_follows_chord = true;
|
||||
a.run("text.insert", json!({"text": "source target"})).unwrap();
|
||||
a.run("select.text", json!({"text": "source"})).unwrap();
|
||||
a.run("format.bold", json!({})).unwrap();
|
||||
a.run("edit.copyFormat", json!({})).unwrap();
|
||||
a.run("select.text", json!({"text": "target"})).unwrap();
|
||||
let ctx = egui::Context::default();
|
||||
let chord = Modifiers::COMMAND | Modifiers::SHIFT;
|
||||
mods_frame(&ctx, &mut a, chord, vec![key_event(Key::V, true, chord), egui::Event::Paste("clipboard text".into())]);
|
||||
let body = |a: &WordApp| a.session.doc.plain_text(wordcraft_doc::StoryRef::Body);
|
||||
assert_eq!(body(&a), "source target", "the paste event didn't replace the formatted text");
|
||||
let bold = a.session.run("format.state", &json!({})).unwrap().get("bold").and_then(serde_json::Value::as_bool);
|
||||
assert_eq!(bold, Some(true), "the key pasted the formatting");
|
||||
mods_frame(&ctx, &mut a, Modifiers::COMMAND, vec![key_event(Key::V, true, Modifiers::COMMAND), egui::Event::Paste("pasted".into())]);
|
||||
assert_eq!(body(&a), "source pasted", "the next Mod+V pastes");
|
||||
}
|
||||
|
||||
/// An empty document whose clipboard holds a 4 × 4 picture.
|
||||
fn picture_app() -> WordApp {
|
||||
use crate::paste_picture::ClipboardPicture;
|
||||
@@ -154,6 +192,93 @@ fn mods_frame(ctx: &egui::Context, a: &mut WordApp, modifiers: Modifiers, events
|
||||
ctx.run_ui(egui::RawInput { events, ..Default::default() }, |ui| canvas_events(a, ui.ctx())).drop_without_applying_deltas();
|
||||
}
|
||||
|
||||
/// A source word in bold with Copy Formatting picked up, and "target" selected.
|
||||
fn copied_bold_app() -> WordApp {
|
||||
let mut a = WordApp::new(wordcraft_engine::Session::new(wordcraft_doc::Document::new()), Default::default());
|
||||
a.run("text.insert", json!({"text": "source target"})).unwrap();
|
||||
a.run("select.text", json!({"text": "source"})).unwrap();
|
||||
a.run("format.bold", json!({})).unwrap();
|
||||
a.run("edit.copyFormat", json!({})).unwrap();
|
||||
a.run("select.text", json!({"text": "target"})).unwrap();
|
||||
a
|
||||
}
|
||||
|
||||
/// #5616: on a desktop no paste event follows Mod+Shift+V's key, so none is swallowed: a later
|
||||
/// paste (from a menu, say) pastes.
|
||||
#[test]
|
||||
fn a_desktop_paste_after_mod_shift_v_is_not_swallowed() {
|
||||
let mut a = copied_bold_app();
|
||||
assert!(!a.canvas.paste_event_follows_chord || cfg!(target_arch = "wasm32"));
|
||||
a.canvas.paste_event_follows_chord = false;
|
||||
let ctx = egui::Context::default();
|
||||
let chord = Modifiers::COMMAND | Modifiers::SHIFT;
|
||||
mods_frame(&ctx, &mut a, chord, vec![key_event(Key::V, true, chord)]);
|
||||
assert!(!a.canvas.ignore_next_paste);
|
||||
mods_frame(&ctx, &mut a, Modifiers::NONE, vec![egui::Event::Paste("later".into())]);
|
||||
assert_eq!(a.session.doc.plain_text(wordcraft_doc::StoryRef::Body), "source later");
|
||||
}
|
||||
|
||||
/// #5616: a pointer press ends the wait for the browser's paste event, so a later keyless paste
|
||||
/// (context menu, touch) isn't swallowed.
|
||||
#[test]
|
||||
fn a_pointer_press_ends_the_wait_for_the_ignored_paste() {
|
||||
let mut a = copied_bold_app();
|
||||
a.canvas.ignore_next_paste = true;
|
||||
let ctx = egui::Context::default();
|
||||
let press =
|
||||
egui::Event::PointerButton { pos: egui::pos2(5.0, 5.0), button: egui::PointerButton::Primary, pressed: true, modifiers: Modifiers::NONE };
|
||||
mods_frame(&ctx, &mut a, Modifiers::NONE, vec![press]);
|
||||
assert!(!a.canvas.ignore_next_paste);
|
||||
}
|
||||
|
||||
/// #5616: the modifiers noted for a paste are used up by it: a later paste with no Mod held
|
||||
/// keeps the HTML's formatting instead of reading the stale Mod+Shift.
|
||||
#[test]
|
||||
fn a_paste_event_uses_up_the_noted_paste_modifiers() {
|
||||
let services = crate::Services { clipboard_html: Some(Box::new(|| Some("<p><b>external</b></p>".into()))), ..Default::default() };
|
||||
let mut a = WordApp::new(wordcraft_engine::Session::new(wordcraft_doc::Document::new()), services);
|
||||
let ctx = egui::Context::default();
|
||||
mods_frame(&ctx, &mut a, Modifiers::COMMAND | Modifiers::SHIFT, vec![egui::Event::Paste("external".into())]);
|
||||
assert!(a.canvas.paste_mods.is_none());
|
||||
a.run("select.all", json!({})).unwrap();
|
||||
mods_frame(&ctx, &mut a, Modifiers::NONE, vec![egui::Event::Paste("external".into())]);
|
||||
let bold = a.session.run("format.state", &json!({})).unwrap().get("bold").and_then(serde_json::Value::as_bool);
|
||||
assert_eq!(bold, Some(true), "the later paste was a plain paste");
|
||||
}
|
||||
|
||||
/// #5616: the browser paste event after a formatting paste is dropped before an open equation
|
||||
/// can type it.
|
||||
#[test]
|
||||
fn the_ignored_paste_event_never_reaches_an_open_equation() {
|
||||
let mut a = copied_bold_app();
|
||||
a.canvas.paste_event_follows_chord = true;
|
||||
a.run("insert.equation", json!({"linear": "abc"})).unwrap();
|
||||
a.canvas.ignore_next_paste = true;
|
||||
let ctx = egui::Context::default();
|
||||
let before = a.session.math.clone();
|
||||
mods_frame(&ctx, &mut a, Modifiers::COMMAND, vec![egui::Event::Paste("zzz".into())]);
|
||||
assert_eq!(a.session.math, before, "the ignored paste typed nothing into the equation");
|
||||
assert!(!a.canvas.ignore_next_paste);
|
||||
}
|
||||
|
||||
/// #5616: only Mod+Shift+V leaves a browser paste event; Paste Formatting on another key neither
|
||||
/// arms the ignore flag nor waits for a paste.
|
||||
#[test]
|
||||
fn paste_formatting_on_another_key_leaves_no_paste_to_ignore() {
|
||||
for formatting in [true, false] {
|
||||
let mut a = if formatting {
|
||||
copied_bold_app()
|
||||
} else {
|
||||
WordApp::new(wordcraft_engine::Session::new(wordcraft_doc::Document::new()), Default::default())
|
||||
};
|
||||
a.canvas.paste_event_follows_chord = true;
|
||||
a.session.keymap = wordcraft_engine::keymap::KeyMap::from_value(&json!({"assigned": {"Mod+Alt+P": "edit.pasteFormat"}}));
|
||||
let m = Modifiers::COMMAND | Modifiers::ALT;
|
||||
super::shortcuts::dispatch(&mut a, Key::P, m);
|
||||
assert!(!a.canvas.ignore_next_paste, "formatting={formatting}");
|
||||
}
|
||||
}
|
||||
|
||||
/// A press or release of `key` with `modifiers`.
|
||||
fn key_event(key: Key, pressed: bool, modifiers: Modifiers) -> egui::Event {
|
||||
egui::Event::Key { key, physical_key: Some(key), pressed, repeat: false, modifiers }
|
||||
@@ -1335,3 +1460,21 @@ fn typing_with_smart_art_selected_types_into_an_item() {
|
||||
let g = wordcraft_engine::cmd::smart_art::selected_smart_art(&a.session).expect("still there");
|
||||
assert_eq!(g.items[0].text, "Ada", "one undo step for the typing");
|
||||
}
|
||||
|
||||
/// #5616: modifiers recorded for an unrelated key (Mod+Shift+C) don't steer a later keyless paste,
|
||||
/// whether Mod was released or is still held with no change event.
|
||||
#[test]
|
||||
fn an_unrelated_keys_modifiers_do_not_steer_a_later_paste() {
|
||||
for mod_still_held in [false, true] {
|
||||
let services = crate::Services { clipboard_html: Some(Box::new(|| Some("<p><b>external</b></p>".into()))), ..Default::default() };
|
||||
let mut a = WordApp::new(wordcraft_engine::Session::new(wordcraft_doc::Document::new()), services);
|
||||
let ctx = egui::Context::default();
|
||||
let chord = Modifiers::COMMAND | Modifiers::SHIFT;
|
||||
mods_frame(&ctx, &mut a, chord, vec![key_event(Key::C, true, chord)]);
|
||||
// Shift released, Mod still held: Mod+C's chord isn't the paste's modifiers.
|
||||
let after = if mod_still_held { Modifiers::COMMAND } else { Modifiers::NONE };
|
||||
mods_frame(&ctx, &mut a, after, vec![egui::Event::Paste("external".into())]);
|
||||
let bold = a.session.run("format.state", &json!({})).unwrap().get("bold").and_then(serde_json::Value::as_bool);
|
||||
assert_eq!(bold, Some(true), "held={mod_still_held}: a plain paste keeps the HTML's bold");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4126,7 +4126,8 @@ fn ctrl_shift_c_and_v_copy_and_paste_formatting_not_text() {
|
||||
let key = |key, modifiers| egui::Event::Key { key, physical_key: None, pressed: true, repeat: false, modifiers };
|
||||
h.event(key(egui::Key::C, modifiers));
|
||||
h.step();
|
||||
assert!(h.state().session.painter.is_some(), "{modifiers:?}: Copy Formatting picked up the selection's formatting");
|
||||
assert!(h.state().session.copied_format.is_some(), "{modifiers:?}: Copy Formatting picked up the selection's formatting");
|
||||
assert!(h.state().session.painter.is_none(), "{modifiers:?}: Copy Formatting doesn't arm the Format Painter brush");
|
||||
assert_eq!(h.state().session.clip_history.len(), clipboard_entries, "{modifiers:?}: Copy Formatting preserved clipboard history");
|
||||
assert_eq!(h.state().session.clipboard_text, "plain", "{modifiers:?}: the existing clipboard text was preserved");
|
||||
assert!(copied(&h).is_empty(), "{modifiers:?}: no text reached the system clipboard: {:?}", copied(&h));
|
||||
@@ -4137,14 +4138,45 @@ fn ctrl_shift_c_and_v_copy_and_paste_formatting_not_text() {
|
||||
h.event(key(egui::Key::V, modifiers));
|
||||
h.step();
|
||||
assert_eq!(bold(&mut h), Some(true), "{modifiers:?}: Paste Formatting made the target bold");
|
||||
assert!(h.state().session.painter.is_none(), "{modifiers:?}: the non-sticky painter is used up");
|
||||
assert_eq!(body_text(h.state()), "Bold plain", "{modifiers:?}: pasting formatting left the document text alone");
|
||||
assert!(h.state().session.copied_format.is_some(), "{modifiers:?}: copied formatting stays for the next Paste Formatting");
|
||||
// Pasted again, as in Word: formatting again, never the clipboard's text (#5616).
|
||||
h.state_mut().run("caret.docEnd", json!({})).unwrap();
|
||||
h.state_mut().run("text.insert", json!({"text": " more"})).unwrap();
|
||||
h.state_mut().run("select.text", json!({"text": "more"})).unwrap();
|
||||
// It types on in the pasted bold: make it plain first.
|
||||
h.state_mut().run("format.bold", json!({})).unwrap();
|
||||
assert_eq!(bold(&mut h), Some(false));
|
||||
h.event(key(egui::Key::V, modifiers));
|
||||
h.step();
|
||||
assert_eq!(bold(&mut h), Some(true), "{modifiers:?}: a second Paste Formatting made the next target bold");
|
||||
assert_eq!(body_text(h.state()), "Bold plain more", "{modifiers:?}: the second paste left the text alone");
|
||||
assert_eq!(h.state().session.clip_history.len(), clipboard_entries, "{modifiers:?}: Paste Formatting preserved clipboard history");
|
||||
assert_eq!(h.state().session.clipboard_text, "plain", "{modifiers:?}: Paste Formatting preserved the clipboard text");
|
||||
assert!(copied(&h).is_empty(), "{modifiers:?}: pasting formatting put no text on the system clipboard: {:?}", copied(&h));
|
||||
}
|
||||
}
|
||||
|
||||
/// #1537/#4943: with no formatting picked up, Ctrl/Cmd+Shift+V pastes the clipboard as text
|
||||
/// only (Word for Windows): the text arrives in the destination's formatting, not the source's.
|
||||
#[test]
|
||||
fn ctrl_shift_v_without_copied_formatting_pastes_text_only() {
|
||||
let mut a = app();
|
||||
a.run("text.insert", json!({"text": "Bold plain"})).unwrap();
|
||||
a.run("select.text", json!({"text": "Bold"})).unwrap();
|
||||
a.run("format.bold", json!({})).unwrap();
|
||||
a.run("edit.copy", json!({})).unwrap();
|
||||
a.run("select.text", json!({"text": "plain"})).unwrap();
|
||||
assert!(a.session.painter.is_none());
|
||||
let mut h = harness(a);
|
||||
h.run_steps(6);
|
||||
let modifiers = egui::Modifiers::COMMAND | egui::Modifiers::SHIFT;
|
||||
h.event(egui::Event::Key { key: egui::Key::V, physical_key: None, pressed: true, repeat: false, modifiers });
|
||||
h.step();
|
||||
assert_eq!(body_text(h.state()), "Bold Bold", "the clipboard text replaced the selection");
|
||||
let bold = h.state_mut().run("format.state", json!({})).unwrap().get("bold").and_then(Value::as_bool);
|
||||
assert_ne!(bold, Some(true), "the pasted text took the destination's formatting, not the copied bold");
|
||||
}
|
||||
|
||||
/// #1669: with Shift held, Backspace still erases (#707) and Delete cuts the selection, doing
|
||||
/// nothing without one, as in Word for Windows. (Windows sends Shift+Delete as a Cut; macOS and
|
||||
/// Linux send the key, which reached no command.)
|
||||
|
||||
+2
-1
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user