mirror of
https://github.com/aphrody-labs/wordcraft.git
synced 2026-10-14 03:29:04 +00:00
Paste Special: paste the clipboard as formatted text, RTF, HTML or unformatted text (edit.pasteSpecial) (#235)
New edit.pasteSpecial command ({"as"?, "text"?, "html"?, "rtf"?}). Without "as" it
lists the clipboard's formats and asks the front end for the dialog; with "as" it pastes in
that format. HTML/RTF go through the wordcraft-formats importers, with their lists, styles and
images merged into the document under fresh ids. Respects Track Changes (now also inside pasted
tables) and caps payloads at 16 MB. UI: Paste Special dialog (radio list + result hint),
Mod+Alt+V and Paste menu entry; strings in all four catalogs. Parity 355/405, floor 87.5%.
Co-authored-by: Dmitrii Khlapov <19991457+dkhlapov@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
This commit is contained in:
17 files changed
+444
-29
No files matched your search
+2
-2
@@ -8,8 +8,8 @@ and agent control. This file tracks where we are honestly. Generated numbers com
|
||||
|
||||
| Measure | Value |
|
||||
|---|---|
|
||||
| Commands (every action, scriptable by CLI/MCP/control channel) | **422** |
|
||||
| Feature catalog coverage (Word ribbon/menu features with a command) | **381 / 430 (89%)** |
|
||||
| Commands (every action, scriptable by CLI/MCP/control channel) | **427** |
|
||||
| Feature catalog coverage (Word ribbon/menu features with a command) | **382 / 430 (89%)** |
|
||||
| **Estimated real feature parity** (depth and fidelity, not just a command) | **~62%** |
|
||||
| **Distance to alpha** | **~85% of the way; ≈20–25 h of Claude wall-clock work** |
|
||||
| **Distance to 100% parity** | **≈120–150 h of Claude Opus 5.5 wall-clock work** (with parallel agents) |
|
||||
|
||||
@@ -502,6 +502,6 @@ mod tests {
|
||||
let p = parity(®);
|
||||
let pct = p["percent"].as_f64().unwrap();
|
||||
// The floor only ever rises.
|
||||
assert!(pct >= 60.0, "parity {pct}%");
|
||||
assert!(pct >= 87.5, "parity {pct}%");
|
||||
}
|
||||
}
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use wordcraft_doc::edit::Fragment;
|
||||
use wordcraft_doc::{Block, Pos, StoryRef};
|
||||
use wordcraft_doc::{Pos, StoryRef};
|
||||
|
||||
use super::{delete_selection, pos_json, sel_result};
|
||||
use crate::{CmdError, CmdResult, CommandSpec, Selection, Session, p};
|
||||
@@ -84,23 +84,7 @@ fn paste(s: &mut Session, v: &Value) -> CmdResult {
|
||||
(None, Some(f)) => f.clone(),
|
||||
(None, None) => return Err(CmdError::Failed("the clipboard is empty".into())),
|
||||
};
|
||||
if let Some(images) = v.get("image").and_then(Value::as_str) {
|
||||
let _ = images;
|
||||
}
|
||||
let at = delete_selection(s)?;
|
||||
let mut frag = frag;
|
||||
if s.doc.settings.track_changes {
|
||||
let rid = super::new_revision(s, wordcraft_doc::RevisionKind::Insert);
|
||||
for b in &mut frag.blocks {
|
||||
if let Block::Para(p) = b {
|
||||
for r in &mut p.runs {
|
||||
r.props.ins = Some(rid);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
let end = s.doc.insert_fragment(&at, &frag)?;
|
||||
s.sel = Selection::caret(end);
|
||||
super::paste::insert(s, frag)?;
|
||||
sel_result(s)
|
||||
}
|
||||
|
||||
|
||||
@@ -12,6 +12,7 @@ pub mod mailings;
|
||||
pub mod objects;
|
||||
pub mod page;
|
||||
pub mod para;
|
||||
pub mod paste;
|
||||
pub mod references;
|
||||
pub mod review;
|
||||
pub mod speech;
|
||||
@@ -33,6 +34,7 @@ pub fn registry() -> Registry {
|
||||
v.extend(text::specs());
|
||||
v.extend(caret::specs());
|
||||
v.extend(edit::specs());
|
||||
v.extend(paste::specs());
|
||||
v.extend(format::specs());
|
||||
v.extend(para::specs());
|
||||
v.extend(view::specs());
|
||||
|
||||
@@ -0,0 +1,215 @@
|
||||
//! Paste Special: paste the clipboard in a chosen format (formatted, RTF, HTML, unformatted).
|
||||
//!
|
||||
//! The clipboard can hold several representations of the same content. WordCraft's own copy keeps
|
||||
//! a rich fragment; the system clipboard gives text, and callers (agents, the CLI, a front end
|
||||
//! with a richer clipboard) may pass `html` or `rtf` too. Text that is itself RTF or HTML source
|
||||
//! can also be pasted as that format.
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
use std::sync::Arc;
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use wordcraft_doc::edit::Fragment;
|
||||
use wordcraft_doc::{Block, Document, Paragraph};
|
||||
|
||||
use super::{delete_selection, sel_result};
|
||||
use crate::{CmdError, CmdResult, CommandSpec, Selection, Session, p};
|
||||
|
||||
/// Largest clipboard payload Paste Special accepts, in bytes.
|
||||
pub const MAX_PASTE_BYTES: usize = 16 << 20;
|
||||
/// Nesting depth we follow when adjusting pasted tables.
|
||||
const MAX_DEPTH: usize = 64;
|
||||
|
||||
/// The formats, in the order the dialog lists them: (id, label).
|
||||
pub const FORMATS: [(&str, &str); 4] =
|
||||
[("formatted", "Formatted Text"), ("rtf", "Rich Text Format (RTF)"), ("html", "HTML Format"), ("text", "Unformatted Text")];
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![
|
||||
CommandSpec::new("edit.pasteSpecial", "Paste Special", "Home › Clipboard › Paste", paste_special)
|
||||
.key("Mod+Alt+V")
|
||||
.params(r#"{"as"?: "formatted"|"rtf"|"html"|"text", "text"?: string, "html"?: string, "rtf"?: string}"#),
|
||||
]
|
||||
}
|
||||
|
||||
/// Clipboard text from the params (system clipboard), else WordCraft's own copy.
|
||||
fn clip_text(s: &Session, v: &Value) -> Option<String> {
|
||||
match p::str(v, "text") {
|
||||
Some(t) => Some(t.replace("\r\n", "\n")),
|
||||
None => s.clipboard.as_ref().map(Fragment::plain_text),
|
||||
}
|
||||
}
|
||||
|
||||
fn looks_like_rtf(t: &str) -> bool {
|
||||
t.trim_start().starts_with("{\\rtf")
|
||||
}
|
||||
|
||||
fn looks_like_html(t: &str) -> bool {
|
||||
let t = t.trim_start();
|
||||
t.starts_with('<') && (t.contains("</") || t.contains("/>"))
|
||||
}
|
||||
|
||||
/// The source for a format, if the clipboard has it.
|
||||
fn source(s: &Session, v: &Value, fmt: &str) -> Option<String> {
|
||||
let non_empty = |t: String| if t.is_empty() { None } else { Some(t) };
|
||||
match fmt {
|
||||
"formatted" => {
|
||||
let f = s.clipboard.as_ref()?;
|
||||
// System clipboard text that isn't our own copy means someone else copied since.
|
||||
match p::str(v, "text") {
|
||||
Some(t) if f.plain_text() != t.replace("\r\n", "\n") => None,
|
||||
_ => Some(String::new()),
|
||||
}
|
||||
}
|
||||
"rtf" => p::str(v, "rtf").map(str::to_string).or_else(|| clip_text(s, v).filter(|t| looks_like_rtf(t))).and_then(non_empty),
|
||||
"html" => p::str(v, "html").map(str::to_string).or_else(|| clip_text(s, v).filter(|t| looks_like_html(t))).and_then(non_empty),
|
||||
"text" => clip_text(s, v).and_then(non_empty),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Formats the clipboard can be pasted as, best first.
|
||||
pub fn available(s: &Session, v: &Value) -> Vec<&'static str> {
|
||||
FORMATS.iter().map(|(id, _)| *id).filter(|id| source(s, v, id).is_some()).collect()
|
||||
}
|
||||
|
||||
fn formats_json(ids: &[&str]) -> Value {
|
||||
json!(ids.iter().map(|id| json!({"id": id, "label": FORMATS.iter().find(|(f, _)| f == id).map(|(_, l)| *l).unwrap_or(*id)})).collect::<Vec<_>>())
|
||||
}
|
||||
|
||||
fn paste_special(s: &mut Session, v: &Value) -> CmdResult {
|
||||
for k in ["text", "html", "rtf"] {
|
||||
if p::str(v, k).is_some_and(|t| t.len() > MAX_PASTE_BYTES) {
|
||||
return Err(CmdError::Params(format!("`{k}` is larger than {} MB", MAX_PASTE_BYTES >> 20)));
|
||||
}
|
||||
}
|
||||
let ids = available(s, v);
|
||||
let Some(fmt) = p::str(v, "as") else {
|
||||
// No format chosen: list them and ask a front end (if any) to show the dialog.
|
||||
let mut req = json!({"open": "pasteSpecial", "formats": ids});
|
||||
for k in ["text", "html", "rtf"] {
|
||||
if let Some(t) = p::str(v, k) {
|
||||
req[k] = json!(t);
|
||||
}
|
||||
}
|
||||
s.ui_requests.push(req);
|
||||
return Ok(json!({"formats": formats_json(&ids)}));
|
||||
};
|
||||
let fmt = match fmt.to_ascii_lowercase().as_str() {
|
||||
"formatted" | "keep" | "wordcraft" => "formatted",
|
||||
"rtf" => "rtf",
|
||||
"html" => "html",
|
||||
"text" | "unformatted" | "plain" => "text",
|
||||
other => return Err(CmdError::Params(format!("unknown format `{other}` (formatted, rtf, html or text)"))),
|
||||
};
|
||||
let Some(src) = source(s, v, fmt) else {
|
||||
return Err(CmdError::Failed(if ids.is_empty() {
|
||||
"the clipboard is empty".into()
|
||||
} else {
|
||||
format!("the clipboard has no {fmt} content (available: {})", ids.join(", "))
|
||||
}));
|
||||
};
|
||||
let frag = match fmt {
|
||||
"text" => {
|
||||
super::type_text(s, &src.replace('\n', "\r"))?;
|
||||
let mut r = sel_result(s)?;
|
||||
r["pastedAs"] = json!(fmt);
|
||||
return Ok(r);
|
||||
}
|
||||
"formatted" => s.clipboard.clone().ok_or_else(|| CmdError::Failed("the clipboard is empty".into()))?,
|
||||
"html" => adopt(s, wordcraft_formats::html::import(&src)),
|
||||
_ => adopt(s, wordcraft_formats::rtf::import(src.as_bytes()).map_err(|e| CmdError::Failed(format!("bad RTF: {e}")))?),
|
||||
};
|
||||
insert(s, frag)?;
|
||||
let mut r = sel_result(s)?;
|
||||
r["pastedAs"] = json!(fmt);
|
||||
Ok(r)
|
||||
}
|
||||
|
||||
/// Replace the selection with `frag`, marking it as an insertion under Track Changes.
|
||||
pub fn insert(s: &mut Session, mut frag: Fragment) -> Result<(), CmdError> {
|
||||
let at = delete_selection(s)?;
|
||||
if s.doc.settings.track_changes {
|
||||
let rid = super::new_revision(s, wordcraft_doc::RevisionKind::Insert);
|
||||
for b in &mut frag.blocks {
|
||||
each_para(b, 0, &mut |p| {
|
||||
for r in &mut p.runs {
|
||||
r.props.ins = Some(rid);
|
||||
r.props.del = None;
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
let end = s.doc.insert_fragment(&at, &frag)?;
|
||||
s.sel = Selection::caret(end);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Every paragraph in a block, tables included (bounded depth).
|
||||
fn each_para(b: &mut Block, depth: usize, f: &mut dyn FnMut(&mut Paragraph)) {
|
||||
if depth > MAX_DEPTH {
|
||||
return;
|
||||
}
|
||||
match b {
|
||||
Block::Para(p) => f(p),
|
||||
Block::Table(t) => {
|
||||
for row in &mut t.rows {
|
||||
for cell in &mut row.cells {
|
||||
for cb in &mut cell.blocks {
|
||||
each_para(Arc::make_mut(cb), depth + 1, f);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Turn an imported document into a fragment of the session's document: bring over the media,
|
||||
/// lists and styles it refers to (renumbered so they can't collide with ours).
|
||||
fn adopt(s: &mut Session, src: Document) -> Fragment {
|
||||
let mut media = BTreeMap::new();
|
||||
for (key, data) in &src.media {
|
||||
let ext = key.rsplit_once('.').map(|(_, e)| e).unwrap_or("png");
|
||||
media.insert(key.clone(), s.doc.add_media(data.to_vec(), ext));
|
||||
}
|
||||
let next_abs = s.doc.numbering.abstracts.iter().map(|a| a.id.saturating_add(1)).max().unwrap_or(0);
|
||||
let mut abs_map = BTreeMap::new();
|
||||
for (i, a) in src.numbering.abstracts.iter().enumerate() {
|
||||
let id = next_abs.saturating_add(i as u32);
|
||||
abs_map.insert(a.id, id);
|
||||
s.doc.numbering.abstracts.push(wordcraft_doc::numbering::AbstractNum { id, ..a.clone() });
|
||||
}
|
||||
let next_num = s.doc.numbering.nums.iter().map(|n| n.id.saturating_add(1)).max().unwrap_or(1).max(1);
|
||||
let mut num_map = BTreeMap::new();
|
||||
for (i, n) in src.numbering.nums.iter().enumerate() {
|
||||
let id = next_num.saturating_add(i as u32);
|
||||
num_map.insert(n.id, id);
|
||||
let abstract_id = abs_map.get(&n.abstract_id).copied().unwrap_or(n.abstract_id);
|
||||
s.doc.numbering.nums.push(wordcraft_doc::numbering::Num { id, abstract_id, ..n.clone() });
|
||||
}
|
||||
for st in &src.styles.styles {
|
||||
if s.doc.styles.get(&st.id).is_none() {
|
||||
s.doc.styles.upsert(st.clone());
|
||||
}
|
||||
}
|
||||
let mut blocks: Vec<Block> = src.body.iter().map(|b| (**b).clone()).collect();
|
||||
for b in &mut blocks {
|
||||
each_para(b, 0, &mut |p| {
|
||||
if let Some(n) = p.props.numbering.as_mut()
|
||||
&& n.num != 0
|
||||
{
|
||||
n.num = num_map.get(&n.num).copied().unwrap_or(0);
|
||||
}
|
||||
for o in &mut p.objects {
|
||||
if let wordcraft_doc::InlineObject::Image { media: key, .. } = o
|
||||
&& let Some(k) = media.get(key.as_str())
|
||||
{
|
||||
*key = k.clone();
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
// Text box stories the imported shapes show; pasting gives each a new story of its own.
|
||||
let parts = src.parts.into_iter().filter(|(_, p)| p.kind == wordcraft_doc::PartKind::TextBox).collect();
|
||||
Fragment { blocks, parts }
|
||||
}
|
||||
@@ -523,6 +523,84 @@ fn clipboard_round_trip() {
|
||||
assert_eq!(text(&s), "plain\ntext");
|
||||
}
|
||||
|
||||
fn bold_at(s: &Session, block: usize, off: usize) -> bool {
|
||||
let pos = Pos { story: StoryRef::Body, path: wordcraft_doc::Path::top(block), off };
|
||||
s.doc.para_at(&pos).and_then(|p| p.props_of_char(off).bold).unwrap_or(false)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn paste_special_lists_formats_and_pastes_text_without_formatting() {
|
||||
let mut s = s();
|
||||
run(&mut s, "document.setText", json!({"text": "Bold words"}));
|
||||
run(&mut s, "select.text", json!({"text": "Bold"}));
|
||||
run(&mut s, "format.bold", json!({}));
|
||||
run(&mut s, "edit.copy", json!({}));
|
||||
// No format: the formats are listed (and a front end is asked to show the dialog).
|
||||
let r = run(&mut s, "edit.pasteSpecial", json!({}));
|
||||
let ids: Vec<&str> = r["formats"].as_array().unwrap().iter().map(|f| f["id"].as_str().unwrap()).collect();
|
||||
assert_eq!(ids, ["formatted", "text"]);
|
||||
assert_eq!(s.ui_requests.last().unwrap()["open"], "pasteSpecial");
|
||||
run(&mut s, "caret.docEnd", json!({}));
|
||||
let r = run(&mut s, "edit.pasteSpecial", json!({"as": "text"}));
|
||||
assert_eq!(r["pastedAs"], "text");
|
||||
assert_eq!(text(&s), "Bold wordsBold");
|
||||
assert!(bold_at(&s, 0, 0));
|
||||
assert!(!bold_at(&s, 0, 11), "unformatted text drops the bold");
|
||||
// Formatted keeps it.
|
||||
run(&mut s, "edit.pasteSpecial", json!({"as": "formatted"}));
|
||||
assert_eq!(text(&s), "Bold wordsBoldBold");
|
||||
assert!(bold_at(&s, 0, 15));
|
||||
// Text that someone else copied since: our rich copy is no longer offered.
|
||||
let r = run(&mut s, "edit.pasteSpecial", json!({"text": "elsewhere"}));
|
||||
assert_eq!(r["formats"].as_array().unwrap().len(), 1);
|
||||
assert!(s.run("edit.pasteSpecial", &json!({"as": "formatted", "text": "elsewhere"})).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn paste_special_html_and_rtf_keep_bold() {
|
||||
let mut s = s();
|
||||
run(&mut s, "document.setText", json!({"text": "x"}));
|
||||
run(&mut s, "caret.docEnd", json!({}));
|
||||
let html = "<p>plain <b>strong</b></p>";
|
||||
let r = run(&mut s, "edit.pasteSpecial", json!({"html": html, "text": "plain strong"}));
|
||||
let ids: Vec<&str> = r["formats"].as_array().unwrap().iter().map(|f| f["id"].as_str().unwrap()).collect();
|
||||
assert_eq!(ids, ["html", "text"]);
|
||||
run(&mut s, "edit.pasteSpecial", json!({"as": "html", "html": html}));
|
||||
assert_eq!(text(&s), "xplain strong");
|
||||
assert!(!bold_at(&s, 0, 2));
|
||||
assert!(bold_at(&s, 0, 8), "the <b> run stays bold");
|
||||
// HTML source copied as text can be pasted as HTML too.
|
||||
run(&mut s, "document.setText", json!({"text": ""}));
|
||||
run(&mut s, "edit.pasteSpecial", json!({"as": "html", "text": "<b>hi</b> there</p>"}));
|
||||
assert_eq!(text(&s), "hi there");
|
||||
assert!(bold_at(&s, 0, 0) && !bold_at(&s, 0, 4));
|
||||
// RTF.
|
||||
run(&mut s, "document.setText", json!({"text": ""}));
|
||||
run(&mut s, "edit.pasteSpecial", json!({"as": "rtf", "rtf": r"{\rtf1\ansi {\b bold}\b0 then}"}));
|
||||
assert!(text(&s).starts_with("bold"), "{}", text(&s));
|
||||
assert!(bold_at(&s, 0, 0));
|
||||
// Asking for a format the clipboard lacks is an error, not a panic.
|
||||
assert!(s.run("edit.pasteSpecial", &json!({"as": "rtf", "text": "plain"})).is_err());
|
||||
assert!(s.run("edit.pasteSpecial", &json!({"as": "bogus", "text": "plain"})).is_err());
|
||||
let huge = "a".repeat(crate::cmd::paste::MAX_PASTE_BYTES + 1);
|
||||
assert!(s.run("edit.pasteSpecial", &json!({"as": "text", "text": huge})).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn paste_special_respects_track_changes_and_brings_lists() {
|
||||
let mut s = s();
|
||||
run(&mut s, "review.trackChanges", json!({"value": true}));
|
||||
run(&mut s, "edit.pasteSpecial", json!({"as": "html", "html": "<ul><li>one</li><li>two</li></ul>"}));
|
||||
assert!(text(&s).starts_with("one\ntwo"), "{}", text(&s));
|
||||
let ch = run(&mut s, "review.changes", json!({}));
|
||||
assert!(!ch.as_array().unwrap().is_empty(), "the paste is a tracked insertion");
|
||||
let first = s.doc.body.first().and_then(|b| b.as_para()).unwrap();
|
||||
let n = first.props.numbering.expect("list kept");
|
||||
assert!(s.doc.numbering.nums.iter().any(|x| x.id == n.num), "the list definition came along");
|
||||
run(&mut s, "review.acceptAll", json!({}));
|
||||
assert!(text(&s).starts_with("one\ntwo"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tables_commands() {
|
||||
let mut s = s();
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
//! Dialogs: Font, Paragraph, Find & Replace, Go To, Insert Table, Page Setup, Link, Bookmark,
|
||||
//! Word Count, Zoom, Watermark, New/Modify Style, New/Modify Table Style, Command search, About,
|
||||
//! Save Changes, and the mail-merge Recipient List, Insert Merge Field, Find Recipient, merge rules,
|
||||
//! Match Fields and Check for Errors. Every dialog ends by running a command (or shows one's
|
||||
//! result), so agents get the same result without the dialog.
|
||||
//! Word Count, Zoom, Watermark, New/Modify Style, New/Modify Table Style, Command search, Paste
|
||||
//! Special, About, Save Changes, and the mail-merge Recipient List, Insert Merge Field, Find
|
||||
//! Recipient, merge rules, Match Fields and Check for Errors. Every dialog ends by running a command
|
||||
//! (or shows one's result), so agents get the same result without the dialog.
|
||||
|
||||
use egui::{Sense, Ui, vec2};
|
||||
use serde::Serialize;
|
||||
@@ -119,6 +119,14 @@ pub enum Dialog {
|
||||
Commands {
|
||||
query: String,
|
||||
},
|
||||
/// Paste Special: the clipboard's formats and the chosen one. The clipboard payload stays out
|
||||
/// of the serialized dialog state (it can be megabytes).
|
||||
PasteSpecial {
|
||||
formats: Vec<String>,
|
||||
choice: usize,
|
||||
#[serde(skip)]
|
||||
payload: Value,
|
||||
},
|
||||
/// Tabs: 0 About, 1 Contributors, 2 Models.
|
||||
About {
|
||||
tab: u8,
|
||||
@@ -333,6 +341,7 @@ impl Dialog {
|
||||
Dialog::TableStyle { id: None, .. } => "newTableStyle",
|
||||
Dialog::TableStyle { .. } => "modifyTableStyle",
|
||||
Dialog::Commands { .. } => "commands",
|
||||
Dialog::PasteSpecial { .. } => "pasteSpecial",
|
||||
Dialog::About { .. } => "about",
|
||||
Dialog::SaveChanges { .. } => "saveChanges",
|
||||
Dialog::RecipientList { .. } => "recipientList",
|
||||
@@ -446,6 +455,7 @@ impl Dialog {
|
||||
Dialog::TableStyle { id: Some(id), name, based_on, region: 0, basis: regions.clone(), regions }
|
||||
}
|
||||
"commands" => Dialog::Commands { query: String::new() },
|
||||
"pasteSpecial" => Dialog::paste_special(app, &json!({})),
|
||||
"about" => Dialog::About { tab: 0 },
|
||||
"contributors" => Dialog::About { tab: 1 },
|
||||
"models" => Dialog::About { tab: 2 },
|
||||
@@ -484,6 +494,18 @@ impl Dialog {
|
||||
})
|
||||
}
|
||||
|
||||
/// Paste Special for a clipboard payload (`text`, `html`, `rtf`; empty = WordCraft's own copy).
|
||||
pub fn paste_special(app: &WordApp, payload: &Value) -> Dialog {
|
||||
let formats: Vec<String> = wordcraft_engine::cmd::paste::available(&app.session, payload).into_iter().map(str::to_string).collect();
|
||||
let mut kept = json!({});
|
||||
for k in ["text", "html", "rtf"] {
|
||||
if let Some(t) = payload.get(k).and_then(Value::as_str) {
|
||||
kept[k] = json!(t);
|
||||
}
|
||||
}
|
||||
Dialog::PasteSpecial { formats, choice: 0, payload: kept }
|
||||
}
|
||||
|
||||
pub fn modify_style(app: &WordApp, id: &str) -> Option<Dialog> {
|
||||
let st = app.session.doc.styles.get(id)?;
|
||||
let rc = app.session.doc.styles.resolve_char(
|
||||
@@ -570,6 +592,7 @@ pub fn show(app: &mut WordApp, ctx: &egui::Context) {
|
||||
Dialog::TableStyle { id: None, .. } => "New Table Style",
|
||||
Dialog::TableStyle { .. } => "Modify Table Style",
|
||||
Dialog::Commands { .. } => "Search Commands",
|
||||
Dialog::PasteSpecial { .. } => "Paste Special",
|
||||
Dialog::About { .. } => "About WordCraft",
|
||||
Dialog::SaveChanges { .. } => "WordCraft",
|
||||
Dialog::RecipientList { .. } => "Recipient List",
|
||||
@@ -1130,6 +1153,34 @@ fn body(app: &mut WordApp, ui: &mut Ui, d: &mut Dialog) -> bool {
|
||||
}
|
||||
close || ui.input(|i| i.key_pressed(egui::Key::Escape))
|
||||
}
|
||||
Dialog::PasteSpecial { formats, choice, payload } => {
|
||||
ui.label(egui::RichText::new(tl!("Paste as:")).font(semibold(12.5)));
|
||||
if formats.is_empty() {
|
||||
ui.label(egui::RichText::new(tl!("The clipboard is empty.")).weak());
|
||||
}
|
||||
for (i, f) in formats.iter().enumerate() {
|
||||
let label = wordcraft_engine::cmd::paste::FORMATS.iter().find(|(id, _)| id == f).map(|(_, l)| *l).unwrap_or(f.as_str());
|
||||
ui.radio_value(choice, i, tl!(label));
|
||||
}
|
||||
if let Some(f) = formats.get(*choice) {
|
||||
ui.add_space(6.0);
|
||||
ui.label(egui::RichText::new(tl!("Result")).font(semibold(12.5)));
|
||||
let hint = match f.as_str() {
|
||||
"formatted" => "Keeps the fonts and formatting of the copied text.",
|
||||
"rtf" => "Reads the clipboard's rich text and keeps its formatting.",
|
||||
"html" => "Reads the clipboard's web content and keeps its formatting.",
|
||||
_ => "Drops all formatting and pastes plain text.",
|
||||
};
|
||||
ui.label(egui::RichText::new(tl!(hint)).weak());
|
||||
}
|
||||
let (ok, cancel) = buttons(ui, tl!("OK"));
|
||||
if ok && let Some(f) = formats.get(*choice) {
|
||||
let mut params = payload.clone();
|
||||
params["as"] = json!(f);
|
||||
let _ = app.run("edit.pasteSpecial", params);
|
||||
}
|
||||
ok || cancel
|
||||
}
|
||||
Dialog::About { tab } => {
|
||||
ui.set_width(660.0);
|
||||
ui.horizontal(|ui| {
|
||||
|
||||
@@ -216,6 +216,7 @@
|
||||
Drawing Tools Herramientas de dibujo
|
||||
Drawing\nCanvas Lienzo de\ndibujo
|
||||
Drop Cap Letra capital
|
||||
Drops all formatting and pastes plain text. Quita todo el formato y pega texto sin formato.
|
||||
Edit Footer Editar pie de página
|
||||
Edit Header Editar encabezado
|
||||
Edit Recipient List Editar lista de destinatarios
|
||||
@@ -271,6 +272,7 @@
|
||||
Format Page Numbers Formato de números de página
|
||||
Format Page Numbers… Formato de números de página…
|
||||
Format Painter Pincel de formato
|
||||
Formatted Text Texto con formato
|
||||
Formatting at Selection Formato en la selección
|
||||
Formula Fórmula
|
||||
General General
|
||||
@@ -284,6 +286,7 @@
|
||||
Gridlines Líneas de cuadrícula
|
||||
Group Agrupar
|
||||
Grow Font 1 Point Aumentar fuente 1 punto
|
||||
HTML Format Formato HTML
|
||||
Hanging Indent Sangría francesa
|
||||
Header Encabezado
|
||||
Header & Footer Encabezado y pie de página
|
||||
@@ -353,6 +356,7 @@
|
||||
Keep lines together Mantener líneas juntas
|
||||
Keep with Next Mantener con el siguiente
|
||||
Keep with next Mantener con el siguiente
|
||||
Keeps the fonts and formatting of the copied text. Conserva las fuentes y el formato del texto copiado.
|
||||
Labels Etiquetas
|
||||
Landscape Horizontal
|
||||
Language Idioma
|
||||
@@ -497,6 +501,9 @@
|
||||
Paste Pegar
|
||||
Paste (⌘V) Pegar (⌘V)
|
||||
Paste Formatting Pegar formato
|
||||
Paste Special Pegado especial
|
||||
Paste Special… Pegado especial…
|
||||
Paste as: Pegar como:
|
||||
Paste: Keep Text Only Pegar: mantener solo el texto
|
||||
Paste: Merge Formatting Pegar: combinar formato
|
||||
Pen Pluma
|
||||
@@ -530,6 +537,8 @@
|
||||
Read Mode Modo lectura
|
||||
Read\nAloud Leer en\nvoz alta
|
||||
Read\nMode Modo\nlectura
|
||||
Reads the clipboard's rich text and keeps its formatting. Lee el texto enriquecido del portapapeles y conserva su formato.
|
||||
Reads the clipboard's web content and keeps its formatting. Lee el contenido web del portapapeles y conserva su formato.
|
||||
Reading Highlight Resaltado de lectura
|
||||
Recent Recientes
|
||||
Record Macro Grabar macro
|
||||
@@ -568,12 +577,14 @@
|
||||
Restart at 1 Reiniciar en 1
|
||||
Restrict Editing Restringir edición
|
||||
Restrict\nEditing Restringir\nedición
|
||||
Result Resultado
|
||||
Results Resultados
|
||||
Reverse the order Invertir el orden
|
||||
Reviewing Revisión
|
||||
Reviewing Pane Panel de revisión
|
||||
Reviewing — edits become suggestions Revisión — las ediciones pasan a ser sugerencias
|
||||
Rich Text Format (*.rtf) Formato de texto enriquecido (*.rtf)
|
||||
Rich Text Format (RTF) Formato de texto enriquecido (RTF)
|
||||
Right Derecha
|
||||
Right Border Borde derecho
|
||||
Right-to-Left Text Direction Dirección de texto de derecha a izquierda
|
||||
@@ -712,6 +723,7 @@
|
||||
Text: Texto:
|
||||
Text\nBox Cuadro de\ntexto
|
||||
Text\nDirection Dirección del\ntexto
|
||||
The clipboard is empty. El portapapeles está vacío.
|
||||
The new style takes the formatting of the current paragraph. El nuevo estilo toma el formato del párrafo actual.
|
||||
Theme Colors Colores del tema
|
||||
Theme Fonts Fuentes del tema
|
||||
@@ -739,6 +751,7 @@
|
||||
Underline Words Only Subrayar solo las palabras
|
||||
Undo Deshacer
|
||||
Undo {action} Deshacer {action}
|
||||
Unformatted Text Texto sin formato
|
||||
Up Arriba
|
||||
Update Field Actualizar campo
|
||||
Update Index Actualizar índice
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 25.
|
@@ -931,6 +931,19 @@
|
||||
Dark ダーク
|
||||
Use system setting システム設定を使用
|
||||
A file dialog is already open. ファイル ダイアログが既に開いています。
|
||||
Paste Special 形式を選択して貼り付け
|
||||
Paste Special… 形式を選択して貼り付け…
|
||||
Paste as: 貼り付ける形式:
|
||||
The clipboard is empty. クリップボードは空です。
|
||||
Formatted Text 書式付きテキスト
|
||||
Rich Text Format (RTF) リッチ テキスト形式 (RTF)
|
||||
HTML Format HTML 形式
|
||||
Unformatted Text 書式なしテキスト
|
||||
Result 結果
|
||||
Keeps the fonts and formatting of the copied text. コピーしたテキストのフォントと書式を保持します。
|
||||
Reads the clipboard's rich text and keeps its formatting. クリップボードの RTF を読み込み、書式を保持します。
|
||||
Reads the clipboard's web content and keeps its formatting. クリップボードの Web コンテンツを読み込み、書式を保持します。
|
||||
Drops all formatting and pastes plain text. 書式をすべて取り除き、プレーン テキストとして貼り付けます。
|
||||
Advanced Find 高度な検索
|
||||
Advanced Find… 高度な検索…
|
||||
Go To… ジャンプ…
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 411 and column 13.
|
@@ -943,6 +943,19 @@
|
||||
Dark Escuro
|
||||
Use system setting Usar a configuração do sistema
|
||||
A file dialog is already open. Já há uma caixa de diálogo de arquivo aberta.
|
||||
Paste Special Colar especial
|
||||
Paste Special… Colar especial…
|
||||
Paste as: Colar como:
|
||||
The clipboard is empty. A área de transferência está vazia.
|
||||
Formatted Text Texto formatado
|
||||
Rich Text Format (RTF) Rich Text Format (RTF)
|
||||
HTML Format Formato HTML
|
||||
Unformatted Text Texto sem formatação
|
||||
Result Resultado
|
||||
Keeps the fonts and formatting of the copied text. Mantém as fontes e a formatação do texto copiado.
|
||||
Reads the clipboard's rich text and keeps its formatting. Lê o rich text da área de transferência e mantém a formatação.
|
||||
Reads the clipboard's web content and keeps its formatting. Lê o conteúdo web da área de transferência e mantém a formatação.
|
||||
Drops all formatting and pastes plain text. Remove toda a formatação e cola texto simples.
|
||||
Advanced Find Localização avançada
|
||||
Advanced Find… Localização avançada…
|
||||
Go To… Ir para…
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 25.
|
@@ -217,6 +217,7 @@
|
||||
Drawing Tools Знаряддя малювання
|
||||
Drawing\nCanvas Полотно\nмалювання
|
||||
Drop Cap Буквиця
|
||||
Drops all formatting and pastes plain text. Відкидає все форматування й вставляє звичайний текст.
|
||||
Edit Footer Редагувати нижній колонтитул
|
||||
Edit Header Редагувати верхній колонтитул
|
||||
Edit Recipient List Редагувати список одержувачів
|
||||
@@ -273,6 +274,7 @@
|
||||
Format Page Numbers Формат номерів сторінок
|
||||
Format Page Numbers… Формат номерів сторінок…
|
||||
Format Painter Пензель форматування
|
||||
Formatted Text Форматований текст
|
||||
Formatting at Selection Форматування виділення
|
||||
Formula Формула
|
||||
General Загальні
|
||||
@@ -304,6 +306,7 @@
|
||||
Home Основне
|
||||
Horizontal Горизонтальна
|
||||
Horizontal Line Горизонтальна лінія
|
||||
HTML Format Формат HTML
|
||||
Hyphenation Перенесення слів
|
||||
Icons Піктограми
|
||||
Ignore All Ігнорувати всі
|
||||
@@ -355,6 +358,7 @@
|
||||
Keep Text Only Зберегти лише текст
|
||||
Keep with Next Не відривати від наступного
|
||||
Keep with next Не відривати від наступного
|
||||
Keeps the fonts and formatting of the copied text. Зберігає шрифти й форматування скопійованого тексту.
|
||||
Labels Етикетки
|
||||
Landscape Альбомна
|
||||
Language Мова
|
||||
@@ -496,7 +500,10 @@
|
||||
Paragraphs Абзаци
|
||||
Paste Вставити
|
||||
Paste (⌘V) Вставити (⌘V)
|
||||
Paste as: Вставити як:
|
||||
Paste Formatting Вставити форматування
|
||||
Paste Special Спеціальна вставка
|
||||
Paste Special… Спеціальна вставка…
|
||||
Paste: Keep Text Only Вставлення: зберегти лише текст
|
||||
Paste: Merge Formatting Вставлення: об'єднати форматування
|
||||
PDF document (*.pdf) Документ PDF (*.pdf)
|
||||
@@ -531,6 +538,8 @@
|
||||
Read Mode Режим читання
|
||||
Read\nAloud Читати\nвголос
|
||||
Read\nMode Режим\nчитання
|
||||
Reads the clipboard's rich text and keeps its formatting. Читає форматований текст (RTF) із буфера обміну та зберігає його форматування.
|
||||
Reads the clipboard's web content and keeps its formatting. Читає вебвміст із буфера обміну та зберігає його форматування.
|
||||
Reading Highlight Виділення під час читання
|
||||
Recent Останні
|
||||
Record Macro Записати макрос
|
||||
@@ -569,6 +578,7 @@
|
||||
Restart Each Section Починати в кожному розділі
|
||||
Restrict Editing Обмежити редагування
|
||||
Restrict\nEditing Обмежити\nредагування
|
||||
Result Результат
|
||||
Results Результати
|
||||
Reverse the order Зворотний порядок
|
||||
Review Рецензування
|
||||
@@ -576,6 +586,7 @@
|
||||
Reviewing Pane Панель рецензування
|
||||
Reviewing — edits become suggestions Рецензування — зміни стають пропозиціями
|
||||
Rich Text Format (*.rtf) Форматований текст (*.rtf)
|
||||
Rich Text Format (RTF) Формат RTF (Rich Text Format)
|
||||
Right Праворуч
|
||||
Right Border Права межа
|
||||
Right-to-Left Text Direction Напрямок тексту справа наліво
|
||||
@@ -713,6 +724,7 @@
|
||||
Text: Текст:
|
||||
Text\nBox Текстове\nполе
|
||||
Text\nDirection Напрямок\nтексту
|
||||
The clipboard is empty. Буфер обміну порожній.
|
||||
The new style takes the formatting of the current paragraph. Новий стиль використовує форматування поточного абзацу.
|
||||
Theme Colors Кольори теми
|
||||
Theme Fonts Шрифти теми
|
||||
@@ -739,6 +751,7 @@
|
||||
Underline Words Only Підкреслити лише слова
|
||||
Undo Скасувати
|
||||
Undo {action} Скасувати {action}
|
||||
Unformatted Text Неформатований текст
|
||||
Up Угору
|
||||
Update Field Оновити поле
|
||||
Update Index Оновити покажчик
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 31.
|
@@ -942,6 +942,19 @@
|
||||
Dark 深色
|
||||
Use system setting 使用系统设置
|
||||
A file dialog is already open. 已有一个文件对话框处于打开状态。
|
||||
Paste Special 选择性粘贴
|
||||
Paste Special… 选择性粘贴…
|
||||
Paste as: 粘贴为:
|
||||
The clipboard is empty. 剪贴板为空。
|
||||
Formatted Text 带格式文本
|
||||
Rich Text Format (RTF) RTF 格式
|
||||
HTML Format HTML 格式
|
||||
Unformatted Text 无格式文本
|
||||
Result 结果
|
||||
Keeps the fonts and formatting of the copied text. 保留所复制文本的字体和格式。
|
||||
Reads the clipboard's rich text and keeps its formatting. 读取剪贴板中的 RTF 内容并保留其格式。
|
||||
Reads the clipboard's web content and keeps its formatting. 读取剪贴板中的网页内容并保留其格式。
|
||||
Drops all formatting and pastes plain text. 去除所有格式,粘贴纯文本。
|
||||
Advanced Find 高级查找
|
||||
Advanced Find… 高级查找…
|
||||
Go To… 定位…
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 25.
|
@@ -932,6 +932,19 @@
|
||||
Dark 深色
|
||||
Use system setting 使用系統設定
|
||||
A file dialog is already open. 已有一個檔案對話方塊開啟中。
|
||||
Paste Special 選擇性貼上
|
||||
Paste Special… 選擇性貼上…
|
||||
Paste as: 貼上為:
|
||||
The clipboard is empty. 剪貼簿是空的。
|
||||
Formatted Text 格式化文字
|
||||
Rich Text Format (RTF) RTF 格式
|
||||
HTML Format HTML 格式
|
||||
Unformatted Text 純文字
|
||||
Result 結果
|
||||
Keeps the fonts and formatting of the copied text. 保留所複製文字的字型和格式。
|
||||
Reads the clipboard's rich text and keeps its formatting. 讀取剪貼簿中的 RTF 內容並保留其格式。
|
||||
Reads the clipboard's web content and keeps its formatting. 讀取剪貼簿中的網頁內容並保留其格式。
|
||||
Drops all formatting and pastes plain text. 移除所有格式,貼上純文字。
|
||||
Advanced Find 進階尋找
|
||||
Advanced Find… 進階尋找…
|
||||
Go To… 移至…
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 412 and column 13.
|
@@ -185,7 +185,13 @@ pub fn canvas_events(app: &mut WordApp, ctx: &egui::Context) {
|
||||
}
|
||||
}
|
||||
egui::Event::Paste(t) => {
|
||||
let id = if ctx.input(|i| i.modifiers.shift && i.modifiers.alt) { "edit.pasteText" } else { "edit.paste" };
|
||||
// Mod+Alt+V arrives as a paste too (the shell reads the clipboard): Paste Special.
|
||||
let m = ctx.input(|i| i.modifiers);
|
||||
let id = match (m.alt, m.shift) {
|
||||
(true, true) => "edit.pasteText",
|
||||
(true, false) => "edit.pasteSpecial",
|
||||
_ => "edit.paste",
|
||||
};
|
||||
let _ = app.run(id, json!({"text": t}));
|
||||
}
|
||||
egui::Event::Copy | egui::Event::Cut => {
|
||||
|
||||
@@ -476,6 +476,7 @@ impl WordApp {
|
||||
self.ui.backstage = true;
|
||||
self.ui.backstage_page = "options".into();
|
||||
}
|
||||
"pasteSpecial" => self.dialog = Some(dialogs::Dialog::paste_special(self, req)),
|
||||
other => self.dialog = dialogs::Dialog::open(other, self),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -246,6 +246,7 @@ fn home(app: &mut WordApp, ui: &mut Ui) {
|
||||
mi(ui, app, "Paste", "edit.paste", json!({}));
|
||||
mi(ui, app, "Keep Text Only", "edit.pasteText", json!({}));
|
||||
mi(ui, app, "Merge Formatting", "edit.pasteMerge", json!({}));
|
||||
mi(ui, app, "Paste Special…", "edit.pasteSpecial", json!({}));
|
||||
});
|
||||
stack(ui, |ui| {
|
||||
small(ui, app, "cut", Some("Cut"), "Cut", "edit.cut", json!({}), false);
|
||||
|
||||
+2
-3
@@ -2,13 +2,13 @@
|
||||
|
||||
Generated by `cargo xtask parity` (`wordcraft-cli parity --markdown`): the word-processor feature catalog (`crates/engine/src/catalog.rs`) compared with the live command registry.
|
||||
|
||||
**381 of 430 catalog features have commands (88.6%).**
|
||||
**382 of 430 catalog features have commands (88.8%).**
|
||||
|
||||
| Tab | Live | Total |
|
||||
|---|---|---|
|
||||
| File | 20 | 20 |
|
||||
| Quick Access | 4 | 4 |
|
||||
| Home | 93 | 101 |
|
||||
| Home | 94 | 101 |
|
||||
| Insert | 42 | 53 |
|
||||
| Draw | 0 | 11 |
|
||||
| Design | 10 | 10 |
|
||||
@@ -30,7 +30,6 @@ Generated by `cargo xtask parity` (`wordcraft-cli parity --markdown`): the word-
|
||||
|
||||
### Home
|
||||
|
||||
- Clipboard › Paste Special (`edit.pasteSpecial`)
|
||||
- Clipboard › Clipboard Pane (`edit.clipboardPane`)
|
||||
- Font › Phonetic Guide (`format.phonetic`)
|
||||
- Font › Enclose Characters (`format.enclose`)
|
||||
|
||||
Reference in new issue
Block a user