Co-authored-by: Claude Sonnet 5.5 <noreply@anthropic.com> Co-authored-by: Brandon Thomas <echelon@gmail.com>
This commit is contained in:
parent
19684cff26
commit
8bd3427012
|
|
@ -130,6 +130,7 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm
|
|||
## Progress log
|
||||
|
||||
| Date | What landed |
|
||||
| 2026-10-11 | Hide White Space (#645): `view.hideWhiteSpace` (View › Show checkbox, or double-click the gap between pages) collapses margins, headers and footers between pages to a thin line in Print Layout; screen only, page count unchanged |
|
||||
| 2026-10-11 | Fonts: Palatino Linotype and Book Antiqua fall back to Palatino (macOS) or TeX Gyre Pagella / P052 / URW Palladio (Linux) before generic serif (#670) |
|
||||
|---|---|
|
||||
| 2026-10-11 | Security review fixes: `ui.render`/`ui.screenshot` without a `path` (and the MCP bridge's `render_page`/`screenshot`) return the PNG in the reply instead of writing a fixed name into the shared temporary folder; exported HTML/Markdown write `href` only for web, mail, bookmark and relative links, never `javascript:` and other schemes (one allow-list in `wordcraft_doc::link`) |
|
||||
|
|
|
|||
|
|
@ -27,6 +27,10 @@ pub fn specs() -> Vec<CommandSpec> {
|
|||
// The body as an outline of its headings, with the Outlining tab (`outline.*`).
|
||||
CommandSpec::new("view.outline", "Outline", "View › Views", |s, _| mode(s, ViewMode::Outline, false)).key("Mod+Alt+O").pure(),
|
||||
CommandSpec::new("view.readMode", "Read Mode", "View › Views", |s, _| mode(s, ViewMode::Print, true)).pure(),
|
||||
// Print Layout: hide the white space (margins, headers, footers) between pages. Screen only.
|
||||
CommandSpec::new("view.hideWhiteSpace", "Hide White Space", "View › Show", |s, v| toggle(s, v, |x| &mut x.hide_white_space))
|
||||
.params(r#"{"value"?: bool}"#)
|
||||
.pure(),
|
||||
CommandSpec::new("view.focus", "Focus", "View › Immersive", |s, v| toggle(s, v, |x| &mut x.focus_mode)).pure(),
|
||||
CommandSpec::new("view.ruler", "Ruler", "View › Show", |s, v| toggle(s, v, |x| &mut x.ruler)).pure(),
|
||||
// The drawing grid over the page (screen only; never printed or exported).
|
||||
|
|
|
|||
|
|
@ -95,6 +95,11 @@ pub struct ViewState {
|
|||
/// Show field codes instead of their results (`view.fieldCodes`, Alt+F9).
|
||||
#[serde(default)]
|
||||
pub field_codes: bool,
|
||||
/// Print Layout › Hide White Space: pages run together, the top and bottom margins (with
|
||||
/// headers and footers) between pages collapse to a thin line. On screen only; the layout
|
||||
/// and page count are unchanged.
|
||||
#[serde(default)]
|
||||
pub hide_white_space: bool,
|
||||
/// Outline view: collapsed headings, Show Level, first lines, text formatting.
|
||||
#[serde(default)]
|
||||
pub outline: crate::cmd::outline::OutlineState,
|
||||
|
|
@ -136,6 +141,7 @@ impl Default for ViewState {
|
|||
hide_ink: false,
|
||||
draw: Default::default(),
|
||||
field_codes: false,
|
||||
hide_white_space: false,
|
||||
outline: Default::default(),
|
||||
design_mode: false,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3588,6 +3588,17 @@ fn note_options_per_section_and_convert_notes() {
|
|||
assert!(s.run("references.convertNotes", &json!({"to": "sideways"})).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hide_white_space_toggles_view_state_only() {
|
||||
let mut s = s();
|
||||
let before = s.doc.body.len();
|
||||
assert!(!s.view.hide_white_space);
|
||||
assert_eq!(run(&mut s, "view.hideWhiteSpace", json!({}))["value"], true);
|
||||
assert!(s.view.hide_white_space);
|
||||
assert_eq!(run(&mut s, "view.hideWhiteSpace", json!({"value": false}))["value"], false);
|
||||
assert_eq!(s.doc.body.len(), before, "a view option never edits the document");
|
||||
}
|
||||
|
||||
/// The first SmartArt (or other graphic) in the body, selected.
|
||||
fn select_graphic(s: &mut Session) {
|
||||
let (path, off) = (0..s.doc.body.len() as u32)
|
||||
|
|
|
|||
|
|
@ -181,6 +181,62 @@ pub struct Geometry {
|
|||
pub scale: f32,
|
||||
/// Pages per row.
|
||||
pub cols: usize,
|
||||
/// Per page, the vertical range (pixels from the page rect's top) that is on screen. The
|
||||
/// whole page, except with Hide White Space, which cuts the margins between pages.
|
||||
pub bands: Vec<(f32, f32)>,
|
||||
}
|
||||
|
||||
/// Room kept above the first line and below the last line of a page when white space is hidden (points).
|
||||
const WHITE_SPACE_PAD: f32 = 6.0;
|
||||
/// The line drawn between two pages when white space is hidden (pixels).
|
||||
const SEAM: f32 = 1.0;
|
||||
|
||||
/// Hide White Space: stack single-column pages (`rects`, full pages) so that only each page's
|
||||
/// text area (`bodies`: top and bottom of the body in points, plus a little room) shows, with a
|
||||
/// [`SEAM`] between pages. The first page keeps its top and the last its bottom. Rects keep the
|
||||
/// full page size, so page coordinates still map as `(p - rect.min) / scale`; neighbouring rects
|
||||
/// overlap in the hidden margins. Returns the moved rects, the visible band of each, and the
|
||||
/// content height.
|
||||
fn collapse_white_space(rects: &[Rect], bodies: &[(f32, f32)], scale: f32, avail_h: f32) -> (Vec<Rect>, Vec<(f32, f32)>, f32) {
|
||||
let n = rects.len();
|
||||
let scale = if scale.is_finite() { scale.clamp(0.01, 10.0) } else { PX_PER_PT };
|
||||
let avail_h = if avail_h.is_finite() { avail_h } else { 0.0 };
|
||||
let mut out = Vec::with_capacity(n);
|
||||
let mut bands = Vec::with_capacity(n);
|
||||
let Some(first) = rects.first() else { return (out, bands, avail_h) };
|
||||
let mut cursor = first.min.y;
|
||||
for (i, r) in rects.iter().enumerate() {
|
||||
let h = if r.height().is_finite() { r.height().max(0.0) } else { 0.0 };
|
||||
let (bt, bb) = bodies.get(i).copied().unwrap_or((0.0, h));
|
||||
let fin = |v: f32, d: f32| if v.is_finite() { v } else { d };
|
||||
let top = if i == 0 { 0.0 } else { (fin(bt, 0.0) * scale - WHITE_SPACE_PAD * scale).clamp(0.0, h) };
|
||||
let bot = if i + 1 == n { h } else { (fin(bb, 0.0) * scale + WHITE_SPACE_PAD * scale).clamp(top, h) };
|
||||
let y = cursor - top;
|
||||
out.push(Rect::from_min_size(pos2(r.min.x, y), r.size()));
|
||||
bands.push((top, bot));
|
||||
cursor = y + bot + SEAM;
|
||||
}
|
||||
(out, bands, (cursor - SEAM + GAP).max(avail_h))
|
||||
}
|
||||
|
||||
/// True when any page is cut to a band (white space is hidden).
|
||||
fn white_space_hidden(rects: &[Rect], bands: &[(f32, f32)]) -> bool {
|
||||
rects.iter().zip(bands).any(|(r, b)| b.0 > 0.5 || b.1 < r.height() - 0.5)
|
||||
}
|
||||
|
||||
/// True when a double-click at `p` is on the space between two pages (or on the seam when white
|
||||
/// space is hidden): Word's way to toggle Hide White Space.
|
||||
fn on_page_gap(rects: &[Rect], bands: &[(f32, f32)], p: Pos2) -> bool {
|
||||
let hidden = white_space_hidden(rects, bands);
|
||||
rects.windows(2).enumerate().any(|(i, w)| {
|
||||
let (a, b) = (w[0], w[1]);
|
||||
let in_x = p.x >= a.min.x.min(b.min.x) && p.x <= a.max.x.max(b.max.x);
|
||||
let (y0, y1) = match bands.get(i) {
|
||||
Some(&(_, bot)) if hidden => (a.min.y + bot - 3.0, a.min.y + bot + SEAM + 3.0),
|
||||
_ => (a.max.y, b.min.y),
|
||||
};
|
||||
in_x && p.y >= y0 && p.y <= y1
|
||||
})
|
||||
}
|
||||
|
||||
/// Width of the markup area beside each page for comment balloons (points), or 0.
|
||||
|
|
@ -281,8 +337,16 @@ pub fn geometry(app: &WordApp, l: &DocLayout, avail: egui::Vec2) -> Geometry {
|
|||
};
|
||||
let cols = if v.read_mode { 2 } else { columns_for(avail.x, maxw * scale, l.pages.len(), min_cols, auto) };
|
||||
let sizes: Vec<(f32, f32)> = l.pages.iter().map(|p| (p.w, p.h)).collect();
|
||||
let (rects, size) = place_pages(&sizes, markup, scale, cols, avail);
|
||||
Geometry { rects, size, scale, cols }
|
||||
let (mut rects, mut size) = place_pages(&sizes, markup, scale, cols, avail);
|
||||
let mut bands: Vec<(f32, f32)> = rects.iter().map(|r| (0.0, r.height())).collect();
|
||||
let editing_part = matches!(app.session.sel.focus.story, StoryRef::Part(_));
|
||||
if v.hide_white_space && v.mode == wordcraft_layout::ViewMode::Print && !v.read_mode && cols == 1 && !editing_part && rects.len() > 1 {
|
||||
let bodies: Vec<(f32, f32)> = l.pages.iter().map(|p| (p.body.y, p.body.bottom())).collect();
|
||||
let (r, b, h) = collapse_white_space(&rects, &bodies, scale, avail.y);
|
||||
(rects, bands) = (r, b);
|
||||
size.y = h;
|
||||
}
|
||||
Geometry { rects, size, scale, cols, bands }
|
||||
}
|
||||
|
||||
/// While a fit mode (Page Width, One Page, Multiple Pages) sizes the page, keep the session's zoom
|
||||
|
|
@ -476,6 +540,7 @@ pub fn show(app: &mut WordApp, ui: &mut Ui) {
|
|||
for (i, pr) in geo.rects.iter().enumerate() {
|
||||
let sr = pr.translate(content_origin.to_vec2());
|
||||
let Some(page) = layout.pages.get(i) else { continue };
|
||||
let (band_top, band_bot) = geo.bands.get(i).copied().unwrap_or((0.0, sr.height()));
|
||||
let scale_px = (geo.scale * ppp).min(max_tex / page.w.max(1.0)).min(max_tex / page.h.clamp(1.0, 1e6)).max(0.05);
|
||||
let key = page_key(app, page, scale_px, dim_body);
|
||||
let fresh = app.canvas.textures.get(&i).is_some_and(|(k, _)| *k == key);
|
||||
|
|
@ -486,9 +551,17 @@ pub fn show(app: &mut WordApp, ui: &mut Ui) {
|
|||
sr
|
||||
};
|
||||
rects.push(visual_sr);
|
||||
if !sr.intersects(ui.clip_rect().expand(200.0)) {
|
||||
// Hide White Space shows only a band of the page; everything below paints into it.
|
||||
let band =
|
||||
Rect::from_min_max(pos2(visual_sr.min.x - 4.0, visual_sr.min.y + band_top), pos2(visual_sr.max.x + 4.0, visual_sr.min.y + band_bot));
|
||||
if !band.intersects(ui.clip_rect().expand(200.0)) {
|
||||
continue;
|
||||
}
|
||||
let painter = if band_top <= 0.0 && band_bot >= sr.height() {
|
||||
painter.clone()
|
||||
} else {
|
||||
painter.with_clip_rect(painter.clip_rect().intersect(band))
|
||||
};
|
||||
// Shadow and paper.
|
||||
painter.rect_filled(visual_sr.translate(vec2(0.0, 2.0)).expand(1.5), 1.0, t.page_shadow);
|
||||
painter.rect_filled(visual_sr, 0.0, if dark_page { Color32::from_gray(dark_paper) } else { Color32::WHITE });
|
||||
|
|
@ -581,6 +654,14 @@ pub fn show(app: &mut WordApp, ui: &mut Ui) {
|
|||
}
|
||||
}
|
||||
}
|
||||
// Hide White Space: a thin line where one page ends and the next begins.
|
||||
for (i, pair) in rects.windows(2).enumerate() {
|
||||
let Some(&(_, bot)) = geo.bands.get(i) else { continue };
|
||||
if white_space_hidden(&geo.rects, &geo.bands) {
|
||||
let y = pair[0].min.y + bot;
|
||||
painter.rect_filled(Rect::from_min_max(pos2(pair[0].min.x, y), pos2(pair[0].max.x, y + SEAM)), 0.0, t.border_strong);
|
||||
}
|
||||
}
|
||||
if rendered > 0 {
|
||||
app.canvas.render_ms = crate::now_ms() - t0;
|
||||
}
|
||||
|
|
@ -749,6 +830,15 @@ pub fn show(app: &mut WordApp, ui: &mut Ui) {
|
|||
app.canvas.focused = resp.has_focus();
|
||||
// Screen readers: the document under the canvas, with the caret (#488).
|
||||
crate::a11y::expose(app, ui.ctx(), resp.id, &layout, &rects, area);
|
||||
// Double-click the space between two pages (or the line between them) to hide or show it.
|
||||
if resp.double_clicked()
|
||||
&& app.session.view.mode == wordcraft_layout::ViewMode::Print
|
||||
&& !app.session.view.read_mode
|
||||
&& let Some(p) = resp.interact_pointer_pos()
|
||||
&& on_page_gap(&rects, &geo.bands, p)
|
||||
{
|
||||
let _ = app.run("view.hideWhiteSpace", json!({"value": !app.session.view.hide_white_space}));
|
||||
} else
|
||||
// Draw Table / Eraser own the mouse while on (no caret moves); else the usual editing.
|
||||
if !crate::table_pen::pointer(app, ui, &resp, &rects, &layout, geo.scale) {
|
||||
mouse(app, ui, &resp, &rects, &layout, geo.scale);
|
||||
|
|
@ -1123,7 +1213,8 @@ pub fn nearest_page(rects: &[Rect], p: Pos2) -> Option<usize> {
|
|||
let mut best: Option<(usize, f32)> = None;
|
||||
for (i, r) in rects.iter().enumerate() {
|
||||
let d = if r.contains(p) {
|
||||
0.0
|
||||
// Pages overlap in the margins Hide White Space cuts: the one whose middle is nearer wins.
|
||||
1e-3 * (p.y - r.center().y).abs() / r.height().max(1.0)
|
||||
} else {
|
||||
let dx = (r.min.x - p.x).max(p.x - r.max.x).max(0.0);
|
||||
let dy = (r.min.y - p.y).max(p.y - r.max.y).max(0.0);
|
||||
|
|
@ -2223,4 +2314,36 @@ mod tests {
|
|||
|
||||
assert_eq!(visual, layout);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hiding_white_space_stacks_page_bands_and_keeps_hit_testing() {
|
||||
let scale = 1.0;
|
||||
let sizes = [(612.0, 792.0); 3];
|
||||
let (rects, _) = place_pages(&sizes, 0.0, scale, 1, vec2(800.0, 600.0));
|
||||
// Body from 72 to 720 pt on every page.
|
||||
let bodies = [(72.0, 720.0); 3];
|
||||
let (moved, bands, h) = collapse_white_space(&rects, &bodies, scale, 600.0);
|
||||
assert_eq!(bands[0].0, 0.0, "first page keeps its top");
|
||||
assert_eq!(bands[2].1, 792.0, "last page keeps its bottom");
|
||||
assert_eq!(bands[1], (66.0, 726.0));
|
||||
// Bands abut with one seam, so the content is much shorter than three full pages.
|
||||
assert_eq!(moved[1].min.y + bands[1].0, moved[0].min.y + bands[0].1 + SEAM);
|
||||
assert!(h < rects[2].bottom() - 2.0 * 66.0 * 2.0);
|
||||
// A point inside page 2's band maps to that page and the right page coordinates.
|
||||
let p = pos2(moved[1].center().x, moved[1].min.y + 300.0);
|
||||
assert_eq!(page_at_index(&moved, p), Some(1));
|
||||
// Just above the seam is page 1's text area, just below is page 2's, though the rects overlap.
|
||||
let seam = moved[0].min.y + bands[0].1;
|
||||
assert_eq!(nearest_page(&moved, pos2(moved[0].center().x, seam - 2.0)), Some(0));
|
||||
assert_eq!(nearest_page(&moved, pos2(moved[0].center().x, seam + SEAM + 2.0)), Some(1));
|
||||
assert!(on_page_gap(&moved, &bands, pos2(moved[0].center().x, seam + 0.5)));
|
||||
assert!(!on_page_gap(&moved, &bands, pos2(moved[0].center().x, seam - 40.0)));
|
||||
// Hostile numbers never panic.
|
||||
let _ = collapse_white_space(&rects, &[(f32::NAN, f32::INFINITY), (-1e9, 1e9)], f32::NAN, f32::NAN);
|
||||
let _ = collapse_white_space(&[], &[], 1.0, 1.0);
|
||||
}
|
||||
|
||||
fn page_at_index(rects: &[Rect], p: Pos2) -> Option<usize> {
|
||||
nearest_page(rects, p)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1698,6 +1698,7 @@
|
|||
Continuous numbering Numeración continua
|
||||
Apply Aplicar
|
||||
Convert Notes Convertir notas
|
||||
Hide White Space Ocultar espacio en blanco
|
||||
All pages Todas las páginas
|
||||
Current page Página actual
|
||||
Selected pages Páginas seleccionadas
|
||||
|
|
|
|||
|
Can't render this file because it contains an unexpected character in line 6 and column 25.
|
|
|
@ -1659,6 +1659,7 @@
|
|||
Continuous numbering Pidev nummerdus
|
||||
Apply Rakenda
|
||||
Convert Notes Teisenda märkused
|
||||
Hide White Space Peida tühi ala lehtede vahel
|
||||
All pages Kõik leheküljed
|
||||
Current page Praegune lehekülg
|
||||
Selected pages Valitud leheküljed
|
||||
|
|
|
|||
|
Can't render this file because it has a wrong number of fields in line 12.
|
|
|
@ -1682,6 +1682,7 @@
|
|||
Continuous numbering 通し番号
|
||||
Apply 適用
|
||||
Convert Notes 注の変換
|
||||
Hide White Space 余白を非表示
|
||||
All pages すべてのページ
|
||||
Current page 現在のページ
|
||||
Selected pages 選択したページ
|
||||
|
|
|
|||
|
Can't render this file because it has a wrong number of fields in line 3.
|
|
|
@ -1694,6 +1694,7 @@
|
|||
Continuous numbering Numeração contínua
|
||||
Apply Aplicar
|
||||
Convert Notes Converter notas
|
||||
Hide White Space Ocultar espaço em branco
|
||||
All pages Todas as páginas
|
||||
Current page Página atual
|
||||
Selected pages Páginas selecionadas
|
||||
|
|
|
|||
|
Can't render this file because it contains an unexpected character in line 6 and column 25.
|
|
|
@ -2127,6 +2127,7 @@ preview Title Пример
|
|||
Continuous numbering Сквозная нумерация
|
||||
Apply Применить
|
||||
Convert Notes Преобразовать сноски
|
||||
Hide White Space Скрыть пустое место между страницами
|
||||
All pages Все страницы
|
||||
Current page Текущая страница
|
||||
Selected pages Выбранные страницы
|
||||
|
|
|
|||
|
Can't render this file because it contains an unexpected character in line 7 and column 25.
|
|
|
@ -1692,6 +1692,7 @@
|
|||
Continuous numbering Neprekidna numeracija
|
||||
Apply Primeni
|
||||
Convert Notes Pretvori napomene
|
||||
Hide White Space Sakrij prazan prostor
|
||||
All pages Sve stranice
|
||||
Current page Trenutna stranica
|
||||
Selected pages Izabrane stranice
|
||||
|
|
|
|||
|
Can't render this file because it contains an unexpected character in line 6 and column 25.
|
|
|
@ -1696,6 +1696,7 @@
|
|||
Continuous numbering Непрекидна нумерација
|
||||
Apply Примени
|
||||
Convert Notes Претвори напомене
|
||||
Hide White Space Сакриј празан простор
|
||||
All pages Све странице
|
||||
Current page Тренутна страница
|
||||
Selected pages Изабране странице
|
||||
|
|
|
|||
|
Can't render this file because it contains an unexpected character in line 11 and column 25.
|
|
|
@ -1749,6 +1749,7 @@
|
|||
Continuous numbering Sürekli numaralandırma
|
||||
Apply Uygula
|
||||
Convert Notes Notları Dönüştür
|
||||
Hide White Space Boşluğu gizle
|
||||
All pages Tüm sayfalar
|
||||
Current page Geçerli sayfa
|
||||
Selected pages Seçili sayfalar
|
||||
|
|
|
|||
|
Can't render this file because it has a wrong number of fields in line 18.
|
|
|
@ -1694,6 +1694,7 @@
|
|||
Continuous numbering Наскрізна нумерація
|
||||
Apply Застосувати
|
||||
Convert Notes Перетворити примітки
|
||||
Hide White Space Приховати порожній простір
|
||||
All pages Усі сторінки
|
||||
Current page Поточна сторінка
|
||||
Selected pages Вибрані сторінки
|
||||
|
|
|
|||
|
Can't render this file because it contains an unexpected character in line 6 and column 31.
|
|
|
@ -1694,6 +1694,7 @@
|
|||
Continuous numbering 连续编号
|
||||
Apply 应用
|
||||
Convert Notes 转换注释
|
||||
Hide White Space 隐藏空白
|
||||
All pages 所有页面
|
||||
Current page 当前页
|
||||
Selected pages 所选页面
|
||||
|
|
|
|||
|
Can't render this file because it contains an unexpected character in line 7 and column 25.
|
|
|
@ -1683,6 +1683,7 @@
|
|||
Continuous numbering 連續編號
|
||||
Apply 套用
|
||||
Convert Notes 轉換附註
|
||||
Hide White Space 隱藏空白
|
||||
All pages 所有頁面
|
||||
Current page 目前頁面
|
||||
Selected pages 所選頁面
|
||||
|
|
|
|||
|
Can't render this file because it has a wrong number of fields in line 4.
|
|
|
@ -1501,6 +1501,10 @@ fn view(app: &mut WordApp, ui: &mut Ui) {
|
|||
if ui.checkbox(&mut n, tl!("Navigation Pane")).changed() {
|
||||
let _ = app.run("view.navigationPane", json!({"value": n}));
|
||||
}
|
||||
let mut w = v.hide_white_space;
|
||||
if ui.checkbox(&mut w, tl!("Hide White Space")).changed() {
|
||||
let _ = app.run("view.hideWhiteSpace", json!({"value": w}));
|
||||
}
|
||||
});
|
||||
});
|
||||
group(ui, "Zoom", None, app, |ui, app| {
|
||||
|
|
|
|||
Loading…
Reference in New Issue