mirror of
https://github.com/storytold/wordcraft.git
synced 2026-11-06 18:29:46 -06:00
Compare commits
16
Commits
house-rules
...
v0.16.0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
02a802cbaf | ||
|
|
6e266e8525 | ||
|
|
729cd0a5fc | ||
|
|
8c46f4fc1b | ||
|
|
2a0a20aec5 | ||
|
|
247c7feaab | ||
|
|
8833c604e0 | ||
|
|
cad8ede1e8 | ||
|
|
beca3f3485 | ||
|
|
cfcc8c2aec | ||
|
|
64a3badfe3 | ||
|
|
edfea68101 | ||
|
|
74d3701c74 | ||
|
|
8512ac6f36 | ||
|
|
421700e890 | ||
|
|
d69cbec87f |
Generated
+19
-19
@@ -6795,7 +6795,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wordcraft"
|
||||
version = "0.15.1"
|
||||
version = "0.16.0"
|
||||
dependencies = [
|
||||
"arboard",
|
||||
"chrono",
|
||||
@@ -6833,7 +6833,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wordcraft-cli"
|
||||
version = "0.15.1"
|
||||
version = "0.16.0"
|
||||
dependencies = [
|
||||
"serde_json",
|
||||
"sys-locale",
|
||||
@@ -6846,11 +6846,11 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wordcraft-control-key"
|
||||
version = "0.15.1"
|
||||
version = "0.16.0"
|
||||
|
||||
[[package]]
|
||||
name = "wordcraft-doc"
|
||||
version = "0.15.1"
|
||||
version = "0.16.0"
|
||||
dependencies = [
|
||||
"base64 0.23.1",
|
||||
"log",
|
||||
@@ -6864,7 +6864,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wordcraft-docbin"
|
||||
version = "0.15.1"
|
||||
version = "0.16.0"
|
||||
dependencies = [
|
||||
"cfb",
|
||||
"chrono",
|
||||
@@ -6881,7 +6881,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wordcraft-docx"
|
||||
version = "0.15.1"
|
||||
version = "0.16.0"
|
||||
dependencies = [
|
||||
"aes",
|
||||
"base64 0.23.1",
|
||||
@@ -6906,7 +6906,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wordcraft-engine"
|
||||
version = "0.15.1"
|
||||
version = "0.16.0"
|
||||
dependencies = [
|
||||
"cfb",
|
||||
"chrono",
|
||||
@@ -6939,7 +6939,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wordcraft-fonts"
|
||||
version = "0.15.1"
|
||||
version = "0.16.0"
|
||||
dependencies = [
|
||||
"brotli-decompressor",
|
||||
"harfrust 0.13.3",
|
||||
@@ -6953,7 +6953,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wordcraft-formats"
|
||||
version = "0.15.1"
|
||||
version = "0.16.0"
|
||||
dependencies = [
|
||||
"aes",
|
||||
"base64 0.23.1",
|
||||
@@ -6980,7 +6980,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wordcraft-geom"
|
||||
version = "0.15.1"
|
||||
version = "0.16.0"
|
||||
dependencies = [
|
||||
"image",
|
||||
"serde",
|
||||
@@ -6988,7 +6988,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wordcraft-layout"
|
||||
version = "0.15.1"
|
||||
version = "0.16.0"
|
||||
dependencies = [
|
||||
"craft-proof",
|
||||
"image",
|
||||
@@ -7008,7 +7008,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wordcraft-mcp"
|
||||
version = "0.15.1"
|
||||
version = "0.16.0"
|
||||
dependencies = [
|
||||
"log",
|
||||
"serde_json",
|
||||
@@ -7020,7 +7020,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wordcraft-metafile"
|
||||
version = "0.15.1"
|
||||
version = "0.16.0"
|
||||
dependencies = [
|
||||
"kurbo",
|
||||
"thiserror 2.0.21",
|
||||
@@ -7028,7 +7028,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wordcraft-pdf"
|
||||
version = "0.15.1"
|
||||
version = "0.16.0"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"craft-pdf",
|
||||
@@ -7049,7 +7049,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wordcraft-render"
|
||||
version = "0.15.1"
|
||||
version = "0.16.0"
|
||||
dependencies = [
|
||||
"image",
|
||||
"log",
|
||||
@@ -7063,7 +7063,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wordcraft-ui-egui"
|
||||
version = "0.15.1"
|
||||
version = "0.16.0"
|
||||
dependencies = [
|
||||
"accesskit_consumer",
|
||||
"cfb",
|
||||
@@ -7096,7 +7096,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wordcraft-web"
|
||||
version = "0.15.1"
|
||||
version = "0.16.0"
|
||||
dependencies = [
|
||||
"craft-web-host",
|
||||
"eframe",
|
||||
@@ -7116,7 +7116,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wordcraft-zotero"
|
||||
version = "0.15.1"
|
||||
version = "0.16.0"
|
||||
dependencies = [
|
||||
"log",
|
||||
"proptest",
|
||||
@@ -7279,7 +7279,7 @@ checksum = "9440ea3e5aeabb0ac63af70daf835274065238cdd0cec83418f417eae38bacee"
|
||||
|
||||
[[package]]
|
||||
name = "xtask"
|
||||
version = "0.15.1"
|
||||
version = "0.16.0"
|
||||
dependencies = [
|
||||
"serde_json",
|
||||
]
|
||||
|
||||
+1
-1
@@ -4,7 +4,7 @@ members = ["crates/*", "apps/*", "xtask"]
|
||||
default-members = ["crates/*", "apps/wordcraft", "apps/wordcraft-cli", "xtask"]
|
||||
|
||||
[workspace.package]
|
||||
version = "0.15.1"
|
||||
version = "0.16.0"
|
||||
edition = "2024"
|
||||
license = "MIT OR Apache-2.0"
|
||||
rust-version = "1.95"
|
||||
|
||||
+8
-1
File diff suppressed because one or more lines are too long
+1
-1
@@ -1 +1 @@
|
||||
9d6d54d6d9ff603cc41e12e5f6ba51af3554ab66
|
||||
1e515422afdda2f317af430c1f9d68a5f83935b4
|
||||
|
||||
@@ -316,20 +316,34 @@ impl InlineObject {
|
||||
/// A group's members placed in a `w` × `h` box at (`x`, `y`): each member's rectangle
|
||||
/// (x, y, w, h) and the member. Empty for anything else.
|
||||
pub fn group_rects(&self, x: f32, y: f32, w: f32, h: f32) -> Vec<([f32; 4], &InlineObject)> {
|
||||
let InlineObject::Group { ch_w, ch_h, children, .. } = self else { return Vec::new() };
|
||||
let InlineObject::Group { children, .. } = self else { return Vec::new() };
|
||||
self.member_rects(x, y, w, h).into_iter().filter_map(|(i, r)| Some((r, &children.get(i)?.obj))).collect()
|
||||
}
|
||||
/// [`InlineObject::group_rects`] with each member's index in `children` instead of the member.
|
||||
pub fn member_rects(&self, x: f32, y: f32, w: f32, h: f32) -> Vec<(usize, [f32; 4])> {
|
||||
let InlineObject::Group { children, .. } = self else { return Vec::new() };
|
||||
let (sx, sy) = self.group_scale(w, h);
|
||||
let fin = |v: f32| if v.is_finite() { v } else { 0.0 };
|
||||
let sx = if fin(*ch_w) > 0.0 { fin(w) / ch_w } else { 1.0 };
|
||||
let sy = if fin(*ch_h) > 0.0 { fin(h) / ch_h } else { 1.0 };
|
||||
children
|
||||
.iter()
|
||||
.take(MAX_GROUP_CHILDREN)
|
||||
.filter_map(|c| {
|
||||
.enumerate()
|
||||
.filter_map(|(i, c)| {
|
||||
let (cw, chh, _) = c.obj.frame()?;
|
||||
let r = [x + fin(c.x) * sx, y + fin(c.y) * sy, (fin(cw) * sx).clamp(0.0, 4000.0), (fin(chh) * sy).clamp(0.0, 4000.0)];
|
||||
(!matches!(c.obj, InlineObject::Group { .. })).then_some((r, &c.obj))
|
||||
(!matches!(c.obj, InlineObject::Group { .. })).then_some((i, r))
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
/// How much a group drawn `w` × `h` scales its members' space (x, y): 1 for a canvas, whose
|
||||
/// members keep their size.
|
||||
pub fn group_scale(&self, w: f32, h: f32) -> (f32, f32) {
|
||||
let InlineObject::Group { ch_w, ch_h, .. } = self else { return (1.0, 1.0) };
|
||||
let fin = |v: f32| if v.is_finite() { v } else { 0.0 };
|
||||
let sx = if fin(*ch_w) > 0.0 { fin(w) / ch_w } else { 1.0 };
|
||||
let sy = if fin(*ch_h) > 0.0 { fin(h) / ch_h } else { 1.0 };
|
||||
(sx, sy)
|
||||
}
|
||||
/// A legacy form field's definition (`FORMCHECKBOX`, `FORMTEXT`, `FORMDROPDOWN`), read from
|
||||
/// the `w:ffData` it keeps; `None` for anything else.
|
||||
pub fn legacy_form(&self) -> Option<crate::legacy_form::LegacyForm> {
|
||||
|
||||
@@ -169,6 +169,9 @@ pub(crate) fn delete_selection_effect(s: &mut Session) -> Result<SelectionDelete
|
||||
if a == b {
|
||||
return Ok(SelectionDelete { at: a, content_changed: false, state_changed: false });
|
||||
}
|
||||
if canvas::selected_member(s).is_some() {
|
||||
return Err(CmdError::Disabled("a shape inside a drawing canvas is selected".into()));
|
||||
}
|
||||
controls::check_delete(s, &a, &b)?;
|
||||
let effect = if s.doc.settings.track_changes {
|
||||
if !track_delete_would_change(s, &a, &b) {
|
||||
@@ -186,7 +189,9 @@ pub(crate) fn delete_selection_effect(s: &mut Session) -> Result<SelectionDelete
|
||||
Ok(effect)
|
||||
}
|
||||
|
||||
/// Delete the selection (respecting track changes); returns the collapsed position.
|
||||
/// Delete the selection (respecting track changes); returns the collapsed position. With a
|
||||
/// Drawing Canvas member picked (#2637) the selection is the whole canvas, which a command that
|
||||
/// replaces the selection (Insert › Table, Equation, Symbol, Field…) would take away: refused.
|
||||
pub fn delete_selection(s: &mut Session) -> Result<Pos, CmdError> {
|
||||
delete_selection_effect(s).map(|e| e.at)
|
||||
}
|
||||
|
||||
@@ -5,6 +5,10 @@
|
||||
//! top-left corner, unscaled). While a canvas is selected, Insert › Pictures, Shapes and Text Box
|
||||
//! put the new object inside it ([`add_member`]). Fit, Expand and Scale Drawing change its frame;
|
||||
//! Shape Fill and Shape Outline colour its background and border.
|
||||
//!
|
||||
//! A member can be picked on its own (#2637, `select.member`; a click on it does this): Shape
|
||||
//! Format, Size, Rotate, Alt Text, Delete, Cut and Copy then act on that member, and Move or
|
||||
//! Resize and Nudge move it inside the canvas. The canvas stays the selection in the text.
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use wordcraft_doc::para::{CanvasStyle, Float, GroupChild, InlineObject, MAX_GROUP_CHILDREN};
|
||||
@@ -14,6 +18,29 @@ use super::objects::object_selection;
|
||||
use super::{delete_selection, sel_result};
|
||||
use crate::{CmdError, CmdResult, CommandSpec, Selection, Session, p};
|
||||
|
||||
/// A member of the selected canvas picked on its own: its index, and the selection it was picked
|
||||
/// in (it counts only while that selection stands).
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct CanvasMember {
|
||||
sel: Selection,
|
||||
pub index: usize,
|
||||
}
|
||||
|
||||
/// The canvas member picked on its own: the canvas's position, the member's index and the member.
|
||||
pub fn selected_member(s: &Session) -> Option<(Pos, usize, &InlineObject)> {
|
||||
let m = s.member.as_ref().filter(|m| m.sel == s.sel)?;
|
||||
let (pos, canvas) = selected_canvas(s)?;
|
||||
let InlineObject::Group { children, .. } = canvas else { return None };
|
||||
let c = children.get(m.index).filter(|c| pickable(c))?;
|
||||
Some((pos, m.index, &c.obj))
|
||||
}
|
||||
|
||||
/// Whether a member can be picked on its own: a picture, shape or text box that is drawn (a
|
||||
/// group inside a canvas isn't, nor a hidden member).
|
||||
pub fn pickable(c: &GroupChild) -> bool {
|
||||
c.obj.is_drawing() && !matches!(c.obj, InlineObject::Group { .. }) && !c.obj.frame().is_some_and(|(_, _, f)| f.hidden)
|
||||
}
|
||||
|
||||
/// Largest canvas side and member offset, points (well past any page).
|
||||
const MAX_SIDE: f32 = 4000.0;
|
||||
/// Smallest canvas side, points.
|
||||
@@ -47,6 +74,10 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
CommandSpec::new("canvas.scale", "Scale Drawing", "Shape Format › Drawing Canvas", scale)
|
||||
.params(r#"{"factor": number (0.1–10)} (scales the frame and every member together)"#)
|
||||
.when(has_canvas),
|
||||
CommandSpec::new("select.member", "Select Canvas Member", "Shape Format › Drawing Canvas", select_member)
|
||||
.params(r#"{"index": n | null} (picks member `index` of the selected drawing canvas on its own: Shape Format, Size, Rotate, Alt Text, Delete, Cut and Copy then act on it, and Move or Resize and Nudge move it inside the canvas; null: the canvas itself again)"#)
|
||||
.when(has_canvas)
|
||||
.pure(),
|
||||
CommandSpec::new("canvas.member", "Canvas Member", "Shape Format › Drawing Canvas", member)
|
||||
.params(r#"{"index": n, "x"?: pt, "y"?: pt, "width"?: pt, "height"?: pt, "delete"?: bool} (moves, resizes or removes member `index` of the selected canvas; x/y from the canvas's top-left; with no change, returns the members)"#)
|
||||
.when(has_canvas),
|
||||
@@ -224,9 +255,171 @@ fn member(s: &mut Session, v: &Value) -> CmdResult {
|
||||
c.obj.set_size(nw.unwrap_or(w0).clamp(1.0, MAX_SIDE), nh.unwrap_or(h0).clamp(1.0, MAX_SIDE));
|
||||
}
|
||||
})?;
|
||||
// The indices after a removed member shift down, so a picked member would name another one.
|
||||
if delete {
|
||||
s.member = None;
|
||||
}
|
||||
result(&o)
|
||||
}
|
||||
|
||||
/// `select.member`: pick a member of the selected canvas on its own, or (`null`) the canvas again.
|
||||
fn select_member(s: &mut Session, v: &Value) -> CmdResult {
|
||||
let Some((_, InlineObject::Group { children, .. })) = selected_canvas(s) else {
|
||||
return Err(CmdError::Disabled("select a drawing canvas first".into()));
|
||||
};
|
||||
let index = match v.get("index") {
|
||||
None | Some(Value::Null) => None,
|
||||
Some(_) => {
|
||||
Some(p::u64(v, "index").and_then(|i| usize::try_from(i).ok()).ok_or_else(|| CmdError::Params("`index` is a member number".into()))?)
|
||||
}
|
||||
};
|
||||
let Some(i) = index else {
|
||||
s.member = None;
|
||||
return sel_result(s);
|
||||
};
|
||||
let count = children.len();
|
||||
let c = children.get(i).ok_or_else(|| CmdError::Params(format!("no member {i} (the canvas has {count})")))?;
|
||||
if !pickable(c) {
|
||||
return Err(CmdError::Params(format!("member {i} can't be selected on its own")));
|
||||
}
|
||||
let (w, h) = c.obj.frame().map(|(w, h, _)| (w, h)).unwrap_or((0.0, 0.0));
|
||||
let out = json!({"index": i, "x": c.x, "y": c.y, "width": w, "height": h, "object": serde_json::to_value(&c.obj).unwrap_or(Value::Null)});
|
||||
s.member = Some(CanvasMember { sel: s.sel.clone(), index: i });
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// Delete, Backspace (and their word forms), Cut and Copy with a member picked act on the member
|
||||
/// alone; the canvas stays selected. Typing and pasting, which would replace the whole canvas,
|
||||
/// are disabled. `None`: another command, or no member picked.
|
||||
pub(crate) fn member_command(s: &mut Session, id: &str, _: &Value) -> Option<CmdResult> {
|
||||
let replaces = (id.starts_with("text.") && id != "text.overtype")
|
||||
|| matches!(
|
||||
id,
|
||||
"edit.paste" | "edit.pasteText" | "edit.pasteMerge" | "edit.pasteSpecial" | "edit.pasteClipboardItem" | "edit.pasteAllClipboard"
|
||||
);
|
||||
if !replaces && !matches!(id, "edit.cut" | "edit.copy") {
|
||||
return None;
|
||||
}
|
||||
let (pos, index, _) = selected_member(s)?;
|
||||
Some(match id {
|
||||
"edit.copy" => copy_member(s, &pos, index),
|
||||
"edit.cut" => copy_member(s, &pos, index).and_then(|r| remove_member(s, &pos, index).map(|_| r)),
|
||||
"text.delete" | "text.backspace" | "text.deleteWordBack" | "text.deleteWordForward" => {
|
||||
remove_member(s, &pos, index).and_then(|_| sel_result(s))
|
||||
}
|
||||
_ => Err(CmdError::Disabled("a shape inside a drawing canvas is selected".into())),
|
||||
})
|
||||
}
|
||||
|
||||
/// Copy member `index` of the canvas at `pos` alone: pasted, it is a picture or shape of its own.
|
||||
fn copy_member(s: &mut Session, pos: &Pos, index: usize) -> CmdResult {
|
||||
let end = Pos { off: pos.off + wordcraft_doc::para::OBJ.len_utf8(), ..pos.clone() };
|
||||
let mut f = s.doc.copy_range(pos, &end);
|
||||
let mut copied = false;
|
||||
for b in &mut f.blocks {
|
||||
let wordcraft_doc::Block::Para(p) = b else { continue };
|
||||
for o in &mut p.objects {
|
||||
if let InlineObject::Group { children, canvas: Some(_), .. } = o
|
||||
&& let Some(c) = children.get(index)
|
||||
{
|
||||
*o = c.obj.clone();
|
||||
copied = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
if !copied {
|
||||
return Err(CmdError::Failed("the member vanished".into()));
|
||||
}
|
||||
s.clipboard_text = f.clipboard_text();
|
||||
s.clip_history.push(&f, &s.clipboard_text);
|
||||
s.clipboard = Some(f);
|
||||
Ok(super::edit::copy_result(s))
|
||||
}
|
||||
|
||||
/// Take member `index` out of the canvas at `pos`; the canvas stays selected.
|
||||
fn remove_member(s: &mut Session, pos: &Pos, index: usize) -> Result<(), CmdError> {
|
||||
edit_canvas(s, pos, |_, _, children| {
|
||||
if index < children.len() {
|
||||
children.remove(index);
|
||||
}
|
||||
})?;
|
||||
s.member = None;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Move or Resize with a member picked: `x`/`y` (the page point of its top-left, as dragging its
|
||||
/// frame gives it) and the size become its place and size in the canvas. `None`: no member.
|
||||
pub(crate) fn member_bounds(s: &mut Session, v: &Value) -> Option<CmdResult> {
|
||||
let (pos, index, obj) = selected_member(s)?;
|
||||
let (w0, h0, _) = obj.frame()?;
|
||||
let min = super::objects::min_size(obj);
|
||||
let to = match (p::f32(v, "x"), p::f32(v, "y")) {
|
||||
(Some(x), Some(y)) => Some((x, y)),
|
||||
(None, None) => None,
|
||||
_ => return Some(Err(CmdError::Params("`x` and `y` go together".into()))),
|
||||
};
|
||||
// `x`/`y` are on the canvas's page: a drop on another page would put the member outside it.
|
||||
if to.is_some()
|
||||
&& let Some(page) = p::u64(v, "page").and_then(|n| usize::try_from(n).ok())
|
||||
&& s.layout().object(&pos, s.page_hint).is_some_and(|o| o.page != page)
|
||||
{
|
||||
return Some(Err(CmdError::Disabled("a shape can't leave its drawing canvas".into())));
|
||||
}
|
||||
let (nw, nh) = (p::f32(v, "width"), p::f32(v, "height"));
|
||||
Some(edit_member(s, &pos, index, |frame, (sx, sy), c| {
|
||||
if let Some((x, y)) = to {
|
||||
c.x = ((x - frame.x) / sx).clamp(-MAX_SIDE, MAX_SIDE);
|
||||
c.y = ((y - frame.y) / sy).clamp(-MAX_SIDE, MAX_SIDE);
|
||||
}
|
||||
let w = nw.map_or(w0, |w| w / sx).clamp(min, MAX_SIDE);
|
||||
let h = nh.map_or(h0, |h| h / sy).clamp(min, MAX_SIDE);
|
||||
if (w, h) != (w0, h0) {
|
||||
c.obj.set_size(w, h);
|
||||
}
|
||||
}))
|
||||
}
|
||||
|
||||
/// Nudge with a member picked: it moves inside the canvas. `None`: no member.
|
||||
pub(crate) fn member_nudge(s: &mut Session, dx: f32, dy: f32) -> Option<CmdResult> {
|
||||
let (pos, index, _) = selected_member(s)?;
|
||||
Some(edit_member(s, &pos, index, |_, (sx, sy), c| {
|
||||
c.x = (c.x + dx / sx).clamp(-MAX_SIDE, MAX_SIDE);
|
||||
c.y = (c.y + dy / sy).clamp(-MAX_SIDE, MAX_SIDE);
|
||||
}))
|
||||
}
|
||||
|
||||
/// Change member `index` of the canvas at `pos`: `f` gets the canvas's frame on the page, the
|
||||
/// scale of its members' space and the member.
|
||||
fn edit_member(s: &mut Session, pos: &Pos, index: usize, f: impl FnOnce(wordcraft_geom::Rect, (f32, f32), &mut GroupChild)) -> CmdResult {
|
||||
let frame = s.layout().object(pos, s.page_hint).ok_or_else(|| CmdError::Failed("the drawing canvas isn't laid out".into()))?.rect;
|
||||
let canvas =
|
||||
s.doc.para_at(pos).and_then(|p| p.object_at(pos.off)).cloned().ok_or_else(|| CmdError::Failed("the drawing canvas vanished".into()))?;
|
||||
let scale = canvas.group_scale(frame.w, frame.h);
|
||||
let mut f = Some(f);
|
||||
let canvas = edit_canvas(s, pos, |_, _, children| {
|
||||
if let (Some(c), Some(f)) = (children.get_mut(index), f.take()) {
|
||||
f(frame, scale, c);
|
||||
}
|
||||
})?;
|
||||
let InlineObject::Group { children, .. } = &canvas else { return result(&canvas) };
|
||||
let member = children.get(index).map(|c| serde_json::to_value(&c.obj).unwrap_or(Value::Null)).unwrap_or(Value::Null);
|
||||
Ok(json!({"object": member, "pos": super::pos_json(pos), "member": index}))
|
||||
}
|
||||
|
||||
/// Change the picked member's own object (Shape Format, Size, Rotate, Alt Text) when `pos` is its
|
||||
/// canvas. `None`: no member picked there.
|
||||
pub(crate) fn edit_member_object(s: &mut Session, pos: &Pos, f: impl Fn(&mut InlineObject)) -> Option<Result<InlineObject, CmdError>> {
|
||||
let (at, index, _) = selected_member(s).filter(|(at, ..)| at == pos)?;
|
||||
let mut out = None;
|
||||
let r = edit_canvas(s, &at, |_, _, children| {
|
||||
if let Some(c) = children.get_mut(index) {
|
||||
f(&mut c.obj);
|
||||
out = Some(c.obj.clone());
|
||||
}
|
||||
});
|
||||
Some(r.and_then(|_| out.ok_or_else(|| CmdError::Failed("the member vanished".into()))))
|
||||
}
|
||||
|
||||
/// Shape Fill and Shape Outline on a selected canvas colour its background and border.
|
||||
pub fn style_mut(o: &mut InlineObject) -> Option<&mut CanvasStyle> {
|
||||
match o {
|
||||
@@ -327,4 +520,131 @@ mod tests {
|
||||
fn canvas_in_text(s: &Session) -> InlineObject {
|
||||
s.doc.body.first().and_then(|b| b.as_para()).and_then(|p| p.objects.first()).cloned().unwrap()
|
||||
}
|
||||
|
||||
fn member_fill(o: &InlineObject, i: usize) -> Option<wordcraft_doc::fill::Fill> {
|
||||
let InlineObject::Group { children, .. } = o else { panic!("{o:?}") };
|
||||
match &children[i].obj {
|
||||
InlineObject::Shape { fill, .. } => fill.clone(),
|
||||
o => panic!("{o:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
/// #2637: a shape inside a drawing canvas can be picked on its own, as in Word. Shape Format,
|
||||
/// Move or Resize, Nudge, Delete and Copy then act on it rather than on the whole canvas,
|
||||
/// which stays the selection in the text; the commands that place an object in the text
|
||||
/// (wrapping, position) don't apply to it.
|
||||
#[test]
|
||||
fn a_canvas_member_is_selected_and_changed_on_its_own() {
|
||||
let mut s = Session::new(wordcraft_doc::Document::new());
|
||||
s.run("insert.canvas", &json!({"width": 400, "height": 300})).unwrap();
|
||||
s.run("insert.shape", &json!({"kind": "rectangle", "width": 100, "height": 40})).unwrap();
|
||||
s.run("insert.shape", &json!({"kind": "ellipse", "width": 50, "height": 50})).unwrap();
|
||||
s.run("canvas.member", &json!({"index": 0, "x": 40, "y": 30})).unwrap();
|
||||
s.run("canvas.member", &json!({"index": 1, "x": 200, "y": 100})).unwrap();
|
||||
let canvas_sel = s.sel.clone();
|
||||
assert!(s.run("select.member", &json!({"index": 5})).is_err(), "no such member");
|
||||
s.run("select.member", &json!({"index": 1})).unwrap();
|
||||
assert_eq!(selected_member(&s).map(|(_, i, _)| i), Some(1));
|
||||
assert_eq!(s.sel, canvas_sel, "the canvas stays the selection in the text");
|
||||
|
||||
// Shape Fill colours the member, not the canvas or the other member.
|
||||
let other = member_fill(&canvas(&s), 0);
|
||||
s.run("shape.fill", &json!({"color": "FF0000"})).unwrap();
|
||||
let c = canvas(&s);
|
||||
assert_eq!(member_fill(&c, 1), wordcraft_doc::props::Rgb::parse("FF0000").map(wordcraft_doc::fill::Fill::solid));
|
||||
assert_eq!(member_fill(&c, 0), other);
|
||||
assert_eq!(c.canvas_style().and_then(|st| st.fill.clone()), None);
|
||||
assert!(selected_member(&s).is_some(), "still picked after a change");
|
||||
|
||||
// Dragging its frame: the page point becomes its place in the canvas.
|
||||
let frame = s.layout().object(&selected_canvas(&s).unwrap().0, 0).unwrap().rect;
|
||||
s.run("arrange.bounds", &json!({"page": 0, "x": frame.x + 10.0, "y": frame.y + 20.0, "width": 60, "height": 30})).unwrap();
|
||||
assert_eq!(members(&canvas(&s))[1], (10.0, 20.0, 60.0, 30.0));
|
||||
assert_eq!(canvas(&s).frame().map(|(w, h, _)| (w, h)), Some((400.0, 300.0)), "the canvas keeps its frame");
|
||||
s.run("arrange.nudge", &json!({"dx": 6, "dy": -6})).unwrap();
|
||||
assert_eq!(members(&canvas(&s))[1], (16.0, 14.0, 60.0, 30.0));
|
||||
assert!(s.run("arrange.wrap", &json!({"wrap": "square"})).is_err(), "the canvas places its members");
|
||||
|
||||
// Copy puts the member alone on the clipboard; Delete takes it out of the canvas.
|
||||
s.run("edit.copy", &json!({})).unwrap();
|
||||
let clip = s.clipboard.clone().unwrap();
|
||||
let copied: Vec<&InlineObject> = clip.blocks.iter().filter_map(|b| b.as_para()).flat_map(|p| p.objects.iter()).collect();
|
||||
assert!(matches!(copied.as_slice(), [InlineObject::Shape { kind: wordcraft_doc::para::ShapeKind::Ellipse, .. }]), "{copied:?}");
|
||||
s.run("text.delete", &json!({})).unwrap();
|
||||
assert_eq!(members(&canvas(&s)), [(40.0, 30.0, 100.0, 40.0)]);
|
||||
assert!(selected_member(&s).is_none());
|
||||
assert_eq!(s.sel, canvas_sel, "the canvas is still selected");
|
||||
// Undo puts the member back.
|
||||
s.run("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(members(&canvas_in_text(&s)).len(), 2);
|
||||
|
||||
// Picking the canvas again (or anything else) ends the member's selection.
|
||||
s.run("select.objects", &json!({"index": 0})).unwrap();
|
||||
s.run("select.member", &json!({"index": 0})).unwrap();
|
||||
s.run("select.member", &json!({"index": null})).unwrap();
|
||||
assert!(selected_member(&s).is_none());
|
||||
s.run("select.member", &json!({"index": 0})).unwrap();
|
||||
s.run("caret.docEnd", &json!({})).unwrap();
|
||||
assert!(selected_member(&s).is_none());
|
||||
// Pasted outside the canvas, the copied member is a shape of its own in the text.
|
||||
s.run("edit.paste", &json!({})).unwrap();
|
||||
let para = s.doc.body.first().and_then(|b| b.as_para()).unwrap();
|
||||
assert!(matches!(para.objects.last(), Some(InlineObject::Shape { kind: wordcraft_doc::para::ShapeKind::Ellipse, .. })), "{:?}", para.objects);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn typing_or_removing_a_member_never_hits_another_shape_or_the_canvas() {
|
||||
let mut s = Session::new(wordcraft_doc::Document::new());
|
||||
s.run("insert.canvas", &json!({"width": 400, "height": 300})).unwrap();
|
||||
s.run("insert.shape", &json!({"kind": "rectangle", "width": 100, "height": 40})).unwrap();
|
||||
s.run("insert.shape", &json!({"kind": "ellipse", "width": 50, "height": 50})).unwrap();
|
||||
s.run("select.member", &json!({"index": 1})).unwrap();
|
||||
|
||||
// Typing or pasting would replace the whole canvas: refused, and the member stays picked.
|
||||
for (id, v) in [
|
||||
("text.insert", json!({"text": "x"})),
|
||||
("text.newParagraph", json!({})),
|
||||
("edit.paste", json!({"text": "x"})),
|
||||
("edit.pasteText", json!({"text": "x"})),
|
||||
] {
|
||||
assert!(matches!(s.run(id, &v), Err(CmdError::Disabled(_))), "{id}");
|
||||
assert_eq!(members(&canvas(&s)).len(), 2, "{id}");
|
||||
}
|
||||
assert_eq!(selected_member(&s).map(|(_, i, _)| i), Some(1));
|
||||
// Ctrl+Backspace removes the member alone, like Backspace.
|
||||
s.run("text.deleteWordBack", &json!({})).unwrap();
|
||||
let sizes: Vec<(f32, f32)> = members(&canvas(&s)).iter().map(|m| (m.2, m.3)).collect();
|
||||
assert_eq!(sizes, [(100.0, 40.0)], "the ellipse went, the rectangle stays");
|
||||
assert!(selected_member(&s).is_none());
|
||||
|
||||
// Removing an earlier member through `canvas.member` drops the pick: its index would now
|
||||
// name the next member.
|
||||
s.run("insert.shape", &json!({"kind": "ellipse", "width": 50, "height": 50})).unwrap();
|
||||
s.run("insert.shape", &json!({"kind": "rectangle", "width": 20, "height": 20})).unwrap();
|
||||
s.run("select.member", &json!({"index": 1})).unwrap();
|
||||
s.run("canvas.member", &json!({"index": 0, "delete": true})).unwrap();
|
||||
assert!(selected_member(&s).is_none(), "the pick doesn't slide onto the member that moved up");
|
||||
|
||||
// Commands that replace the selection (the whole canvas) are refused too.
|
||||
s.run("select.member", &json!({"index": 0})).unwrap();
|
||||
for (id, v) in [
|
||||
("insert.table", json!({"rows": 1, "cols": 1})),
|
||||
("table.quick", json!({})),
|
||||
("insert.equation", json!({})),
|
||||
("insert.symbol", json!({"char": "§"})),
|
||||
("insert.field", json!({"instr": "AUTHOR"})),
|
||||
("insert.pageBreak", json!({})),
|
||||
] {
|
||||
assert!(s.run(id, &v).is_err(), "{id}");
|
||||
assert_eq!(members(&canvas(&s)).len(), 2, "{id}: the canvas and its members stay");
|
||||
}
|
||||
// Moving the member: on the canvas's page it moves; a drop on another page is refused.
|
||||
let frame = s.layout().object(&selected_canvas(&s).unwrap().0, 0).unwrap();
|
||||
let before = members(&canvas(&s));
|
||||
let other = frame.page + 1;
|
||||
assert!(matches!(s.run("arrange.bounds", &json!({"page": other, "x": 100, "y": 100})), Err(CmdError::Disabled(_))));
|
||||
assert_eq!(members(&canvas(&s)), before);
|
||||
s.run("arrange.bounds", &json!({"page": frame.page, "x": frame.rect.x + 5.0, "y": frame.rect.y + 5.0})).unwrap();
|
||||
assert_ne!(members(&canvas(&s)), before);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -361,7 +361,7 @@ impl ClipHistory {
|
||||
self.items.is_empty()
|
||||
}
|
||||
|
||||
fn push(&mut self, f: &Fragment, text: &str) {
|
||||
pub(crate) fn push(&mut self, f: &Fragment, text: &str) {
|
||||
// Text plus per-paragraph overhead; pictures are references into the document.
|
||||
let paras = text.matches('\n').count().saturating_add(f.blocks.len()).saturating_add(f.parts.len()).saturating_add(1);
|
||||
let bytes = text.len().saturating_mul(2).saturating_add(paras.saturating_mul(256));
|
||||
|
||||
@@ -6,6 +6,9 @@ pub(super) const MAX_OFFSET: f32 = 4000.0;
|
||||
|
||||
/// Resize and/or move the selected object (dragging its frame or handles).
|
||||
pub(super) fn bounds(s: &mut Session, v: &Value) -> CmdResult {
|
||||
if let Some(r) = crate::cmd::canvas::member_bounds(s, v) {
|
||||
return r;
|
||||
}
|
||||
let (pos, obj) = selected(s).ok_or_else(|| CmdError::Disabled("no picture or shape selected".into()))?;
|
||||
let to = match (p::f32(v, "x"), p::f32(v, "y")) {
|
||||
(Some(x), Some(y)) => Some((x, y)),
|
||||
@@ -58,7 +61,7 @@ fn landing(s: &Session, pos: &Pos, to: &Pos) -> Result<Pos, CmdError> {
|
||||
|
||||
/// Move the In Line with Text object at `pos` to the text position `to`, where it stays In Line
|
||||
/// with Text, keeping its character formatting: Word's drag of an inline picture, which lands
|
||||
/// where its dashed insertion cursor was (#2233, #2234). Dropped on itself, nothing changes.
|
||||
/// where its insertion cursor was (#2233, #2234). Dropped on itself, nothing changes.
|
||||
/// Returns the object's position afterwards.
|
||||
fn move_in_text(s: &mut Session, pos: Pos, to: &Pos) -> Result<Pos, CmdError> {
|
||||
let to = landing(s, &pos, to)?;
|
||||
@@ -161,8 +164,9 @@ fn move_object_once(s: &mut Session, pos: &Pos, page: usize, x: f32, y: f32, (w,
|
||||
Some(path) => (Pos { story, path, off: 0 }, false),
|
||||
None => (nearest_line(&layout, page, y, story).ok_or_else(|| CmdError::Params("no text on that page to anchor to".into()))?, true),
|
||||
};
|
||||
// Move its character to the anchor (unless it's already in that paragraph).
|
||||
let at = if anchor.story == pos.story && anchor.path == pos.path && !on_page {
|
||||
// Move its character to the anchor, unless it's already in that paragraph and drawn on this
|
||||
// page (one anchored on a later page of it is drawn there, #4250).
|
||||
let at = if anchor.story == pos.story && anchor.path == pos.path && !on_page && layout.object(pos, page).is_some_and(|o| o.page == page) {
|
||||
pos.clone()
|
||||
} else {
|
||||
let obj = s.doc.para_at(pos).and_then(|p| p.object_at(pos.off)).cloned().ok_or_else(|| CmdError::Failed("object vanished".into()))?;
|
||||
|
||||
@@ -12,6 +12,10 @@ pub(super) fn has_object(s: &Session) -> Option<&'static str> {
|
||||
}
|
||||
|
||||
pub(super) fn has_floating(s: &Session) -> Option<&'static str> {
|
||||
// A canvas member moves inside its canvas.
|
||||
if crate::cmd::canvas::selected_member(s).is_some() {
|
||||
return None;
|
||||
}
|
||||
match selected(s) {
|
||||
Some((_, InlineObject::Graphic { .. })) => Some(FROZEN),
|
||||
Some((_, o)) if o.is_floating() => None,
|
||||
@@ -51,6 +55,15 @@ pub(super) fn has_movable(s: &Session) -> Option<&'static str> {
|
||||
}
|
||||
}
|
||||
|
||||
/// `has_movable` for what places an object in the text (wrapping, position, alignment, Move with
|
||||
/// Text): not for a member picked inside a drawing canvas, which the canvas places.
|
||||
pub(super) fn has_placeable(s: &Session) -> Option<&'static str> {
|
||||
if crate::cmd::canvas::selected_member(s).is_some() {
|
||||
return Some("a shape inside a drawing canvas is placed by the canvas");
|
||||
}
|
||||
has_movable(s)
|
||||
}
|
||||
|
||||
/// Word's own ink in a group is kept in the form a group member takes; it can't stand on its own
|
||||
/// yet, so such a group stays whole.
|
||||
pub(super) const INK_GROUP: &str = "a group holding Word ink can't be ungrouped yet";
|
||||
@@ -99,7 +112,9 @@ pub fn object_selection(s: &Session) -> Option<(Pos, &InlineObject)> {
|
||||
o.is_drawing().then_some((a, o))
|
||||
}
|
||||
|
||||
/// The first picture/shape in the selection, or just before a collapsed caret.
|
||||
/// The first picture/shape in the selection, or just before a collapsed caret. With a canvas
|
||||
/// member picked ([`crate::cmd::canvas::selected_member`]), that member, at its canvas's position:
|
||||
/// the commands that change it go through [`edit_obj`], which changes the member.
|
||||
pub fn selected(s: &Session) -> Option<(Pos, InlineObject)> {
|
||||
selected_all(s, 1).into_iter().next()
|
||||
}
|
||||
@@ -107,6 +122,9 @@ pub fn selected(s: &Session) -> Option<(Pos, InlineObject)> {
|
||||
/// The pictures and shapes in the selection (at most `max`), or the one just before a collapsed
|
||||
/// caret.
|
||||
pub fn selected_all(s: &Session, max: usize) -> Vec<(Pos, InlineObject)> {
|
||||
if let Some((pos, _, member)) = crate::cmd::canvas::selected_member(s) {
|
||||
return vec![(pos, member.clone())];
|
||||
}
|
||||
let mut out = Vec::new();
|
||||
let (a, b) = s.sel.ordered();
|
||||
let max = if a == b { 1 } else { max };
|
||||
@@ -175,8 +193,12 @@ pub(super) fn with_obj(s: &mut Session, f: impl Fn(&mut InlineObject)) -> CmdRes
|
||||
Ok(json!({"object": serde_json::to_value(o).unwrap_or(Value::Null)}))
|
||||
}
|
||||
|
||||
/// Change the object at `pos`; returns it afterwards.
|
||||
/// Change the object at `pos`; returns it afterwards. With a member of the canvas at `pos`
|
||||
/// picked, the member changes.
|
||||
pub(crate) fn edit_obj(s: &mut Session, pos: &Pos, f: impl Fn(&mut InlineObject)) -> Result<InlineObject, CmdError> {
|
||||
if let Some(r) = crate::cmd::canvas::edit_member_object(s, pos, &f) {
|
||||
return r;
|
||||
}
|
||||
let para = s.doc.para_mut(pos.story, &pos.path)?;
|
||||
let o = para.object_at_mut(pos.off).ok_or_else(|| CmdError::Failed("object vanished".into()))?;
|
||||
f(o);
|
||||
|
||||
@@ -2,8 +2,8 @@ use super::arrange::{MAX_OFFSET, bounds, copy_to, move_with_text, unalign};
|
||||
use super::group::{add_object, group, ungroup};
|
||||
use super::picture::{adjust, adjust_media, clear_lum, crop, original_media, recolor, relook, remove_background, reset, size};
|
||||
use super::select::{
|
||||
all_objects, alt_text, can_group, edit_obj, has_floating, has_group, has_movable, has_object, has_picture, has_shape, has_shape_or_canvas,
|
||||
has_sizable, obj_size, objects_list, selected, with_float, with_obj,
|
||||
all_objects, alt_text, can_group, edit_obj, has_floating, has_group, has_movable, has_object, has_picture, has_placeable, has_shape,
|
||||
has_shape_or_canvas, has_sizable, obj_size, objects_list, selected, with_float, with_obj,
|
||||
};
|
||||
use super::shape::{NO_TEXT_BOX, can_link, has_text_box, link_text_box, set_text_body, shape_effects, text_box_target};
|
||||
use super::{CmdError, CommandSpec, InlineObject, Pos, Rgb, Selection, Spin, TextVert, VAlign, Wrap, WrapText, json, p, sel_result};
|
||||
@@ -193,17 +193,17 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
with_float(s, |f| f.wrap = wrap)
|
||||
})
|
||||
.params(r#"{"wrap": "inline|square|tight|through|topAndBottom|behindText|inFrontOfText"}"#)
|
||||
.when(has_movable),
|
||||
.when(has_placeable),
|
||||
CommandSpec::new("arrange.wrapText", "Wrap Text Sides", "Layout › Arrange", |s, v| {
|
||||
let side = p::str(v, "side").unwrap_or("bothSides");
|
||||
let side = WrapText::from_ooxml(side).ok_or_else(|| CmdError::Params(format!("unknown side {side:?}")))?;
|
||||
with_float(s, |f| f.wrap_text = side)
|
||||
})
|
||||
.params(r#"{"side": "bothSides|left|right|largest"} (the sides of the selected Square, Tight or Through object text flows on; largest: the wider side, line by line)"#)
|
||||
.when(has_movable),
|
||||
.when(has_placeable),
|
||||
CommandSpec::new("arrange.moveWithText", "Move with Text", "Layout › Arrange › Wrap Text", |s, v| move_with_text(s, p::bool(v, "value").unwrap_or(true)))
|
||||
.params(r#"{"value": bool} (true: Move with Text, the floating object's vertical position follows its paragraph; false: Fix Position on Page. It stays where it is)"#)
|
||||
.when(has_floating),
|
||||
.when(|s| has_placeable(s).or_else(|| has_floating(s))),
|
||||
CommandSpec::new("arrange.position", "Position", "Layout › Arrange", |s, v| {
|
||||
let preset = p::str(v, "preset").unwrap_or("middleCenter").to_string();
|
||||
let (x, y) = (p::f32(v, "x"), p::f32(v, "y"));
|
||||
@@ -224,9 +224,9 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
})
|
||||
})
|
||||
.params(r#"{"preset"?: "topLeft|topCenter|topRight|middleLeft|middleCenter|middleRight|bottomLeft|bottomCenter|bottomRight", "x"?: pt, "y"?: pt}"#)
|
||||
.when(has_movable),
|
||||
CommandSpec::new("arrange.bringForward", "Bring Forward", "Layout › Arrange", |s, _| with_float(s, |f| f.wrap = Wrap::InFrontOfText)).when(has_movable),
|
||||
CommandSpec::new("arrange.sendBackward", "Send Backward", "Layout › Arrange", |s, _| with_float(s, |f| f.wrap = Wrap::BehindText)).when(has_movable),
|
||||
.when(has_placeable),
|
||||
CommandSpec::new("arrange.bringForward", "Bring Forward", "Layout › Arrange", |s, _| with_float(s, |f| f.wrap = Wrap::InFrontOfText)).when(has_placeable),
|
||||
CommandSpec::new("arrange.sendBackward", "Send Backward", "Layout › Arrange", |s, _| with_float(s, |f| f.wrap = Wrap::BehindText)).when(has_placeable),
|
||||
CommandSpec::new("arrange.align", "Align", "Layout › Arrange", |s, v| {
|
||||
let h = p::str(v, "value").unwrap_or("center").to_string();
|
||||
let tw = crate::cmd::page::sect(s).text_width();
|
||||
@@ -245,17 +245,20 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
})
|
||||
})
|
||||
.params(r#"{"value": "left|center|right"}"#)
|
||||
.when(has_movable),
|
||||
.when(has_placeable),
|
||||
CommandSpec::new("arrange.selectionPane", "Selection Pane", "Layout › Arrange", objects_list).pure(),
|
||||
CommandSpec::new("arrange.bounds", "Move or Resize", "Layout › Arrange", bounds)
|
||||
.params(r#"{"width"?: pt, "height"?: pt, "x"?: pt, "y"?: pt, "page"?: n, "to"?: Pos} (x/y: the top-left on page `page` (0-based, default its page); moving an inline object by x/y floats it. to: an In Line with Text object moves to that text position and stays inline, as a drag does)"#)
|
||||
.when(has_movable),
|
||||
CommandSpec::new("arrange.copyTo", "Copy to", "Layout › Arrange", copy_to)
|
||||
.params(r#"{"x": pt, "y": pt, "page"?: n} | {"to": Pos} (Ctrl+drag: a copy of the selected object lands with its top-left there, or an In Line with Text one at the text position `to`, and is selected; the original and the clipboard stay as they were)"#)
|
||||
.when(has_movable),
|
||||
.when(has_placeable),
|
||||
CommandSpec::new("arrange.nudge", "Nudge", "Layout › Arrange", |s, v| {
|
||||
let d = |k| p::f32(v, k).unwrap_or(0.0).clamp(-MAX_OFFSET, MAX_OFFSET);
|
||||
let (dx, dy) = (d("dx"), d("dy"));
|
||||
if let Some(r) = crate::cmd::canvas::member_nudge(s, dx, dy) {
|
||||
return r;
|
||||
}
|
||||
unalign(s)?;
|
||||
with_float(s, |f| {
|
||||
f.x = (f.x + dx).clamp(-MAX_OFFSET, MAX_OFFSET);
|
||||
|
||||
@@ -14,6 +14,69 @@ fn object_area(s: &mut Session) -> wordcraft_layout::hit::ObjectHit {
|
||||
s.layout().find_object(0, |o| o.text_box.is_none()).unwrap()
|
||||
}
|
||||
|
||||
/// #4250: a shape dragged into the middle of a page that only continues a long paragraph
|
||||
/// stays there, as in Word: anchored at the line beside it, it's drawn on that page, not
|
||||
/// thrown back to the page the paragraph starts on. One undo step puts it back.
|
||||
#[test]
|
||||
fn a_shape_dropped_on_a_page_a_paragraph_continues_onto_stays_there() {
|
||||
let mut s = Session::new(wordcraft_doc::Document::new());
|
||||
s.run("text.insert", &json!({"text": "The quick brown fox jumps over the lazy dog. ".repeat(400)})).unwrap();
|
||||
s.run("caret.docStart", &json!({})).unwrap();
|
||||
s.run("insert.shape", &json!({"kind": "rectangle"})).unwrap();
|
||||
s.run("arrange.wrap", &json!({"wrap": "square"})).unwrap();
|
||||
s.run("arrange.bounds", &json!({"page": 0, "x": 100, "y": 250, "width": 100, "height": 50})).unwrap();
|
||||
assert!(s.layout().pages.len() > 2);
|
||||
let before = (object_area(&mut s), s.undo_depth());
|
||||
s.run("arrange.bounds", &json!({"page": 1, "x": 100, "y": 275})).unwrap();
|
||||
let o = object_area(&mut s);
|
||||
assert_eq!(o.page, 1, "on the page it was dropped on: {o:?}");
|
||||
assert!((o.rect.x - 100.0).abs() < 0.5 && (o.rect.y - 275.0).abs() < 0.5, "where it was dropped: {:?}", o.rect);
|
||||
assert_eq!(s.undo_depth(), before.1 + 1);
|
||||
s.run("edit.undo", &json!({})).unwrap();
|
||||
let back = object_area(&mut s);
|
||||
assert_eq!((back.page, back.rect), (before.0.page, before.0.rect));
|
||||
}
|
||||
|
||||
/// #4250: and dragged back onto the page the paragraph starts on, it stays there: its anchor
|
||||
/// goes back to the paragraph's start rather than staying on the later page's line.
|
||||
#[test]
|
||||
fn a_shape_dragged_back_to_the_page_its_paragraph_starts_on_stays_there() {
|
||||
let mut s = Session::new(wordcraft_doc::Document::new());
|
||||
s.run("text.insert", &json!({"text": "The quick brown fox jumps over the lazy dog. ".repeat(400)})).unwrap();
|
||||
s.run("caret.docStart", &json!({})).unwrap();
|
||||
s.run("insert.shape", &json!({"kind": "rectangle"})).unwrap();
|
||||
s.run("arrange.wrap", &json!({"wrap": "square"})).unwrap();
|
||||
s.run("arrange.bounds", &json!({"page": 1, "x": 100, "y": 275, "width": 100, "height": 50})).unwrap();
|
||||
let before = (object_area(&mut s), s.undo_depth());
|
||||
assert!(before.0.page == 1 && before.0.off > 0, "anchored on page 2's line: {:?}", before.0);
|
||||
s.run("arrange.bounds", &json!({"page": 0, "x": 120, "y": 300})).unwrap();
|
||||
let o = object_area(&mut s);
|
||||
assert_eq!((o.page, o.off), (0, 0), "on the page it was dropped on, anchored at the paragraph's start: {o:?}");
|
||||
assert!((o.rect.x - 120.0).abs() < 0.5 && (o.rect.y - 300.0).abs() < 0.5, "where it was dropped: {:?}", o.rect);
|
||||
assert_eq!(s.undo_depth(), before.1 + 1);
|
||||
}
|
||||
|
||||
/// #4262: dropped beside the first line after a page break inside a paragraph, a shape stays
|
||||
/// on the page it was dropped on, anchored in that line.
|
||||
#[test]
|
||||
fn a_shape_dropped_just_after_a_page_break_in_a_paragraph_stays_there() {
|
||||
let mut s = Session::new(wordcraft_doc::Document::new());
|
||||
let text = "The quick brown fox jumps over the lazy dog. ";
|
||||
s.run("text.insert", &json!({"text": text.repeat(20)})).unwrap();
|
||||
s.run("insert.pageBreak", &json!({})).unwrap();
|
||||
s.run("text.insert", &json!({"text": text.repeat(40)})).unwrap();
|
||||
assert_eq!(s.doc.body.len(), 1, "one paragraph");
|
||||
s.run("caret.docStart", &json!({})).unwrap();
|
||||
s.run("insert.shape", &json!({"kind": "rectangle"})).unwrap();
|
||||
s.run("arrange.wrap", &json!({"wrap": "square"})).unwrap();
|
||||
s.run("arrange.bounds", &json!({"page": 0, "x": 100, "y": 250, "width": 100, "height": 50})).unwrap();
|
||||
s.run("arrange.bounds", &json!({"page": 1, "x": 100, "y": 80})).unwrap();
|
||||
let o = object_area(&mut s);
|
||||
let after_break = text.len() * 20 + wordcraft_doc::para::PAGE_BREAK.len_utf8();
|
||||
assert_eq!((o.page, o.off), (1, after_break), "on the page it was dropped on, anchored right after the break: {o:?}");
|
||||
assert!((o.rect.x - 100.0).abs() < 0.5 && (o.rect.y - 80.0).abs() < 0.5, "where it was dropped: {:?}", o.rect);
|
||||
}
|
||||
|
||||
/// What a click and a handle or body drag do in the editor (#142, #82): hit-test the object,
|
||||
/// select it, then resize and move it with `arrange.bounds`.
|
||||
fn click_resize_and_drag(insert: &str, params: Value) {
|
||||
|
||||
@@ -652,19 +652,33 @@ pub(crate) fn typing_pos(s: &Session, p: &Pos) -> Pos {
|
||||
if para.objects.is_empty() {
|
||||
return p.clone();
|
||||
}
|
||||
let editable: Vec<u32> = s.doc.perm_ranges().iter().filter(|r| editable_by(r, &s.author)).map(|r| r.id).collect();
|
||||
let is_start = |off: usize| matches!(para.object_at(off), Some(InlineObject::PermStart { id, .. }) if editable.contains(id));
|
||||
let is_end = |off: usize| matches!(para.object_at(off), Some(InlineObject::PermEnd { id }) if editable.contains(id));
|
||||
let Some(before) = para.text.get(..p.off) else { return p.clone() };
|
||||
let Some(after) = para.text.get(p.off..) else { return p.clone() };
|
||||
if !before.ends_with(OBJ) && !after.starts_with(OBJ) {
|
||||
return p.clone();
|
||||
}
|
||||
let editable: HashSet<u32> = s.doc.perm_ranges().iter().filter(|r| editable_by(r, &s.author)).map(|r| r.id).collect();
|
||||
// Count the preceding objects once. Each adjacent marker can then use the matching
|
||||
// object directly, forwards for starts and backwards for ends, even with short storage.
|
||||
let index = before.chars().filter(|c| *c == OBJ).count();
|
||||
let w = OBJ.len_utf8();
|
||||
let mut off = p.off;
|
||||
if is_start(off) {
|
||||
while is_start(off) {
|
||||
off += w;
|
||||
for (ch, object) in after.chars().zip(para.objects.iter().skip(index)) {
|
||||
if ch != OBJ || !matches!(object, InlineObject::PermStart { id, .. } if editable.contains(id)) {
|
||||
break;
|
||||
}
|
||||
off += w;
|
||||
}
|
||||
if off > p.off {
|
||||
return Pos { off, ..p.clone() };
|
||||
}
|
||||
while off >= w && is_end(off - w) {
|
||||
off -= w;
|
||||
if let Some(objects) = para.objects.get(..index) {
|
||||
for (ch, object) in before.chars().rev().zip(objects.iter().rev()) {
|
||||
if ch != OBJ || !matches!(object, InlineObject::PermEnd { id } if editable.contains(id)) {
|
||||
break;
|
||||
}
|
||||
off -= w;
|
||||
}
|
||||
}
|
||||
Pos { off, ..p.clone() }
|
||||
}
|
||||
|
||||
@@ -922,3 +922,159 @@ fn legacy_password_is_verified_and_unknown_forms_refused() {
|
||||
assert!(e.to_string().contains(super::UNSUPPORTED_PASSWORD));
|
||||
assert!(super::enforced(&s.doc));
|
||||
}
|
||||
|
||||
/// Consecutive permission boundaries must not recount the paragraph prefix or search all
|
||||
/// editable IDs once per marker. Both ends are used by typing and Enter.
|
||||
#[test]
|
||||
fn typing_boundary_many_permission_markers_is_not_quadratic() {
|
||||
use wordcraft_doc::para::Run;
|
||||
let cpu = || std::fs::read_to_string("/proc/thread-self/schedstat").ok()?.split_whitespace().next()?.parse::<u64>().ok();
|
||||
let measure = |n: usize, at_end: bool| {
|
||||
let mut p = wordcraft_doc::Paragraph::new();
|
||||
p.text.push_str("λ prefix ");
|
||||
let start = p.text.len();
|
||||
for i in 0..n {
|
||||
p.text.push(OBJ);
|
||||
p.objects.push(InlineObject::PermStart { id: i as u32, group: "everyone".into(), editor: String::new() });
|
||||
}
|
||||
p.text.push_str("body");
|
||||
let end = p.text.len();
|
||||
for i in 0..n {
|
||||
p.text.push(OBJ);
|
||||
p.objects.push(InlineObject::PermEnd { id: i as u32 });
|
||||
}
|
||||
let after = p.text.len();
|
||||
p.runs.push(Run { len: p.text.len(), props: CharProps::default() });
|
||||
let mut doc = Document::new();
|
||||
doc.body = vec![Block::Para(p).into()];
|
||||
doc.settings.protection = Some("readOnly".into());
|
||||
let s = Session::new(doc);
|
||||
let at = Pos::body(0, if at_end { after } else { start });
|
||||
let expected = Pos::body(0, if at_end { end } else { start + n * OBJ.len_utf8() });
|
||||
let before = cpu();
|
||||
for _ in 0..3 {
|
||||
assert_eq!(typing_pos(std::hint::black_box(&s), &at), expected);
|
||||
}
|
||||
match (before, cpu()) {
|
||||
(Some(a), Some(b)) if b > a => Some((b - a) as f64 * 1e-9),
|
||||
_ => None,
|
||||
}
|
||||
};
|
||||
for at_end in [false, true] {
|
||||
let (small, large) = (measure(2_000, at_end), measure(16_000, at_end));
|
||||
let (Some(a), Some(b)) = (small, large) else {
|
||||
eprintln!("Typing boundary: CPU counter unavailable; semantic checks passed, timing assertion skipped");
|
||||
continue;
|
||||
};
|
||||
eprintln!("Typing boundary end={at_end}: small={a:.9}s large={b:.9}s ratio={:.2}", b / a);
|
||||
assert!(b < a * 24.0 + 0.005, "8x permission markers must not take quadratic boundary work: {a:.9}s -> {b:.9}s");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn typing_boundary_keeps_author_precedence_and_malformed_pairing() {
|
||||
use wordcraft_doc::para::Run;
|
||||
// Frozen boundary semantics from before the one-pass lookup. This oracle deliberately
|
||||
// uses object_at independently of the new traversal, including malformed storage.
|
||||
let original = |s: &Session, p: &Pos| {
|
||||
if !matches!(s.doc.settings.protection.as_deref(), Some("readOnly" | "comments")) {
|
||||
return p.clone();
|
||||
}
|
||||
let Some(para) = s.doc.para_at(p) else { return p.clone() };
|
||||
if para.objects.is_empty() {
|
||||
return p.clone();
|
||||
}
|
||||
let editable: Vec<u32> = s.doc.perm_ranges().iter().filter(|r| editable_by(r, &s.author)).map(|r| r.id).collect();
|
||||
let start = |off| matches!(para.object_at(off), Some(InlineObject::PermStart { id, .. }) if editable.contains(id));
|
||||
let end = |off| matches!(para.object_at(off), Some(InlineObject::PermEnd { id }) if editable.contains(id));
|
||||
let mut off = p.off;
|
||||
if start(off) {
|
||||
while start(off) {
|
||||
off += OBJ.len_utf8();
|
||||
}
|
||||
} else {
|
||||
while off >= OBJ.len_utf8() && end(off - OBJ.len_utf8()) {
|
||||
off -= OBJ.len_utf8();
|
||||
}
|
||||
}
|
||||
Pos { off, ..p.clone() }
|
||||
};
|
||||
let objects = [
|
||||
InlineObject::PermStart { id: 1, group: "everyone".into(), editor: String::new() },
|
||||
InlineObject::PermStart { id: 2, group: String::new(), editor: " ALICE ".into() },
|
||||
InlineObject::PermStart { id: 3, group: String::new(), editor: "alice".into() },
|
||||
InlineObject::PermEnd { id: 1 },
|
||||
InlineObject::PermEnd { id: 3 },
|
||||
InlineObject::BookmarkStart { name: "Original".into() },
|
||||
InlineObject::PermEnd { id: 7 },
|
||||
InlineObject::PermStart { id: 7, group: String::new(), editor: "Bob".into() },
|
||||
];
|
||||
for text in
|
||||
[format!("λ{}x{}終", OBJ.to_string().repeat(4), OBJ.to_string().repeat(4)), OBJ.to_string().repeat(9), format!("λ{OBJ}{OBJ}x{OBJ}{OBJ}{OBJ}")]
|
||||
{
|
||||
for count in 0..=objects.len() {
|
||||
for mode in [None, Some("readOnly"), Some("comments"), Some("forms"), Some("trackedChanges")] {
|
||||
let mut p = wordcraft_doc::Paragraph::new();
|
||||
p.text = text.clone();
|
||||
p.objects = objects[..count].to_vec();
|
||||
p.runs.push(Run { len: p.text.len(), props: CharProps::default() });
|
||||
let mut doc = Document::new();
|
||||
doc.body = vec![Block::Para(p).into()];
|
||||
doc.settings.protection = mode.map(str::to_string);
|
||||
let mut s = Session::new(doc);
|
||||
s.author = " Alice ".into();
|
||||
for off in 0..=text.len() + 1 {
|
||||
let at = Pos::body(0, off);
|
||||
assert_eq!(typing_pos(&s, &at), original(&s, &at), "mode={mode:?} count={count} off={off} text={text:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut s = Session::new(Document::from_text("outside"));
|
||||
s.doc.settings.protection = Some("readOnly".into());
|
||||
assert_eq!(typing_pos(&s, &Pos::body(99, 0)), Pos::body(99, 0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn typing_boundary_keeps_duplicate_ids_across_stories_and_start_precedence() {
|
||||
use wordcraft_doc::para::Run;
|
||||
let paragraph = |text: String, objects| wordcraft_doc::Paragraph {
|
||||
runs: vec![Run { len: text.len(), props: CharProps::default() }],
|
||||
text,
|
||||
objects,
|
||||
..Default::default()
|
||||
};
|
||||
let mut doc = Document::new();
|
||||
doc.body = vec![
|
||||
Block::Para(paragraph(
|
||||
format!("{OBJ}x{OBJ}"),
|
||||
vec![InlineObject::PermStart { id: 1, group: String::new(), editor: "Bob".into() }, InlineObject::PermEnd { id: 1 }],
|
||||
))
|
||||
.into(),
|
||||
];
|
||||
doc.add_part(
|
||||
wordcraft_doc::PartKind::Header,
|
||||
vec![
|
||||
Block::Para(paragraph(
|
||||
format!("{OBJ}y{OBJ}"),
|
||||
vec![InlineObject::PermStart { id: 1, group: "everyone".into(), editor: String::new() }, InlineObject::PermEnd { id: 1 }],
|
||||
))
|
||||
.into(),
|
||||
],
|
||||
);
|
||||
doc.settings.protection = Some("comments".into());
|
||||
let mut s = Session::new(doc);
|
||||
s.author = "Alice".into();
|
||||
// Preserve the existing document-wide ID membership, even for a duplicate in another story.
|
||||
assert_eq!(typing_pos(&s, &Pos::body(0, 0)), Pos::body(0, OBJ.len_utf8()));
|
||||
assert_eq!(typing_pos(&s, &Pos::body(0, 7)), Pos::body(0, 4));
|
||||
s.doc.body = vec![
|
||||
Block::Para(paragraph(
|
||||
format!("{OBJ}{OBJ}x"),
|
||||
vec![InlineObject::PermEnd { id: 1 }, InlineObject::PermStart { id: 1, group: "everyone".into(), editor: String::new() }],
|
||||
))
|
||||
.into(),
|
||||
];
|
||||
// A start to the right wins over an end to the left.
|
||||
assert_eq!(typing_pos(&s, &Pos::body(0, 3)), Pos::body(0, 6));
|
||||
}
|
||||
|
||||
@@ -219,6 +219,10 @@ pub struct Session {
|
||||
/// More pictures and shapes selected along with the one the selection holds (Shift+click),
|
||||
/// for Group. Cleared by any edit or selection change other than adding to it.
|
||||
pub also_selected: Vec<Pos>,
|
||||
/// The member of the selected Drawing Canvas picked on its own (#2637), which Shape Format,
|
||||
/// Delete, Copy and the frame act on; see [`crate::cmd::canvas::selected_member`]. Cleared by
|
||||
/// any selection change, caret movement or undo.
|
||||
pub member: Option<crate::cmd::canvas::CanvasMember>,
|
||||
/// Column (block) selection, if one was made (see [`Session::column_segments`]).
|
||||
pub column: Option<ColumnBlock>,
|
||||
/// Column selection mode (Ctrl+Shift+F8): caret movement extends the block.
|
||||
|
||||
@@ -276,6 +276,8 @@ impl Session {
|
||||
ime_state_exhausted: _,
|
||||
pending_ime_application_change: _,
|
||||
ime_origin: _,
|
||||
// A picked Drawing Canvas member is cleared by a switch, not kept per document.
|
||||
member: _,
|
||||
others: _,
|
||||
order: _,
|
||||
untitled_count: _,
|
||||
@@ -376,6 +378,8 @@ impl Session {
|
||||
swap(&mut self.widened, widened);
|
||||
swap(&mut self.edit_marks, edit_marks);
|
||||
swap(&mut self.go_back_step, go_back_step);
|
||||
// A picked Drawing Canvas member (#2637) belongs to the document left behind.
|
||||
self.member = None;
|
||||
}
|
||||
|
||||
/// Make document `id` active without anything else a switch by the user does.
|
||||
|
||||
@@ -150,6 +150,7 @@ impl Session {
|
||||
math_latex: false,
|
||||
math_normal_text: false,
|
||||
also_selected,
|
||||
member: None,
|
||||
prefs: Prefs::default(),
|
||||
system_region: Default::default(),
|
||||
keymap: crate::KeyMap::default(),
|
||||
|
||||
@@ -185,7 +185,10 @@ impl Session {
|
||||
let mutates = spec.mutates;
|
||||
let ime_caret_before = self.current_ime_caret_state();
|
||||
let rev_before = self.rev;
|
||||
let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| crate::cmd::column::dispatch(self, id, mutates, run, params)));
|
||||
let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| match crate::cmd::canvas::member_command(self, id, params) {
|
||||
Some(r) => r,
|
||||
None => crate::cmd::column::dispatch(self, id, mutates, run, params),
|
||||
}));
|
||||
let mut result = match result {
|
||||
Ok(r) => r,
|
||||
Err(p) => {
|
||||
@@ -391,6 +394,15 @@ impl Session {
|
||||
if !matches!(id, "select.addObject" | "arrange.ungroup" | "shape.fill" | "shape.effects") && (spec.mutates || self.sel != *sel_before) {
|
||||
self.also_selected.clear();
|
||||
}
|
||||
// A canvas member stays picked while its canvas does and nothing else is selected.
|
||||
if id != "select.member"
|
||||
&& (self.sel != *sel_before
|
||||
|| id.starts_with("select.")
|
||||
|| id.starts_with("caret.")
|
||||
|| matches!(id, "edit.undo" | "edit.undoTo" | "edit.redo"))
|
||||
{
|
||||
self.member = None;
|
||||
}
|
||||
self.note_typing(id, before_doc.as_ref().map(|b| &b.0), sel_before);
|
||||
}
|
||||
|
||||
|
||||
@@ -23,6 +23,7 @@ impl Session {
|
||||
self.pending = None;
|
||||
self.typing = None;
|
||||
self.also_selected.clear();
|
||||
self.member = None;
|
||||
self.column = None;
|
||||
self.column_mode = false;
|
||||
self.widened = None;
|
||||
@@ -155,6 +156,7 @@ impl Session {
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
recovery_slot: _,
|
||||
also_selected: _,
|
||||
member: _,
|
||||
// Column selection and a widened selection's ends, like `sel`'s shape: valid only while
|
||||
// `sel` matches them.
|
||||
column: _,
|
||||
@@ -211,6 +213,7 @@ impl Session {
|
||||
self.doc = doc;
|
||||
self.sel = sel;
|
||||
self.also_selected.clear();
|
||||
self.member = None;
|
||||
self.history.truncate(history_len);
|
||||
self.redo = redo;
|
||||
self.typing_open = typing_open;
|
||||
|
||||
@@ -141,6 +141,8 @@ impl Session {
|
||||
self.page_hint = page_hint;
|
||||
self.column_mode = column_mode;
|
||||
self.math = math;
|
||||
// A picked Drawing Canvas member (#2637) is not part of a view's saved selection.
|
||||
self.member = None;
|
||||
if current {
|
||||
self.hf_return = hf_return;
|
||||
self.typing_open = typing_open;
|
||||
|
||||
@@ -332,7 +332,14 @@ pub(crate) fn layout_box(ctx: &mut Ctx, input: BoxInput<'_>) -> BoxLayout {
|
||||
prev_end = push_para(&mut items, (story, &path), &pl, (0, pl.lines.len()), (0.0, y + lead), width, edges);
|
||||
// Pictures, shapes and text boxes: drawn, their areas and text boxes' text.
|
||||
if !p.objects.is_empty() {
|
||||
let at = ObjFrame { page: frame, col: (0.0, width), para_y: para_top, table_chr: table.map(|t| &t.chr), pinned: Some(&pinned) };
|
||||
let at = ObjFrame {
|
||||
page: frame,
|
||||
col: (0.0, width),
|
||||
para_y: para_top,
|
||||
table_chr: table.map(|t| &t.chr),
|
||||
pinned: Some(&pinned),
|
||||
late: None,
|
||||
};
|
||||
let (back, front) = place_objects(
|
||||
ctx,
|
||||
&ObjectPlacement {
|
||||
|
||||
+263
-12
@@ -3,8 +3,8 @@ use super::page::{PageBuilder, PageFrame};
|
||||
use super::placed::Placed;
|
||||
use super::table_flow::footer_table_wraps;
|
||||
use super::{
|
||||
Anchor, Arc, CharProps, Document, Float, FloatAlign, HashMap, HideDeleted, InlineObject, Level, ParaLayout, Paragraph, Path, Point, Rect, Spin,
|
||||
StoryRef, Wrap, WrapText, contour, display, para, textbox,
|
||||
Anchor, Arc, CharProps, Document, Float, FloatAlign, HashMap, HideDeleted, InlineObject, Level, LineEnd, ParaLayout, Paragraph, Path, Point,
|
||||
Rect, Spin, StoryRef, Wrap, WrapText, contour, display, para, textbox,
|
||||
};
|
||||
|
||||
/// Which of `p`'s objects (by index) the layout leaves out ([`para::left_out`]): anchored in hidden
|
||||
@@ -336,14 +336,133 @@ fn group_members(o: &InlineObject, rect: Rect) -> Vec<(Rect, &InlineObject)> {
|
||||
/// page's wrap areas (its area as placed now). Without `place` (a later piece of the paragraph
|
||||
/// broken again for another column) the floats stay where its first piece placed and drew them:
|
||||
/// they aren't placed again in the new column, and their record for the page they are drawn on
|
||||
/// ([`EarlyFloats`]) stands.
|
||||
/// ([`EarlyFloats`]) stands. Floats anchored on a later page ([`late_floats`]), and those in
|
||||
/// `force`, aren't placed here: the paragraph is laid out without them, and they're returned.
|
||||
pub(crate) fn anchor_floats(
|
||||
ctx: &mut Ctx,
|
||||
pb: &mut PageBuilder,
|
||||
anchor: FloatAnchor,
|
||||
force: &[usize],
|
||||
) -> (HashMap<usize, Rect>, Arc<ParaLayout>, Vec<para::Exclusion>, Vec<usize>) {
|
||||
let FloatAnchor { p, block, y0, place, .. } = anchor;
|
||||
ctx.fields.page = pb.number;
|
||||
// Each layout of it here counts its automatic equation numbers from the same place.
|
||||
let eq0 = ctx.eq_count;
|
||||
let (rects, pl, rel) = anchor_floats_but(ctx, pb, anchor, force);
|
||||
if !place || pb.web || p.objects.is_empty() {
|
||||
return (rects, pl, rel, force.to_vec());
|
||||
}
|
||||
// Not those left out, nor those the page started with (#2048 settled them here).
|
||||
let page = pb.pages.len().saturating_sub(1);
|
||||
let seeded: Vec<usize> =
|
||||
pb.early.get(&page).map(|v| v.iter().take(pb.seeded).filter(|(b, ..)| *b == block).map(|(_, k, _)| *k).collect()).unwrap_or_default();
|
||||
let (bottom, more, col_top) = (pb.bottom, pb.cols.len().saturating_sub(pb.col + 1), pb.top);
|
||||
let fit = |pl: &ParaLayout| fitting(pl, 0, y0 + pl.lines.first().map_or(0.0, |l| l.top.max(0.0)), bottom, (more, col_top));
|
||||
let left_out = left_out_objects(ctx.doc, p, None, ctx.opts.show_hidden, ctx.hide_deleted);
|
||||
let mut late: Vec<usize> =
|
||||
late_floats(p, &pl, fit(&pl)).into_iter().chain(force.iter().copied()).filter(|oi| !left_out(*oi) && !seeded.contains(oi)).collect();
|
||||
late.sort_unstable();
|
||||
late.dedup();
|
||||
if late.as_slice() == force {
|
||||
return (rects, pl, rel, late);
|
||||
}
|
||||
ctx.eq_count = eq0;
|
||||
let (rects, pl, rel) = anchor_floats_but(ctx, pb, anchor, &late);
|
||||
// Without them in the way more of the paragraph may fit, and some of their anchors with it.
|
||||
let still = late_floats(p, &pl, fit(&pl));
|
||||
let fewer: Vec<usize> = late.iter().copied().filter(|oi| force.binary_search(oi).is_ok() || still.binary_search(oi).is_ok()).collect();
|
||||
if fewer.len() == late.len() {
|
||||
return (rects, pl, rel, late);
|
||||
}
|
||||
ctx.eq_count = eq0;
|
||||
let (rects, pl, rel) = anchor_floats_but(ctx, pb, anchor, &fewer);
|
||||
(rects, pl, rel, fewer)
|
||||
}
|
||||
|
||||
/// The floating objects of `p` (laid out as `pl`) whose anchor line ([`anchor_lines`]) is at or
|
||||
/// after `fit`, in order: Word draws them on the page their anchor line is on (#4250).
|
||||
pub(crate) fn late_floats(p: &Paragraph, pl: &ParaLayout, fit: usize) -> Vec<usize> {
|
||||
let lines = anchor_lines(p, pl);
|
||||
let late =
|
||||
|(oi, o): (usize, &InlineObject)| (floating(o).is_some() && lines.get(oi).copied().flatten().is_some_and(|li| li >= fit)).then_some(oi);
|
||||
p.objects.iter().enumerate().filter_map(late).collect()
|
||||
}
|
||||
|
||||
/// The end of the lines of `pl` from `l0` that fit on the page: line `l0`'s top at `top` in the
|
||||
/// column, whose bottom is at `bottom`, then `more` columns from `col_top` (at least one line; a
|
||||
/// page break ends them, a column break its column).
|
||||
pub(crate) fn fitting(pl: &ParaLayout, l0: usize, top: f32, bottom: f32, (mut more, col_top): (usize, f32)) -> usize {
|
||||
let Some(first) = pl.lines.get(l0) else { return l0 };
|
||||
// A line's place in its column: `shift` plus its top.
|
||||
let (mut shift, mut start, mut fit) = (top - first.top, l0, l0 + 1);
|
||||
for (li, l) in pl.lines.iter().enumerate().skip(l0) {
|
||||
if li > start && shift + l.top + l.height > bottom + 0.01 {
|
||||
if more == 0 {
|
||||
break;
|
||||
}
|
||||
(more, start, shift) = (more - 1, li, col_top - l.top);
|
||||
}
|
||||
fit = li + 1;
|
||||
match l.end {
|
||||
LineEnd::PageBreak => break,
|
||||
LineEnd::ColumnBreak if more == 0 => break,
|
||||
LineEnd::ColumnBreak => {
|
||||
more -= 1;
|
||||
start = li + 1;
|
||||
shift = pl.lines.get(start).map_or(shift, |n| col_top - n.top);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
fit
|
||||
}
|
||||
|
||||
/// Place late float `oi` of body paragraph `p` ([`LateFloats`]) in the current column,
|
||||
/// positioned from `top`, where the paragraph continues; its wrap area joins the page's.
|
||||
pub(crate) fn place_late(ctx: &mut Ctx, pb: &mut PageBuilder, p: &Paragraph, oi: usize, top: f32, late: &mut LateFloats) {
|
||||
let Some(o) = p.objects.get(oi) else { return };
|
||||
let Some((w, h, float)) = floating(o) else { return };
|
||||
let h = ctx.float_height(o, w, h);
|
||||
let frame = PageFrame { sect: pb.sect, margin_top: None, origin: (0.0, 0.0), number: pb.number, mirror: ctx.mirror };
|
||||
let rect = float_rect(Some(frame), (pb.col_x(), pb.col_w()), top, w, h, float);
|
||||
let outline = ctx.wrap_outline(o);
|
||||
let area = wrap_area(rect, float, outline.as_deref().map(Vec::as_slice)).map(|a| {
|
||||
let bounds = a.0;
|
||||
pb.excl.push(a);
|
||||
bounds
|
||||
});
|
||||
late.placed.insert(oi, Some(LatePlace { rect, top, spot: (pb.pages.len(), pb.col), drawn: false, area }));
|
||||
}
|
||||
|
||||
/// The line of `pl` each object of `p` goes with (by object index; None if it isn't laid out):
|
||||
/// the one holding its anchor, or for anchors on a line after a page or column break that holds
|
||||
/// nothing taking room ([`para::takes_room`]: only anchors and markers such as bookmarks), the
|
||||
/// break's: Word keeps them on the page before (a cover page's title box, #1632). With text after
|
||||
/// them they are on the new page with it (#4262).
|
||||
pub(crate) fn anchor_lines(p: &Paragraph, pl: &ParaLayout) -> Vec<Option<usize>> {
|
||||
let mut out = vec![None; p.objects.len()];
|
||||
for (li, l) in pl.lines.iter().enumerate() {
|
||||
let clusters = pl.clusters.get(l.c0..l.c1).unwrap_or_default();
|
||||
let lead = li.checked_sub(1).and_then(|k| pl.lines.get(k)).is_some_and(|l| matches!(l.end, LineEnd::PageBreak | LineEnd::ColumnBreak))
|
||||
&& !clusters.iter().any(para::takes_room);
|
||||
for c in clusters {
|
||||
let para::ClKind::Object(oi) = c.kind else { continue };
|
||||
if let Some(slot) = out.get_mut(oi).filter(|s| s.is_none()) {
|
||||
*slot = Some(if lead { li.saturating_sub(1) } else { li });
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// [`anchor_floats`], leaving out the floats in `late` (in order).
|
||||
fn anchor_floats_but(
|
||||
ctx: &mut Ctx,
|
||||
pb: &mut PageBuilder,
|
||||
anchor: FloatAnchor,
|
||||
late: &[usize],
|
||||
) -> (HashMap<usize, Rect>, Arc<ParaLayout>, Vec<para::Exclusion>) {
|
||||
let FloatAnchor { p, block, y0, before, excl_mark, label, place } = anchor;
|
||||
ctx.fields.page = pb.number;
|
||||
let (col_x, width) = (pb.col_x(), pb.col_w());
|
||||
let mut rects = HashMap::new();
|
||||
if !pb.web && place {
|
||||
@@ -363,6 +482,10 @@ pub(crate) fn anchor_floats(
|
||||
if left_out(oi) {
|
||||
continue;
|
||||
}
|
||||
if late.binary_search(&oi).is_ok() {
|
||||
ctx.floats_above.remove(&(block, oi));
|
||||
continue;
|
||||
}
|
||||
let h = ctx.float_height(o, w, h);
|
||||
// Positions relative to the paragraph start above its space before (Word for Windows).
|
||||
let mut r = float_rect(Some(frame), (col_x, width), y0 - before, w, h, float);
|
||||
@@ -414,6 +537,123 @@ pub(crate) struct ObjFrame<'a> {
|
||||
/// Floats placed on the page from a header or footer cell (by object index, in page
|
||||
/// coordinates): their items go to [`Ctx::pin`].
|
||||
pub(crate) pinned: Option<&'a HashMap<usize, Rect>>,
|
||||
/// A body paragraph's floats that go on a later page or column than its first piece.
|
||||
pub(crate) late: Option<&'a LateFloats>,
|
||||
}
|
||||
|
||||
/// Where a late float ([`LateFloats`]) was placed: its rect, the top it's positioned from, the
|
||||
/// page and column, `(pages, col)`, whether it's drawn, and the bounds of its wrap area.
|
||||
#[derive(Clone, Copy)]
|
||||
pub(crate) struct LatePlace {
|
||||
pub(crate) rect: Rect,
|
||||
pub(crate) top: f32,
|
||||
pub(crate) spot: (usize, usize),
|
||||
pub(crate) drawn: bool,
|
||||
pub(crate) area: Option<Rect>,
|
||||
}
|
||||
|
||||
/// A body paragraph's floating objects anchored on a later page than its first line
|
||||
/// ([`late_floats`]): Word draws them there, positioned from where the paragraph continues
|
||||
/// (#4250). Each is placed when the paragraph gets there, and drawn with that piece.
|
||||
pub(crate) struct LateFloats {
|
||||
/// By object index, where it was placed, once it is.
|
||||
pub(crate) placed: HashMap<usize, Option<LatePlace>>,
|
||||
/// The page and column of the piece being placed.
|
||||
pub(crate) piece: (usize, usize),
|
||||
/// [`anchor_lines`], and the paragraph layout they're for.
|
||||
pub(crate) lines: Option<(Arc<ParaLayout>, Vec<Option<usize>>)>,
|
||||
/// Those a piece let go ([`LateFloats::release`]), and those it keeps all the same
|
||||
/// ([`LateFloats::keep_back`]): (piece, object index).
|
||||
pub(crate) held: Vec<((usize, usize), usize)>,
|
||||
pub(crate) kept: Vec<((usize, usize), usize)>,
|
||||
}
|
||||
|
||||
impl LateFloats {
|
||||
pub(crate) fn new(late: &[usize]) -> Self {
|
||||
LateFloats { placed: late.iter().map(|oi| (*oi, None)).collect(), piece: (0, 0), lines: None, held: Vec::new(), kept: Vec::new() }
|
||||
}
|
||||
|
||||
/// [`anchor_lines`] for `pl`.
|
||||
pub(crate) fn refresh(&mut self, p: &Paragraph, pl: &Arc<ParaLayout>) {
|
||||
if !self.lines.as_ref().is_some_and(|(of, _)| Arc::ptr_eq(of, pl)) {
|
||||
self.lines = Some((pl.clone(), anchor_lines(p, pl)));
|
||||
}
|
||||
}
|
||||
|
||||
/// The line object `oi` goes with ([`anchor_lines`]).
|
||||
pub(crate) fn anchor(&self, oi: usize) -> Option<usize> {
|
||||
self.lines.as_ref().and_then(|(_, v)| v.get(oi).copied().flatten())
|
||||
}
|
||||
|
||||
/// Those placed with this piece whose anchor line is past `end` after all (notes, widow
|
||||
/// control or a row kept together took lines [`fitting`] counted) wait for the next piece,
|
||||
/// their wrap areas out of `excl`. Whether there were any.
|
||||
pub(crate) fn release(&mut self, p: &Paragraph, pl: &Arc<ParaLayout>, end: usize, excl: &mut Vec<WrapArea>) -> bool {
|
||||
self.refresh(p, pl);
|
||||
let piece = self.piece;
|
||||
let out: Vec<usize> = self
|
||||
.placed
|
||||
.iter()
|
||||
.filter(|(oi, at)| at.is_some_and(|a| a.spot == piece && !a.drawn) && !self.kept.contains(&(piece, **oi)))
|
||||
.filter(|(oi, _)| self.anchor(**oi).is_some_and(|li| li >= end))
|
||||
.map(|(oi, _)| *oi)
|
||||
.collect();
|
||||
for oi in &out {
|
||||
if let Some(Some(a)) = self.placed.insert(*oi, None)
|
||||
&& let Some(k) = a.area.and_then(|r| excl.iter().rposition(|w| w.0 == r))
|
||||
{
|
||||
excl.remove(k);
|
||||
}
|
||||
self.held.push((piece, *oi));
|
||||
}
|
||||
!out.is_empty()
|
||||
}
|
||||
|
||||
/// Those this piece let go whose anchor line came back into `lines` without them (their own
|
||||
/// wrap pushed it on): kept with this piece after all, as before. Whether there were any.
|
||||
pub(crate) fn keep_back(&mut self, p: &Paragraph, pl: &Arc<ParaLayout>, lines: std::ops::Range<usize>) -> bool {
|
||||
self.refresh(p, pl);
|
||||
let piece = self.piece;
|
||||
let back: Vec<_> =
|
||||
self.held.iter().filter(|(pc, oi)| *pc == piece && self.anchor(*oi).is_some_and(|li| lines.contains(&li))).copied().collect();
|
||||
self.held.retain(|h| !back.contains(h));
|
||||
self.kept.extend(&back);
|
||||
!back.is_empty()
|
||||
}
|
||||
|
||||
/// Those to place with this piece: not placed yet (nor let go by it), or placed for a column
|
||||
/// the paragraph then didn't go into, with their anchor line in `lines`, in order.
|
||||
pub(crate) fn due(&mut self, p: &Paragraph, pl: &Arc<ParaLayout>, lines: std::ops::Range<usize>) -> Vec<usize> {
|
||||
self.refresh(p, pl);
|
||||
let mut due: Vec<usize> = self
|
||||
.placed
|
||||
.iter()
|
||||
.filter(|(_, at)| at.is_none_or(|a| a.spot != self.piece && !a.drawn))
|
||||
.filter(|(oi, _)| !self.held.contains(&(self.piece, **oi)) && self.anchor(**oi).is_some_and(|li| lines.contains(&li)))
|
||||
.map(|(oi, _)| *oi)
|
||||
.collect();
|
||||
due.sort_unstable();
|
||||
due
|
||||
}
|
||||
|
||||
/// Late float `oi` if it's drawn with this piece.
|
||||
pub(crate) fn here(&self, oi: usize) -> Option<LatePlace> {
|
||||
self.placed.get(&oi).copied().flatten().filter(|a| a.spot == self.piece && !a.drawn)
|
||||
}
|
||||
|
||||
/// The lines holding the anchors of those drawn with this piece (an anchor right after a
|
||||
/// break is on the line after its [`anchor_lines`] one).
|
||||
pub(crate) fn lines_here(&self) -> Vec<usize> {
|
||||
self.placed.keys().filter(|oi| self.here(**oi).is_some()).filter_map(|oi| self.anchor(*oi)).flat_map(|li| [li, li + 1]).collect()
|
||||
}
|
||||
|
||||
/// Those drawn with this piece are drawn.
|
||||
pub(crate) fn drew(&mut self) {
|
||||
let piece = self.piece;
|
||||
for a in self.placed.values_mut().flatten().filter(|a| a.spot == piece) {
|
||||
a.drawn = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// What [`anchor_floats`] anchors: paragraph `p` (body block `block`) starting at `y0` with
|
||||
@@ -461,15 +701,15 @@ pub(crate) fn place_objects(ctx: &mut Ctx, req: &ObjectPlacement) -> (Vec<Placed
|
||||
let Some(fl) = pl.lines.get(l0) else { return (Vec::new(), Vec::new()) };
|
||||
// Objects left out of the layout (hidden, or deleted in the final text) aren't placed.
|
||||
let left_out = left_out_objects(ctx.doc, p, at.table_chr, ctx.opts.show_hidden, ctx.hide_deleted);
|
||||
// A floating object belongs to its paragraph, not to its line: Word puts it on the page the
|
||||
// paragraph starts on (a cover page is a paragraph holding a page break and then the text
|
||||
// box with the title; the box stays on the cover). So the paragraph's first piece places
|
||||
// every floating object, whichever of its lines holds the anchor, and later pieces none;
|
||||
// inline objects go with their own line.
|
||||
// The paragraph's first piece draws its floating objects, whichever of its lines holds the
|
||||
// anchor, except those anchored on a later page (`at.late`): the piece there draws them
|
||||
// (#4250). Inline objects go with their own line.
|
||||
let first = l0 == 0;
|
||||
let late = at.late.filter(|l| !l.placed.is_empty());
|
||||
let late_lines = late.map(LateFloats::lines_here).unwrap_or_default();
|
||||
for li in 0..pl.lines.len() {
|
||||
let in_piece = (l0..l1).contains(&li);
|
||||
if !in_piece && !first {
|
||||
if !in_piece && !first && !late_lines.contains(&li) {
|
||||
continue;
|
||||
}
|
||||
let Some(line) = pl.lines.get(li) else { continue };
|
||||
@@ -482,12 +722,23 @@ pub(crate) fn place_objects(ctx: &mut Ctx, req: &ObjectPlacement) -> (Vec<Placed
|
||||
let Some(obj) = p.objects.get(oi) else { continue };
|
||||
let Some((w, h, float)) = obj.frame() else { continue };
|
||||
let floating = float.wrap != Wrap::Inline;
|
||||
if if floating { !first } else { !in_piece } {
|
||||
let is_late = floating && late.is_some_and(|l| l.placed.contains_key(&oi));
|
||||
let late_here = late.and_then(|l| l.here(oi)).filter(|_| floating);
|
||||
let skip = if is_late {
|
||||
late_here.is_none()
|
||||
} else if floating {
|
||||
!first
|
||||
} else {
|
||||
!in_piece
|
||||
};
|
||||
if skip {
|
||||
continue;
|
||||
}
|
||||
let pinned = at.pinned.and_then(|m| m.get(&oi)).copied().filter(|_| floating);
|
||||
let rect = if let Some(r) = pinned {
|
||||
r
|
||||
} else if let Some(l) = late_here {
|
||||
l.rect
|
||||
} else if floating {
|
||||
match floats.get(&oi) {
|
||||
Some(r) => *r,
|
||||
@@ -522,7 +773,7 @@ pub(crate) fn place_objects(ctx: &mut Ctx, req: &ObjectPlacement) -> (Vec<Placed
|
||||
off: c.start,
|
||||
text_box: shown,
|
||||
wrap: float.wrap,
|
||||
origin: Point::new(at.col.0, at.para_y),
|
||||
origin: Point::new(at.col.0, late_here.map_or(at.para_y, |l| l.top)),
|
||||
});
|
||||
// Its text (a group: its text boxes', each where the group's turn carries it; the
|
||||
// text itself stays upright), unless the box budget says no (a box inside itself,
|
||||
|
||||
+116
-11
@@ -1,7 +1,10 @@
|
||||
use super::boxes::{BoxInput, BoxLayout, Edges, joined, layout_box, push_para};
|
||||
use super::cache::has_page_number_fields;
|
||||
use super::ctx::Ctx;
|
||||
use super::floats::{FloatAnchor, ObjFrame, ObjectPlacement, anchor_floats, body_wraps, continued_exclusions, place_objects};
|
||||
use super::floats::{
|
||||
FloatAnchor, LateFloats, ObjFrame, ObjectPlacement, anchor_floats, body_wraps, continued_exclusions, fitting, late_floats, place_late,
|
||||
place_objects,
|
||||
};
|
||||
use super::notes::{DECOR_STORY, NoteBox, para_notes};
|
||||
use super::page::{PageBuilder, PageFrame, item_bottom};
|
||||
use super::placed::Placed;
|
||||
@@ -172,6 +175,15 @@ struct Flow<'a> {
|
||||
wrap_relays: usize,
|
||||
/// The first line not placed yet.
|
||||
l0: usize,
|
||||
/// Its floats anchored on a later page or column than its first piece (#4250).
|
||||
late: LateFloats,
|
||||
/// Whether the floats past the first piece were checked ([`Flow::recheck_late`]), and those
|
||||
/// it held back.
|
||||
rechecked: bool,
|
||||
forced: Vec<usize>,
|
||||
/// The space before it at the top of the piece being placed (a paragraph starting with a page
|
||||
/// break has it on the new page): late floats there are positioned from above it.
|
||||
space_here: f32,
|
||||
}
|
||||
|
||||
impl<'a> Flow<'a> {
|
||||
@@ -185,8 +197,8 @@ impl<'a> Flow<'a> {
|
||||
let guess = pb.y + space_before;
|
||||
let label = ctx.next_label(p);
|
||||
let eq_start = ctx.eq_count;
|
||||
let (float_rects, pl, rel) =
|
||||
anchor_floats(ctx, pb, FloatAnchor { p, block, y0: guess, before: space_before, excl_mark, label: &label, place: true });
|
||||
let (float_rects, pl, rel, late) =
|
||||
anchor_floats(ctx, pb, FloatAnchor { p, block, y0: guess, before: space_before, excl_mark, label: &label, place: true }, &[]);
|
||||
Flow {
|
||||
p,
|
||||
block,
|
||||
@@ -206,6 +218,10 @@ impl<'a> Flow<'a> {
|
||||
page_relays: 0,
|
||||
wrap_relays: 0,
|
||||
l0: 0,
|
||||
late: LateFloats::new(&late),
|
||||
rechecked: false,
|
||||
forced: Vec::new(),
|
||||
space_here: 0.0,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -213,16 +229,19 @@ impl<'a> Flow<'a> {
|
||||
self.pl.lines.len()
|
||||
}
|
||||
|
||||
/// Place the floats and lay the paragraph out again from `y0`, `before` the space before it.
|
||||
fn reanchor(&mut self, ctx: &mut Ctx, pb: &mut PageBuilder, (y0, before): (f32, f32)) {
|
||||
let (rects, pl, rel) = again(ctx, self.eq_start, |ctx| {
|
||||
/// Place the floats and lay the paragraph out again from `y0`, `before` the space before it,
|
||||
/// leaving those in `force` for a later piece.
|
||||
fn reanchor(&mut self, ctx: &mut Ctx, pb: &mut PageBuilder, (y0, before): (f32, f32), force: &[usize]) {
|
||||
let (rects, pl, rel, late) = again(ctx, self.eq_start, |ctx| {
|
||||
anchor_floats(
|
||||
ctx,
|
||||
pb,
|
||||
FloatAnchor { p: self.p, block: self.block, y0, before, excl_mark: self.excl_mark, label: &self.label, place: true },
|
||||
force,
|
||||
)
|
||||
});
|
||||
(self.float_rects, self.pl, self.rel) = (rects, pl, rel);
|
||||
self.late = LateFloats::new(&late);
|
||||
(self.laid_page, self.laid_width) = (pb.number, pb.col_w());
|
||||
}
|
||||
|
||||
@@ -473,8 +492,10 @@ impl<'a> Flow<'a> {
|
||||
pb.advance(self.block, self.body_top);
|
||||
if self.l0 == 0 {
|
||||
let y0 = pb.y;
|
||||
self.reanchor(ctx, pb, (y0, 0.0));
|
||||
self.reanchor(ctx, pb, (y0, 0.0), &[]);
|
||||
self.anchor_y = y0;
|
||||
// Its floats are checked again on the new page.
|
||||
(self.rechecked, self.forced) = (false, Vec::new());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -507,6 +528,13 @@ impl<'a> Flow<'a> {
|
||||
}
|
||||
let end = push_para(&mut items, (StoryRef::Body, &[self.block as u32]), &self.pl, (l0, l1), (x, y), self.width, edges);
|
||||
pb.para_end = Some((pb.pages.len(), pb.col, end));
|
||||
// Late floats whose anchor line came here after all are placed with these lines.
|
||||
self.late.piece = (pb.pages.len(), pb.col);
|
||||
if !pb.web && !self.late.placed.is_empty() {
|
||||
let top = if l0 == 0 { self.anchor_y } else { y - self.space_here };
|
||||
self.place_due(ctx, pb, 0..l1, top);
|
||||
}
|
||||
self.space_here = 0.0;
|
||||
// Pictures, shapes and text boxes in these lines (web view: floats placed by this piece).
|
||||
let at = ObjFrame {
|
||||
page: Some(PageFrame { sect: pb.sect, margin_top: None, origin: (0.0, 0.0), number: pb.number, mirror: ctx.mirror }),
|
||||
@@ -514,6 +542,7 @@ impl<'a> Flow<'a> {
|
||||
para_y: if pb.web { y } else { self.anchor_y },
|
||||
table_chr: None,
|
||||
pinned: None,
|
||||
late: Some(&self.late),
|
||||
};
|
||||
let (behind, front) = place_objects(
|
||||
ctx,
|
||||
@@ -529,6 +558,7 @@ impl<'a> Flow<'a> {
|
||||
depth: 0,
|
||||
},
|
||||
);
|
||||
self.late.drew();
|
||||
items.extend(front);
|
||||
items.extend(line_numbers(ctx, pb, &self.pl, (l0, l1), y));
|
||||
add_items(pb, behind, items);
|
||||
@@ -555,7 +585,8 @@ impl<'a> Flow<'a> {
|
||||
}
|
||||
Some(LineEnd::PageBreak) if starts_with_break && !pb.web => {
|
||||
pb.new_page(block, body_top);
|
||||
pb.y += self.pl.rp.space_before.max(0.0);
|
||||
self.space_here = self.pl.rp.space_before.max(0.0);
|
||||
pb.y += self.space_here;
|
||||
}
|
||||
Some(LineEnd::PageBreak) => pb.new_page(block, body_top),
|
||||
Some(LineEnd::ColumnBreak) => {
|
||||
@@ -578,8 +609,8 @@ impl<'a> Flow<'a> {
|
||||
let y0 = pb.y;
|
||||
// The floats were placed and drawn with the first piece: they stay there.
|
||||
let (p, block, excl_mark, eq_start, label) = (self.p, self.block, self.excl_mark, self.eq_start, &self.label);
|
||||
let (rects, relaid, relaid_rel) = with_page_map(ctx, &mut self.page_map, |ctx| {
|
||||
again(ctx, eq_start, |ctx| anchor_floats(ctx, pb, FloatAnchor { p, block, y0, before: 0.0, excl_mark, label, place: false }))
|
||||
let (rects, relaid, relaid_rel, _) = with_page_map(ctx, &mut self.page_map, |ctx| {
|
||||
again(ctx, eq_start, |ctx| anchor_floats(ctx, pb, FloatAnchor { p, block, y0, before: 0.0, excl_mark, label, place: false }, &[]))
|
||||
});
|
||||
if let Some(k) = relaid.lines.iter().position(|l| l.start == resume) {
|
||||
(self.float_rects, self.pl, self.rel) = (rects, relaid, relaid_rel);
|
||||
@@ -589,6 +620,72 @@ impl<'a> Flow<'a> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Late floats anchored in the lines that fit in this column are placed here, so these lines
|
||||
/// are broken again around them ([`Flow::relay_wrap`]).
|
||||
fn place_due_late(&mut self, ctx: &mut Ctx, pb: &mut PageBuilder) {
|
||||
if self.l0 == 0 || pb.web || self.late.placed.is_empty() {
|
||||
return;
|
||||
}
|
||||
self.late.piece = (pb.pages.len(), pb.col);
|
||||
let lines = self.l0..fitting(&self.pl, self.l0, pb.y, pb.bottom, (0, pb.top));
|
||||
self.place_due(ctx, pb, lines, pb.y - self.space_here);
|
||||
}
|
||||
|
||||
/// Place the late floats due with this piece whose anchor line is in `lines`, positioned from
|
||||
/// `top`.
|
||||
fn place_due(&mut self, ctx: &mut Ctx, pb: &mut PageBuilder, lines: std::ops::Range<usize>, top: f32) {
|
||||
for oi in self.late.due(self.p, &self.pl, lines) {
|
||||
place_late(ctx, pb, self.p, oi, top, &mut self.late);
|
||||
}
|
||||
}
|
||||
|
||||
/// Late floats placed for lines that went on after all (past `l1`) wait for the next piece;
|
||||
/// one whose own wrap pushed its line on stays, as before. Whether the lines from `l0` are to
|
||||
/// be broken again without them. Does nothing once the wrap relays are used up
|
||||
/// ([`MAX_WRAP_RELAYS`]).
|
||||
fn settle_late(&mut self, pb: &mut PageBuilder, l1: usize) -> bool {
|
||||
let l0 = self.l0;
|
||||
if l0 == 0 || pb.web || self.late.placed.is_empty() || self.wrap_relays >= MAX_WRAP_RELAYS {
|
||||
return false;
|
||||
}
|
||||
let end = if l1 == l0 && pb.fresh() { l0 + 1 } else { l1 }; // what the overflow keeps
|
||||
let moved = self.late.release(self.p, &self.pl, end, &mut pb.excl) || self.late.keep_back(self.p, &self.pl, l0..end);
|
||||
moved && l1 > l0
|
||||
}
|
||||
|
||||
/// The first piece's lines end at `l1` on this page. Floats anchored past them, on the next
|
||||
/// page, though [`anchor_floats`] thought they would fit here (footnotes, widow control…),
|
||||
/// are held back after all, once; and those held back whose anchor came back once they were
|
||||
/// out of the way are placed here as before, with the text wrapping around them rather than
|
||||
/// over it (before the notes of these lines are kept: the wrap can push their references on).
|
||||
/// Whether the paragraph was laid out again.
|
||||
fn recheck_late(&mut self, ctx: &mut Ctx, pb: &mut PageBuilder, l1: usize) -> bool {
|
||||
if self.l0 != 0 || pb.web {
|
||||
return false;
|
||||
}
|
||||
if l1 < self.n() && !self.rechecked && pb.col + 1 >= pb.cols.len() {
|
||||
self.rechecked = true;
|
||||
self.forced = late_floats(self.p, &self.pl, l1).into_iter().filter(|oi| !self.late.placed.contains_key(oi)).collect();
|
||||
if !self.forced.is_empty() {
|
||||
let mut force = self.forced.clone();
|
||||
force.extend(self.late.placed.keys());
|
||||
force.sort_unstable();
|
||||
let top = (pb.y, pb.y - self.anchor_y);
|
||||
self.reanchor(ctx, pb, top, &force);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
let back = if self.forced.is_empty() { Vec::new() } else { self.late.due(self.p, &self.pl, 0..l1) };
|
||||
if !back.iter().any(|oi| self.forced.contains(oi)) {
|
||||
return false;
|
||||
}
|
||||
// The others are still held back.
|
||||
self.forced.retain(|oi| !back.contains(oi));
|
||||
let (keep, top) = (std::mem::take(&mut self.forced), (pb.y, pb.y - self.anchor_y));
|
||||
self.reanchor(ctx, pb, top, &keep);
|
||||
true
|
||||
}
|
||||
|
||||
/// Leave the space after the paragraph, for the next one to merge with.
|
||||
fn finish(&self, pb: &mut PageBuilder, rooms: Rooms) {
|
||||
// A mark that stayed with its page break keeps its space after on that page too, and its
|
||||
@@ -730,10 +827,11 @@ fn place_para_in_flow(ctx: &mut Ctx, pb: &mut PageBuilder, p: &Paragraph, block:
|
||||
// A different top only matters when there is something to wrap around.
|
||||
if spot(pb) != placed_at || ((pb.y - f.guess).abs() > 0.01 && !pb.excl.is_empty()) {
|
||||
let y0 = pb.y;
|
||||
f.reanchor(ctx, pb, (y0, applied_before));
|
||||
f.reanchor(ctx, pb, (y0, applied_before), &[]);
|
||||
f.anchor_y = y0 - applied_before;
|
||||
}
|
||||
while f.l0 < f.n() {
|
||||
f.place_due_late(ctx, pb);
|
||||
f.relay_wrap(ctx, pb);
|
||||
f.relay_pages(ctx, pb);
|
||||
// What is laid out for these lines from here (their footnotes) is on this page.
|
||||
@@ -744,6 +842,9 @@ fn place_para_in_flow(ctx: &mut Ctx, pb: &mut PageBuilder, p: &Paragraph, block:
|
||||
let notes = para_notes(ctx.doc, p, &f.pl);
|
||||
let mut fit = f.fit_lines(ctx, pb, ¬es, lead);
|
||||
fit.l1 = f.settled_end(pb, &fit);
|
||||
if f.settle_late(pb, fit.l1) {
|
||||
continue;
|
||||
}
|
||||
if fit.l1 == f.l0 {
|
||||
if !pb.fresh() {
|
||||
f.move_on(ctx, pb);
|
||||
@@ -751,6 +852,9 @@ fn place_para_in_flow(ctx: &mut Ctx, pb: &mut PageBuilder, p: &Paragraph, block:
|
||||
}
|
||||
fit.l1 = f.l0 + 1; // can't fit even one line on an empty page: overflow
|
||||
}
|
||||
if f.recheck_late(ctx, pb, fit.l1) {
|
||||
continue;
|
||||
}
|
||||
let (forced, mark_with_break) = (fit.forced, fit.mark_with_break);
|
||||
f.emit(ctx, pb, rooms, (lead, fit, ¬es));
|
||||
f.after_lines(ctx, pb, forced, mark_with_break);
|
||||
@@ -811,6 +915,7 @@ pub(crate) fn place_outline_para(ctx: &mut Ctx, pb: &mut PageBuilder, p: &Paragr
|
||||
para_y: y,
|
||||
table_chr: None,
|
||||
pinned: None,
|
||||
late: None,
|
||||
};
|
||||
let (behind, front) = place_objects(
|
||||
ctx,
|
||||
|
||||
@@ -72,6 +72,9 @@ pub struct ObjectHit {
|
||||
pub wrap: Wrap,
|
||||
/// Where column- and paragraph-relative offsets start (see [`Placed::Object`]).
|
||||
pub origin: Point,
|
||||
/// A member of a Drawing Canvas, by its index in the canvas, when the hit is that member
|
||||
/// (its frame inside the canvas at `pos`) rather than the canvas. Layout gives `None`.
|
||||
pub member: Option<usize>,
|
||||
}
|
||||
|
||||
/// Object positions in one layout, retaining each page occurrence for page-hinted lookups.
|
||||
@@ -149,6 +152,7 @@ fn objects_in(items: &[Placed], index: usize) -> impl Iterator<Item = ObjectHit>
|
||||
text_box: *text_box,
|
||||
wrap: *wrap,
|
||||
origin: *origin,
|
||||
member: None,
|
||||
}),
|
||||
_ => None,
|
||||
})
|
||||
|
||||
@@ -38,6 +38,7 @@ pub(crate) use chars::{ligature_features, remap_range, thai_run_face};
|
||||
pub use grid::{Exclusion, Grid, is_east_asian};
|
||||
#[cfg(test)]
|
||||
use lines::break_lines;
|
||||
pub(crate) use lines::takes_room;
|
||||
pub use model::{CellText, ClKind, Cluster, Enclosed, Glyph, Label, LabelRun, Line, LineEnd, PARA_MARK_WIDTH, ParaEnv, RubyLayout, StyleRun, VisCl};
|
||||
pub use paragraph::layout_para;
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -109,7 +109,7 @@ fn has_content(pl: &ParaLayout, c0: usize, j: usize) -> bool {
|
||||
/// control's start, a bookmark) and floating objects' anchors take no room and don't count (Word:
|
||||
/// a picture a little wider than the line, after an anchor, stays on the first line rather than
|
||||
/// leaving the anchor a line of its own).
|
||||
fn takes_room(c: &Cluster) -> bool {
|
||||
pub(crate) fn takes_room(c: &Cluster) -> bool {
|
||||
c.kind != ClKind::Marker && !(matches!(c.kind, ClKind::Object(_)) && c.adv == 0.0 && c.obj_h == 0.0)
|
||||
}
|
||||
|
||||
|
||||
+343
-3
@@ -6036,9 +6036,8 @@ fn float_follows_its_paragraph_to_the_next_page() {
|
||||
assert!(para.lines[0].left >= 144.0, "text wraps beside it: {}", para.lines[0].left);
|
||||
}
|
||||
|
||||
/// A floating object belongs to its paragraph, not to the line holding its anchor: Word draws
|
||||
/// it on the page the paragraph starts on. A Word cover page is one paragraph holding a page
|
||||
/// break and then the page-anchored text box with the title; the box stays on the cover.
|
||||
/// A Word cover page is one paragraph holding a page break and then the page-anchored text box
|
||||
/// with the title: with nothing after its anchor, the box stays on the cover.
|
||||
#[test]
|
||||
fn a_float_anchored_after_a_page_break_stays_on_its_paragraph_s_first_page() {
|
||||
let mut d = Document::from_text(&format!("{}\nAfter the cover", wordcraft_doc::para::PAGE_BREAK));
|
||||
@@ -6051,6 +6050,347 @@ fn a_float_anchored_after_a_page_break_stays_on_its_paragraph_s_first_page() {
|
||||
assert!(shapes(&l.pages[1].items).is_empty(), "and not again on page two");
|
||||
}
|
||||
|
||||
/// #4262: a bookmark after the box's anchor (or a comment's end, a field's) takes no room: the box
|
||||
/// still stays on the cover. An inline picture after it does take room: it goes on to page two,
|
||||
/// and the box with it.
|
||||
#[test]
|
||||
fn a_cover_box_stays_on_the_cover_with_a_bookmark_after_it_but_not_with_a_picture() {
|
||||
let cover = |after: InlineObject| {
|
||||
let mut d = Document::from_text(&format!("{}\nAfter the cover", wordcraft_doc::para::PAGE_BREAK));
|
||||
d.insert_object(&Pos::body(0, 1), after, &Default::default()).unwrap();
|
||||
d.insert_object(&Pos::body(0, 1), rect_shape(200.0, 100.0, page_float(100.0, 300.0)), &Default::default()).unwrap();
|
||||
let l = lay(&d);
|
||||
// The floating box; the inline picture is 20 pt wide.
|
||||
let boxes = |pi: usize| shapes(&l.pages[pi].items).iter().filter(|r| r.w > 100.0).count();
|
||||
(boxes(0), boxes(1))
|
||||
};
|
||||
assert_eq!(cover(InlineObject::BookmarkEnd { name: "_GoBack".into() }), (1, 0), "with a bookmark: on the cover");
|
||||
assert_eq!(cover(rect_shape(20.0, 20.0, Default::default())), (0, 1), "with an inline picture: on page two");
|
||||
}
|
||||
|
||||
/// #4262: with text after the anchor, though, the line after the break is on the next page, and
|
||||
/// so is the float anchored at its start. Observed in Word for Mac: a shape dragged beside the
|
||||
/// first line after a page break inside a paragraph is anchored right after the break and stays
|
||||
/// on the new page, positioned from the paragraph's top there.
|
||||
#[test]
|
||||
fn a_float_anchored_before_text_after_a_page_break_is_drawn_on_the_next_page() {
|
||||
use wordcraft_doc::para::{Anchor, Float, Wrap};
|
||||
let text = "The quick brown fox jumps over the lazy dog. ";
|
||||
let mut d = Document::from_text(&format!("{}{}{}", text.repeat(5), wordcraft_doc::para::PAGE_BREAK, text.repeat(5)));
|
||||
let at = text.len() * 5 + wordcraft_doc::para::PAGE_BREAK.len_utf8();
|
||||
let f = Float { wrap: Wrap::Square, h_rel: Anchor::Column, v_rel: Anchor::Paragraph, x: 60.0, y: 1.0, ..Default::default() };
|
||||
d.insert_object(&Pos::body(0, at), rect_shape(100.0, 50.0, f), &Default::default()).unwrap();
|
||||
let l = lay(&d);
|
||||
assert_eq!(l.pages.len(), 2);
|
||||
assert!(shapes(&l.pages[0].items).is_empty(), "not on page one");
|
||||
let (top, ..) = lines_on(&l, 1)[0];
|
||||
assert_eq!(shapes(&l.pages[1].items).len(), 1, "drawn once");
|
||||
let r = shapes(&l.pages[1].items)[0];
|
||||
assert!((r.y - (top + 1.0)).abs() < 0.5, "1 pt below the paragraph's top on page two ({top}): {r:?}");
|
||||
}
|
||||
|
||||
/// The lines of body paragraph 0 on page `pi`: each one's (top on the page, left, start).
|
||||
fn lines_on(l: &DocLayout, pi: usize) -> Vec<(f32, f32, usize)> {
|
||||
let mut out = Vec::new();
|
||||
for it in &l.pages[pi].items {
|
||||
if let Placed::Lines { para, l0, l1, y, story: StoryRef::Body, path, .. } = it
|
||||
&& path.0 == [0]
|
||||
{
|
||||
let first = para.lines[*l0].top;
|
||||
out.extend(para.lines[*l0..*l1].iter().map(|li| (y + li.top - first, li.left, li.start)));
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// #4250: a floating object anchored in a line of a long paragraph on a later page is drawn on
|
||||
/// that page, as Word does (its anchor line's page), not on the page the paragraph starts on.
|
||||
/// That page's text wraps around it; the page before is laid out as without it.
|
||||
#[test]
|
||||
fn a_float_anchored_on_a_later_page_of_its_paragraph_is_drawn_on_that_page() {
|
||||
use wordcraft_doc::para::Wrap;
|
||||
let mut d = Document::from_text(&"The quick brown fox jumps over the lazy dog. ".repeat(400));
|
||||
let plain = lay(&d);
|
||||
assert!(plain.pages.len() >= 3, "pages {}", plain.pages.len());
|
||||
let anchor = lines_on(&plain, 1)[5].2;
|
||||
let f = wordcraft_doc::para::Float { wrap: Wrap::Square, ..page_float(72.0, 300.0) };
|
||||
d.insert_object(&Pos::body(0, anchor), rect_shape(100.0, 50.0, f), &Default::default()).unwrap();
|
||||
let l = lay(&d);
|
||||
assert!(shapes(&l.pages[0].items).is_empty(), "not on the page the paragraph starts on");
|
||||
assert_eq!(l.pages.iter().map(|p| shapes(&p.items).len()).sum::<usize>(), 1, "drawn once");
|
||||
let on_two = shapes(&l.pages[1].items);
|
||||
assert_eq!(on_two.len(), 1, "{on_two:?}");
|
||||
assert!((on_two[0].x - 72.0).abs() < 0.5 && (on_two[0].y - 300.0).abs() < 0.5, "{:?}", on_two[0]);
|
||||
let beside: Vec<_> = lines_on(&l, 1).into_iter().filter(|(y, ..)| *y > 300.0 && *y < 350.0).collect();
|
||||
assert!(!beside.is_empty() && beside.iter().all(|(_, left, _)| *left >= 100.0), "the text beside it wraps: {beside:?}");
|
||||
let starts = |l: &DocLayout| lines_on(l, 0).iter().map(|(_, left, start)| (*left, *start)).collect::<Vec<_>>();
|
||||
assert_eq!(starts(&l), starts(&plain), "page one is as without it");
|
||||
// Its hit area is there too, for selecting and dragging it.
|
||||
let hit = l.object(&Pos::body(0, anchor), 0).expect("laid out");
|
||||
assert_eq!(hit.page, 1);
|
||||
}
|
||||
|
||||
/// #4250: positioned from its paragraph, a float anchored on a later page of the paragraph is
|
||||
/// measured from where the paragraph continues on that page (Word saves such a picture dragged
|
||||
/// onto page two as `relativeFrom="paragraph"` with its offset from that page's top line).
|
||||
#[test]
|
||||
fn a_float_from_its_paragraph_on_a_later_page_is_measured_from_the_paragraph_s_top_there() {
|
||||
use wordcraft_doc::para::{Anchor, Float, Wrap};
|
||||
let mut d = Document::from_text(&"The quick brown fox jumps over the lazy dog. ".repeat(400));
|
||||
let plain = lay(&d);
|
||||
let (top, _, _) = lines_on(&plain, 1)[0];
|
||||
let anchor = lines_on(&plain, 1)[8].2;
|
||||
let f = Float { wrap: Wrap::Square, h_rel: Anchor::Column, v_rel: Anchor::Paragraph, x: 120.0, y: 150.0, dist: 9.0, ..Default::default() };
|
||||
d.insert_object(&Pos::body(0, anchor), rect_shape(130.0, 100.0, f), &Default::default()).unwrap();
|
||||
let l = lay(&d);
|
||||
assert!(shapes(&l.pages[0].items).is_empty());
|
||||
let r = shapes(&l.pages[1].items)[0];
|
||||
assert!((r.y - (top + 150.0)).abs() < 0.5, "150 pt below the paragraph's top on page two ({top}): {r:?}");
|
||||
assert!((r.x - 192.0).abs() < 0.5, "{r:?}");
|
||||
let hit = l.object(&Pos::body(0, anchor), 1).expect("laid out");
|
||||
assert!((hit.origin.y - top).abs() < 0.5, "dragging measures from there too: {:?}", hit.origin);
|
||||
}
|
||||
|
||||
/// #4250: a paragraph starting with a page break has its space before on the new page; a float
|
||||
/// from its paragraph anchored there is still measured from the paragraph's top, above that
|
||||
/// space, as on its first page.
|
||||
#[test]
|
||||
fn a_float_after_a_leading_page_break_is_measured_from_above_the_space_before() {
|
||||
use wordcraft_doc::para::{Anchor, Float, Wrap};
|
||||
let text = format!("Page one.\n{}{}", wordcraft_doc::para::PAGE_BREAK, "The quick brown fox jumps over the lazy dog. ".repeat(20));
|
||||
let mut d = Document::from_text(&text);
|
||||
if let Some(wordcraft_doc::Block::Para(p)) = d.body.get_mut(1).map(std::sync::Arc::make_mut) {
|
||||
p.props.space_before = Some(24.0);
|
||||
}
|
||||
let f = Float { wrap: Wrap::Square, h_rel: Anchor::Column, v_rel: Anchor::Paragraph, y: 150.0, dist: 9.0, ..Default::default() };
|
||||
d.insert_object(&Pos::body(1, 200), rect_shape(130.0, 100.0, f), &Default::default()).unwrap();
|
||||
let l = lay(&d);
|
||||
assert!(shapes(&l.pages[0].items).is_empty());
|
||||
let r = shapes(&l.pages[1].items)[0];
|
||||
assert!((r.y - (72.0 + 150.0)).abs() < 0.5, "150 pt below the top margin: {r:?}");
|
||||
}
|
||||
|
||||
/// #4250, as observed in Word for Mac: lines added at the top of page one push the text down,
|
||||
/// but a picture positioned from its paragraph on page two stays where it is there.
|
||||
#[test]
|
||||
fn text_added_on_page_one_leaves_a_float_on_page_two_where_it_is() {
|
||||
use wordcraft_doc::para::{Anchor, Float, Wrap};
|
||||
let mut d = Document::from_text(&"The quick brown fox jumps over the lazy dog. ".repeat(400));
|
||||
let anchor = lines_on(&lay(&d), 1)[8].2;
|
||||
let f = Float { wrap: Wrap::Square, h_rel: Anchor::Column, v_rel: Anchor::Paragraph, y: 150.0, ..Default::default() };
|
||||
d.insert_object(&Pos::body(0, anchor), rect_shape(130.0, 100.0, f), &Default::default()).unwrap();
|
||||
let before = shapes(&lay(&d).pages[1].items);
|
||||
for _ in 0..3 {
|
||||
d.body.insert(0, wordcraft_doc::para_block(wordcraft_doc::Paragraph::new()));
|
||||
}
|
||||
let l = lay(&d);
|
||||
assert!(shapes(&l.pages[0].items).is_empty());
|
||||
assert_eq!(shapes(&l.pages[1].items), before);
|
||||
}
|
||||
|
||||
/// #4250: anchored on the third page of a paragraph, a float is drawn on the third page.
|
||||
#[test]
|
||||
fn a_float_anchored_two_pages_into_its_paragraph_is_drawn_on_that_page() {
|
||||
let mut d = Document::from_text(&"The quick brown fox jumps over the lazy dog. ".repeat(600));
|
||||
let plain = lay(&d);
|
||||
assert!(plain.pages.len() >= 4, "pages {}", plain.pages.len());
|
||||
let anchor = lines_on(&plain, 2)[3].2;
|
||||
let f = wordcraft_doc::para::Float { wrap: wordcraft_doc::para::Wrap::Square, ..page_float(72.0, 300.0) };
|
||||
d.insert_object(&Pos::body(0, anchor), rect_shape(100.0, 50.0, f), &Default::default()).unwrap();
|
||||
let l = lay(&d);
|
||||
let per_page: Vec<usize> = l.pages.iter().map(|p| shapes(&p.items).len()).collect();
|
||||
assert_eq!(&per_page[..3], [0, 0, 1], "{per_page:?}");
|
||||
assert_eq!(per_page.iter().sum::<usize>(), 1);
|
||||
}
|
||||
|
||||
/// A long paragraph with a long footnote on page one, and a page-placed square float anchored at
|
||||
/// line `k` of page two as laid out without it.
|
||||
fn footnoted_float(k: usize) -> DocLayout {
|
||||
let mut d = Document::from_text(&"The quick brown fox jumps over the lazy dog. ".repeat(400));
|
||||
let mut note = wordcraft_doc::Paragraph::with_text(&"The note text runs on for a while. ".repeat(30), Default::default()).styled("FootnoteText");
|
||||
note.props.space_after = Some(0.0);
|
||||
let id = d.add_part(wordcraft_doc::PartKind::Footnote, vec![wordcraft_doc::para_block(note)]);
|
||||
d.insert_object(
|
||||
&Pos::body(0, 4),
|
||||
InlineObject::NoteRef { kind: wordcraft_doc::para::NoteKind::Footnote, id, custom: String::new() },
|
||||
&Default::default(),
|
||||
)
|
||||
.unwrap();
|
||||
let anchor = lines_on(&lay(&d), 1)[k].2;
|
||||
let f = wordcraft_doc::para::Float { wrap: wordcraft_doc::para::Wrap::Square, ..page_float(72.0, 300.0) };
|
||||
d.insert_object(&Pos::body(0, anchor), rect_shape(100.0, 50.0, f), &Default::default()).unwrap();
|
||||
lay(&d)
|
||||
}
|
||||
|
||||
/// #4250: a footnote on page one leaves less room there than its lines alone suggest; a float
|
||||
/// anchored a few lines into page two is drawn on page two all the same.
|
||||
#[test]
|
||||
fn a_float_anchored_just_past_a_page_of_footnotes_is_drawn_on_the_next_page() {
|
||||
let l = footnoted_float(3);
|
||||
assert!(shapes(&l.pages[0].items).is_empty(), "not on page one");
|
||||
assert_eq!(shapes(&l.pages[1].items).len(), 1);
|
||||
}
|
||||
|
||||
/// #4250: anchored in page two's first line, which goes back to page one once the float no longer
|
||||
/// wraps the text there, a float stays on page one with the text wrapping around it, not over it.
|
||||
#[test]
|
||||
fn a_float_whose_anchor_comes_back_to_page_one_stays_there_with_the_text_around_it() {
|
||||
let l = footnoted_float(0);
|
||||
assert_eq!(l.pages.iter().map(|p| shapes(&p.items).len()).collect::<Vec<_>>()[..2], [1, 0]);
|
||||
let beside: Vec<_> = lines_on(&l, 0).into_iter().filter(|(y, ..)| *y > 300.0 && *y < 350.0).collect();
|
||||
assert!(!beside.is_empty() && beside.iter().all(|(_, left, _)| *left >= 100.0), "the text wraps around it: {beside:?}");
|
||||
}
|
||||
|
||||
/// #4250: the text wrapping around that float again pushes a footnote reference beside its anchor
|
||||
/// back to page two; the note goes with it, once, not left on page one as well.
|
||||
#[test]
|
||||
fn a_footnote_pushed_on_by_a_float_coming_back_is_on_its_reference_s_page_only() {
|
||||
let mut d = Document::from_text(&"The quick brown fox jumps over the lazy dog. ".repeat(400));
|
||||
let note = |text: &str| {
|
||||
let mut p = wordcraft_doc::Paragraph::with_text(text, Default::default()).styled("FootnoteText");
|
||||
p.props.space_after = Some(0.0);
|
||||
vec![wordcraft_doc::para_block(p)]
|
||||
};
|
||||
let noteref = |id| InlineObject::NoteRef { kind: wordcraft_doc::para::NoteKind::Footnote, id, custom: String::new() };
|
||||
let long = d.add_part(wordcraft_doc::PartKind::Footnote, note(&"The note text runs on for a while. ".repeat(30)));
|
||||
d.insert_object(&Pos::body(0, 4), noteref(long), &Default::default()).unwrap();
|
||||
let anchor = lines_on(&lay(&d), 1)[0].2;
|
||||
let short = d.add_part(wordcraft_doc::PartKind::Footnote, note("A short note."));
|
||||
d.insert_object(&Pos::body(0, anchor), noteref(short), &Default::default()).unwrap();
|
||||
let f = wordcraft_doc::para::Float { wrap: wordcraft_doc::para::Wrap::Square, ..page_float(72.0, 300.0) };
|
||||
d.insert_object(&Pos::body(0, anchor), rect_shape(100.0, 50.0, f), &Default::default()).unwrap();
|
||||
let l = lay(&d);
|
||||
assert_eq!(shapes(&l.pages[0].items).len(), 1, "the float stays on page one");
|
||||
let at = anchor + wordcraft_doc::para::OBJ.len_utf8();
|
||||
let ref_page = (0..l.pages.len()).rfind(|&pi| lines_on(&l, pi).first().is_some_and(|f| f.2 <= at));
|
||||
assert_eq!(ref_page, Some(1), "the reference is pushed on to page two");
|
||||
let notes: Vec<usize> = l
|
||||
.pages
|
||||
.iter()
|
||||
.map(|pg| pg.items.iter().filter(|i| matches!(i, Placed::Lines { story: StoryRef::Part(p), .. } if *p == short)).count())
|
||||
.collect();
|
||||
assert_eq!(notes[..2], [0, 1], "the note is on its reference's page, once");
|
||||
}
|
||||
|
||||
/// #4250: of two floats held back for page two, the one whose anchor comes back to page one stays
|
||||
/// there; the other is still drawn on page two.
|
||||
#[test]
|
||||
fn of_two_floats_held_back_only_the_one_whose_anchor_comes_back_stays_on_page_one() {
|
||||
let mut d = Document::from_text(&"The quick brown fox jumps over the lazy dog. ".repeat(400));
|
||||
let mut note = wordcraft_doc::Paragraph::with_text(&"The note text runs on for a while. ".repeat(30), Default::default()).styled("FootnoteText");
|
||||
note.props.space_after = Some(0.0);
|
||||
let id = d.add_part(wordcraft_doc::PartKind::Footnote, vec![wordcraft_doc::para_block(note)]);
|
||||
let noteref = InlineObject::NoteRef { kind: wordcraft_doc::para::NoteKind::Footnote, id, custom: String::new() };
|
||||
d.insert_object(&Pos::body(0, 4), noteref, &Default::default()).unwrap();
|
||||
let lines = lines_on(&lay(&d), 1);
|
||||
let f = |y| wordcraft_doc::para::Float { wrap: wordcraft_doc::para::Wrap::Square, ..page_float(72.0, y) };
|
||||
d.insert_object(&Pos::body(0, lines[3].2), rect_shape(100.0, 50.0, f(400.0)), &Default::default()).unwrap();
|
||||
d.insert_object(&Pos::body(0, lines[0].2), rect_shape(100.0, 50.0, f(300.0)), &Default::default()).unwrap();
|
||||
let l = lay(&d);
|
||||
let ys = |pi: usize| shapes(&l.pages[pi].items).iter().map(|r| r.y).collect::<Vec<_>>();
|
||||
assert_eq!((ys(0).len(), ys(1).len()), (1, 1), "one on each page: {:?} {:?}", ys(0), ys(1));
|
||||
}
|
||||
|
||||
/// #4250: a footnote referenced on page two leaves less room there than its lines alone suggest;
|
||||
/// a float anchored on the first line it pushes on to page three is drawn on page three.
|
||||
#[test]
|
||||
fn a_float_anchored_on_a_line_a_footnote_pushes_on_is_drawn_on_the_next_page() {
|
||||
let mut d = Document::from_text(&"The quick brown fox jumps over the lazy dog. ".repeat(600));
|
||||
let mut note = wordcraft_doc::Paragraph::with_text(&"The note text runs on for a while. ".repeat(30), Default::default()).styled("FootnoteText");
|
||||
note.props.space_after = Some(0.0);
|
||||
let id = d.add_part(wordcraft_doc::PartKind::Footnote, vec![wordcraft_doc::para_block(note)]);
|
||||
let noteref = InlineObject::NoteRef { kind: wordcraft_doc::para::NoteKind::Footnote, id, custom: String::new() };
|
||||
let at = lines_on(&lay(&d), 1)[1].2;
|
||||
d.insert_object(&Pos::body(0, at), noteref, &Default::default()).unwrap();
|
||||
// A word into the line: an anchor at its very start goes with the line before.
|
||||
let anchor = lines_on(&lay(&d), 2)[0].2 + "The ".len();
|
||||
let f = wordcraft_doc::para::Float { wrap: wordcraft_doc::para::Wrap::Square, ..page_float(72.0, 300.0) };
|
||||
d.insert_object(&Pos::body(0, anchor), rect_shape(100.0, 50.0, f), &Default::default()).unwrap();
|
||||
let l = lay(&d);
|
||||
let anchor_page = (0..l.pages.len()).rfind(|&pi| lines_on(&l, pi).first().is_some_and(|f| f.2 <= anchor));
|
||||
let per_page: Vec<usize> = l.pages.iter().map(|p| shapes(&p.items).len()).collect();
|
||||
assert_eq!(anchor_page, Some(2), "the anchor is on page three");
|
||||
assert_eq!(&per_page[..3], [0, 0, 1], "{per_page:?}");
|
||||
let beside: Vec<_> = lines_on(&l, 1).into_iter().filter(|(y, ..)| *y > 300.0 && *y < 350.0).collect();
|
||||
assert!(!beside.is_empty() && beside.iter().all(|(_, left, _)| *left < 1.0), "page two doesn't wrap around it: {beside:?}");
|
||||
}
|
||||
|
||||
/// #4250: widow control takes the line before a paragraph's last on to the next page with it; a
|
||||
/// float anchored there is drawn on that page.
|
||||
#[test]
|
||||
fn a_float_anchored_on_a_line_widow_control_takes_on_is_drawn_on_the_next_page() {
|
||||
// A paragraph of three pages whose last two lines are on the third only by widow control.
|
||||
let (mut d, l) = (150..300)
|
||||
.map(|k| Document::from_text(&"The quick brown fox jumps over the lazy dog. ".repeat(k)))
|
||||
.map(|d| {
|
||||
let l = lay(&d);
|
||||
(d, l)
|
||||
})
|
||||
.find(|(_, l)| l.pages.len() == 3 && lines_on(l, 2).len() == 2 && lines_on(l, 1).len() < lines_on(l, 0).len())
|
||||
.expect("a paragraph ending in a widow");
|
||||
let anchor = lines_on(&l, 2)[0].2 + "The ".len();
|
||||
d.insert_object(&Pos::body(0, anchor), rect_shape(100.0, 50.0, page_float(72.0, 300.0)), &Default::default()).unwrap();
|
||||
let per_page: Vec<usize> = lay(&d).pages.iter().map(|p| shapes(&p.items).len()).collect();
|
||||
assert_eq!(per_page, [0, 0, 1]);
|
||||
}
|
||||
|
||||
/// #4250: a float whose own wrap pushes its anchor line on to the next page stays on the page
|
||||
/// whose lines it was placed with, as before, with the text there wrapping around it.
|
||||
#[test]
|
||||
fn a_float_whose_wrap_pushes_its_own_line_on_stays_with_the_text_around_it() {
|
||||
let mut d = Document::from_text(&"The quick brown fox jumps over the lazy dog. ".repeat(600));
|
||||
let last = *lines_on(&lay(&d), 1).last().unwrap();
|
||||
let anchor = last.2 + "The ".len();
|
||||
let f = wordcraft_doc::para::Float { wrap: wordcraft_doc::para::Wrap::Square, ..page_float(72.0, 300.0) };
|
||||
d.insert_object(&Pos::body(0, anchor), rect_shape(200.0, 100.0, f), &Default::default()).unwrap();
|
||||
let l = lay(&d);
|
||||
let anchor_page = (0..l.pages.len()).rfind(|&pi| lines_on(&l, pi).first().is_some_and(|f| f.2 <= anchor));
|
||||
assert_eq!(anchor_page, Some(2), "its wrap pushes the anchor on to page three");
|
||||
let per_page: Vec<usize> = l.pages.iter().map(|p| shapes(&p.items).len()).collect();
|
||||
assert_eq!(&per_page[..3], [0, 1, 0], "{per_page:?}");
|
||||
let beside: Vec<_> = lines_on(&l, 1).into_iter().filter(|(y, ..)| *y > 300.0 && *y < 400.0).collect();
|
||||
assert!(!beside.is_empty() && beside.iter().all(|(_, left, _)| *left >= 200.0), "the text wraps around it: {beside:?}");
|
||||
}
|
||||
|
||||
/// #4250: holding back a float for a later page lays its paragraph out again; its automatic
|
||||
/// equation numbers aren't counted twice: they read (1), (2).
|
||||
#[test]
|
||||
fn a_paragraph_with_a_float_for_a_later_page_keeps_its_equation_numbers() {
|
||||
let eq = |s: &str| InlineObject::Equation {
|
||||
linear: s.into(),
|
||||
display: true,
|
||||
math: wordcraft_doc::math::Math { nodes: wordcraft_doc::math::parse_linear(s), ..Default::default() },
|
||||
};
|
||||
let mut d = Document::from_text(&format!("{}\n", "The quick brown fox jumps over the lazy dog. ".repeat(400)));
|
||||
let anchor = lines_on(&lay(&d), 1)[5].2;
|
||||
let f = wordcraft_doc::para::Float { wrap: wordcraft_doc::para::Wrap::Square, ..page_float(72.0, 300.0) };
|
||||
d.insert_object(&Pos::body(0, anchor), rect_shape(100.0, 50.0, f), &Default::default()).unwrap();
|
||||
let n = d.para_mut(StoryRef::Body, &Path::top(0)).unwrap().len();
|
||||
d.para_mut(StoryRef::Body, &Path::top(0)).unwrap().insert_object(n, eq("a=b#"), &Default::default()).unwrap();
|
||||
d.para_mut(StoryRef::Body, &Path::top(1)).unwrap().insert_object(0, eq("c=d#"), &Default::default()).unwrap();
|
||||
let l = lay(&d);
|
||||
assert_eq!(shapes(&l.pages[1].items).len(), 1, "held back for page two");
|
||||
assert_eq!(equation_numbers(&l), ["(1)", "(2)"]);
|
||||
}
|
||||
|
||||
/// #4250: in two columns, a float anchored where the paragraph continues in the second column of
|
||||
/// the same page is placed with its first piece, as before, so the first column wraps around it.
|
||||
#[test]
|
||||
fn a_float_anchored_in_the_next_column_of_the_page_stays_with_its_first_piece() {
|
||||
let mut d = Document::from_text(&"The quick brown fox jumps over the lazy dog. ".repeat(120));
|
||||
d.last_section.columns.count = 2;
|
||||
let plain = lay(&d);
|
||||
let lines = lines_on(&plain, 0);
|
||||
let second = lines.iter().position(|w| w.0 < lines[0].0 + 0.5 && w.2 > 0).expect("a second column on page one");
|
||||
let anchor = lines[second + 2].2;
|
||||
let f = wordcraft_doc::para::Float { wrap: wordcraft_doc::para::Wrap::Square, ..page_float(72.0, 200.0) };
|
||||
d.insert_object(&Pos::body(0, anchor), rect_shape(100.0, 50.0, f), &Default::default()).unwrap();
|
||||
let l = lay(&d);
|
||||
assert_eq!(shapes(&l.pages[0].items).len(), 1, "on page one");
|
||||
let beside: Vec<_> = lines_on(&l, 0).into_iter().filter(|(y, left, start)| *y > 200.0 && *y < 250.0 && *start < anchor && *left >= 0.0).collect();
|
||||
assert!(!beside.is_empty() && beside.iter().all(|(_, left, _)| *left >= 100.0), "the first column wraps around it: {beside:?}");
|
||||
}
|
||||
|
||||
/// Floating objects are drawn in their stacking order (`relativeHeight`), not in the order of
|
||||
/// their anchors: a panel anchored after the text box it frames, but lower in the stack, is
|
||||
/// drawn first so the box stays visible over it. Objects without an order keep theirs.
|
||||
|
||||
@@ -324,6 +324,20 @@ fn floating_box_that_resizes_to_fit_takes_its_texts_height() {
|
||||
assert!((shrunk.h - grown.h).abs() < 0.5, "{shrunk:?} {grown:?}");
|
||||
}
|
||||
|
||||
/// #4250: on a later page of its paragraph, a floating box that resizes to fit its text takes its
|
||||
/// text's height too.
|
||||
#[test]
|
||||
fn a_floating_box_on_a_later_page_of_its_paragraph_fits_its_text() {
|
||||
let mut d = Document::from_text(&"The quick brown fox jumps over the lazy dog. ".repeat(400));
|
||||
let float = wordcraft_doc::para::Float { wrap: wordcraft_doc::para::Wrap::Square, ..Default::default() };
|
||||
let id = text_box_with(&mut d, 0, "Inside", 144.0, 1.0, TextBody { auto_fit: true, ..Default::default() }, float);
|
||||
let l = lay(&d);
|
||||
assert!(l.pages.len() >= 3, "pages {}", l.pages.len());
|
||||
let hit = l.find_object(0, |o| o.text_box == Some(id)).unwrap();
|
||||
assert!(hit.page > 0, "drawn on the page of its anchor");
|
||||
assert!(hit.rect.h > 15.0 && hit.rect.h < 30.0, "one line and the margins: {:?}", hit.rect);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_turned_linked_box_turns_only_its_own_lines() {
|
||||
// Linked boxes show the first box's story; turning the second box turns the lines it shows,
|
||||
|
||||
+172
-20
@@ -9,8 +9,8 @@
|
||||
//! was on is drawn again without it (see [`dragged_off_page`]), so it is not left behind where it
|
||||
//! was. The drop is one command, one relayout.
|
||||
//!
|
||||
//! Dragging an In Line with Text object moves it within the text, as in Word: a dashed insertion
|
||||
//! cursor follows the pointer through the text (#2233) and the drop puts it there, still inline
|
||||
//! Dragging an In Line with Text object moves it within the text, as in Word: a bold insertion
|
||||
//! cursor follows the pointer through the text (#2233, #4195) and the drop puts it there, still inline
|
||||
//! (`arrange.bounds` / `arrange.copyTo` with `to`, #2234). A floating one goes where it's dropped.
|
||||
//!
|
||||
//! Moving a floating one snaps it to alignment guides ([`crate::guides`]): the page's edges and
|
||||
@@ -19,6 +19,11 @@
|
||||
//!
|
||||
//! A selected connector shows a handle at each end instead of a frame: dragging one onto a shape
|
||||
//! glues it to the nearest connection site there (`shape.connect`), elsewhere leaves it free.
|
||||
//!
|
||||
//! Inside a Drawing Canvas, a press on a member picks that member (`select.member`, #2637): it
|
||||
//! gets the frame and handles, inside the canvas's frame, and is dragged, resized and turned on
|
||||
//! its own; a press in a text box member's text puts the caret there. The canvas itself is
|
||||
//! picked where no member is.
|
||||
|
||||
use egui::{Color32, Painter, Pos2, Rect, Ui};
|
||||
use serde_json::json;
|
||||
@@ -61,7 +66,7 @@ pub struct ObjectDrag {
|
||||
/// The object drawn alone over its rotated bounds, for the preview: made once it moves.
|
||||
preview: Option<egui::TextureHandle>,
|
||||
/// Moving an In Line with Text object: the text position under the pointer, where it lands,
|
||||
/// and the colour of the dashed insertion cursor drawn there.
|
||||
/// and the colour of the bold insertion cursor drawn there.
|
||||
drop_at: Option<(Pos, Color32)>,
|
||||
}
|
||||
|
||||
@@ -128,22 +133,92 @@ fn active_story(app: &WordApp, layout: &DocLayout) -> StoryRef {
|
||||
/// model for can be selected and deleted, not moved or resized, so it gets no handles, no move
|
||||
/// cursor and no drag.
|
||||
fn fixed(app: &WordApp, hit: &ObjectHit) -> bool {
|
||||
let pos = hit.pos();
|
||||
matches!(app.session.doc.para_at(&pos).and_then(|p| p.object_at(pos.off)), Some(InlineObject::Graphic { .. })) && !size_only(app, hit)
|
||||
matches!(hit_object(app, hit), Some(InlineObject::Graphic { .. })) && !size_only(app, hit)
|
||||
}
|
||||
|
||||
/// Whether the object under `hit` is a chart or SmartArt that WordCraft can edit: it takes
|
||||
/// resize handles and is laid out again at the new size, but it can't be moved or turned.
|
||||
fn size_only(app: &WordApp, hit: &ObjectHit) -> bool {
|
||||
let pos = hit.pos();
|
||||
matches!(
|
||||
app.session.doc.para_at(&pos).and_then(|p| p.object_at(pos.off)),
|
||||
hit_object(app, hit),
|
||||
Some(InlineObject::Graphic { graphic, .. }) if graphic.chart.is_some() || graphic.smart_art.is_some()
|
||||
)
|
||||
}
|
||||
|
||||
/// The story of the selected text box.
|
||||
/// The object a hit is: the object at its position, or the canvas member it names.
|
||||
fn hit_object<'a>(app: &'a WordApp, hit: &ObjectHit) -> Option<&'a InlineObject> {
|
||||
let pos = hit.pos();
|
||||
let o = app.session.doc.para_at(&pos).and_then(|p| p.object_at(pos.off))?;
|
||||
match (hit.member, o) {
|
||||
(None, _) => Some(o),
|
||||
(Some(i), InlineObject::Group { children, canvas: Some(_), .. }) => children.get(i).map(|c| &c.obj),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Member `index` of the canvas laid out at `canvas`, as a hit of its own: its frame where the
|
||||
/// canvas draws it, its own turn. `None` when it isn't drawn, or the canvas is turned (its
|
||||
/// members aren't picked on their own yet).
|
||||
fn member_hit(app: &WordApp, canvas: &ObjectHit, index: usize) -> Option<ObjectHit> {
|
||||
let (o, r) = (pickable_canvas(app, canvas)?, canvas.rect);
|
||||
let (_, frame) = o.member_rects(r.x, r.y, r.w, r.h).into_iter().find(|(i, _)| *i == index)?;
|
||||
member_with_frame(app, canvas, o, index, frame)
|
||||
}
|
||||
|
||||
/// The canvas object of hit `canvas` when its members can be picked: a canvas, hit as a whole,
|
||||
/// not turned.
|
||||
fn pickable_canvas<'a>(app: &'a WordApp, canvas: &ObjectHit) -> Option<&'a InlineObject> {
|
||||
let pos = canvas.pos();
|
||||
let o = app.session.doc.para_at(&pos).and_then(|p| p.object_at(pos.off)).filter(|o| o.is_canvas())?;
|
||||
(canvas.member.is_none() && canvas.spin.is_identity()).then_some(o)
|
||||
}
|
||||
|
||||
/// [`member_hit`] for member `index` of canvas `o`, whose frame on the page (`[x, y, w, h]`) is
|
||||
/// already worked out.
|
||||
fn member_with_frame(app: &WordApp, canvas: &ObjectHit, o: &InlineObject, index: usize, [x, y, w, h]: [f32; 4]) -> Option<ObjectHit> {
|
||||
let InlineObject::Group { children, .. } = o else { return None };
|
||||
let member = children.get(index).filter(|c| wordcraft_engine::cmd::canvas::pickable(c))?;
|
||||
let text_box = match &member.obj {
|
||||
InlineObject::Shape { story: Some(id), .. } if app.session.doc.parts.get(id).is_some_and(|p| p.kind == PartKind::TextBox) => Some(*id),
|
||||
_ => None,
|
||||
};
|
||||
Some(ObjectHit {
|
||||
rect: wordcraft_geom::Rect::new(x, y, w, h),
|
||||
spin: member.obj.frame().map(|(_, _, f)| f.spin()).unwrap_or_default(),
|
||||
text_box,
|
||||
// Placed by the canvas, not the text: dragging any handle may move its top-left.
|
||||
wrap: Wrap::InFrontOfText,
|
||||
member: Some(index),
|
||||
..canvas.clone()
|
||||
})
|
||||
}
|
||||
|
||||
/// The member of the canvas hit `canvas` under the page point (x, y), topmost first; a text box
|
||||
/// member is grabbed anywhere on it too (inside, [`in_text`] tells its text from its border).
|
||||
/// The members' frames are worked out once for all of them.
|
||||
fn member_at(app: &WordApp, canvas: &ObjectHit, x: f32, y: f32, edge: f32) -> Option<ObjectHit> {
|
||||
let (o, r) = (pickable_canvas(app, canvas)?, canvas.rect);
|
||||
// Members are drawn clipped to the canvas: only what shows is grabbed.
|
||||
if !r.contains(wordcraft_geom::Point::new(x, y)) {
|
||||
return None;
|
||||
}
|
||||
o.member_rects(r.x, r.y, r.w, r.h)
|
||||
.into_iter()
|
||||
.rev()
|
||||
.filter_map(|(i, frame)| member_with_frame(app, canvas, o, i, frame))
|
||||
.find(|m| m.rect.expand(edge).contains(m.unspin(x, y)))
|
||||
}
|
||||
|
||||
/// Whether the page point (x, y) is in a text box member's text rather than on its border.
|
||||
fn in_text(m: &ObjectHit, x: f32, y: f32, edge: f32) -> bool {
|
||||
m.text_box.is_some() && m.rect.w.min(m.rect.h) > edge * 3.0 && m.rect.expand(-edge).contains(m.unspin(x, y))
|
||||
}
|
||||
|
||||
/// The story of the selected text box (a picked canvas member too).
|
||||
fn selected_text_box(app: &WordApp) -> Option<u32> {
|
||||
if let Some((_, _, InlineObject::Shape { story: Some(id), .. })) = wordcraft_engine::cmd::canvas::selected_member(&app.session) {
|
||||
return app.session.doc.parts.get(id).is_some_and(|p| p.kind == PartKind::TextBox).then_some(*id);
|
||||
}
|
||||
match selected(app)? {
|
||||
(_, InlineObject::Shape { story: Some(id), .. }) if app.session.doc.parts.get(id).is_some_and(|p| p.kind == PartKind::TextBox) => Some(*id),
|
||||
_ => None,
|
||||
@@ -154,7 +229,11 @@ fn selected_text_box(app: &WordApp) -> Option<u32> {
|
||||
pub fn active(app: &WordApp, layout: &DocLayout) -> Option<(ObjectHit, bool)> {
|
||||
let hint = app.session.page_hint;
|
||||
if let Some((pos, _)) = selected(app) {
|
||||
return layout.object(&pos, hint).map(|o| (o, false));
|
||||
let canvas = layout.object(&pos, hint)?;
|
||||
if let Some((_, i, _)) = wordcraft_engine::cmd::canvas::selected_member(&app.session) {
|
||||
return member_hit(app, &canvas, i).or(Some(canvas)).map(|o| (o, false));
|
||||
}
|
||||
return Some((canvas, false));
|
||||
}
|
||||
match app.session.sel.focus.story {
|
||||
StoryRef::Part(id) if crate::canvas::in_text_box(app) => layout.text_box(id, hint).map(|o| (o, true)),
|
||||
@@ -212,7 +291,7 @@ pub fn pointer(app: &mut WordApp, ui: &Ui, resp: &egui::Response, pages: &[Rect]
|
||||
}
|
||||
if ui.input(|i| i.pointer.primary_down()) {
|
||||
let shift = ui.input(|i| i.modifiers.shift);
|
||||
d.copy = ui.input(|i| i.modifiers.command) && d.drag.grab == Grab::Move;
|
||||
d.copy = ui.input(|i| i.modifiers.command) && d.drag.grab == Grab::Move && d.object.member.is_none();
|
||||
if d.drag.grab == Grab::Rotate {
|
||||
d.drag.turn_to(at, pages, scale, shift);
|
||||
} else {
|
||||
@@ -230,7 +309,12 @@ pub fn pointer(app: &mut WordApp, ui: &Ui, resp: &egui::Response, pages: &[Rect]
|
||||
if d.drag.grab == Grab::Move && d.drag.moved && !d.object.floating() {
|
||||
// An inline one lands in the text: the insertion cursor follows the pointer.
|
||||
d.drop_at = drop_point(app, layout, pages, scale, at, &d);
|
||||
} else if d.drag.grab == Grab::Move && d.drag.moved && app.ui.alignment_guides && !ui.input(|i| i.modifiers.alt) {
|
||||
} else if d.drag.grab == Grab::Move
|
||||
&& d.drag.moved
|
||||
&& d.object.member.is_none()
|
||||
&& app.ui.alignment_guides
|
||||
&& !ui.input(|i| i.modifiers.alt)
|
||||
{
|
||||
snap_move(layout, &mut d, scale, locked_axis);
|
||||
}
|
||||
}
|
||||
@@ -279,6 +363,17 @@ pub fn pointer(app: &mut WordApp, ui: &Ui, resp: &egui::Response, pages: &[Rect]
|
||||
}
|
||||
// Shift/Ctrl+click adds an object to the selected ones (or takes it out), for Group.
|
||||
let adding = ui.input(|i| i.modifiers.shift || i.modifiers.command);
|
||||
if let Some(index) = object.member.filter(|_| pressed && !adding) {
|
||||
return press_member(app, layout, pages, scale, at, object, index, grab);
|
||||
}
|
||||
// Adding to the selection takes the whole canvas.
|
||||
let object = match object.member {
|
||||
Some(_) => match layout.object(&object.pos(), object.page) {
|
||||
Some(o) => o,
|
||||
None => return false,
|
||||
},
|
||||
None => object,
|
||||
};
|
||||
// Ctrl on the object that is already selected starts a copy-drag instead, including
|
||||
// Ctrl+Shift for an aligned copy. Shift alone keeps its additive-selection behavior.
|
||||
let again = selected(app).is_some_and(|(p, _)| p == object.pos())
|
||||
@@ -339,13 +434,55 @@ pub fn pointer(app: &mut WordApp, ui: &Ui, resp: &egui::Response, pages: &[Rect]
|
||||
true
|
||||
}
|
||||
|
||||
/// A press on member `index` of a canvas (`object` is the member's hit): in a text box member's
|
||||
/// text it puts the caret there; elsewhere it picks the member (selecting its canvas first) and
|
||||
/// starts dragging it, or the handle `grab`.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn press_member(app: &mut WordApp, layout: &DocLayout, pages: &[Rect], scale: f32, at: Pos2, object: ObjectHit, index: usize, grab: Grab) -> bool {
|
||||
let edge = EDGE / scale.max(0.01);
|
||||
if grab == Grab::Move
|
||||
&& let (Some(id), Some((page, x, y))) = (object.text_box, crate::canvas::page_at(pages, layout, scale, at))
|
||||
&& in_text(&object, x, y, edge)
|
||||
{
|
||||
// A box linked from another shows (and edits) that box's story.
|
||||
let id = app.session.doc.text_box_chain(id).and_then(|c| c.first().copied()).unwrap_or(id);
|
||||
if let Some(pos) = layout.hit(page, x, y, StoryRef::Part(id)) {
|
||||
let _ = app.run("caret.set", json!({"pos": pos}));
|
||||
}
|
||||
return true;
|
||||
}
|
||||
let pos = object.pos();
|
||||
let end = Pos { off: pos.off + OBJ.len_utf8(), ..pos.clone() };
|
||||
let _ = app.run("select.range", json!({"anchor": pos, "focus": end}));
|
||||
if app.run("select.member", json!({"index": index})).is_err() {
|
||||
return true;
|
||||
}
|
||||
let Some(o) = hit_object(app, &object) else { return true };
|
||||
let (min, picture) = (wordcraft_engine::cmd::objects::min_size(o), matches!(o, InlineObject::Image { .. }));
|
||||
app.canvas.obj_drag = Some(ObjectDrag {
|
||||
drag: Drag::new(grab, object.page, object.rect, object.spin.deg, at),
|
||||
press_origin: pages.get(object.page).map_or(at, |p| p.min),
|
||||
object,
|
||||
min,
|
||||
picture,
|
||||
pending_behind: false,
|
||||
end: None,
|
||||
guides: Vec::new(),
|
||||
preview: None,
|
||||
copy: false,
|
||||
drop_at: None,
|
||||
});
|
||||
true
|
||||
}
|
||||
|
||||
/// The object a move has taken off its page (which page, and where it is in the document): the
|
||||
/// page is rendered again without it while the preview follows the pointer, so the object is not
|
||||
/// left behind where it was. Only for a move — resizing and turning show the object itself, and
|
||||
/// dragging a connector's end onto a shape still shows that connector.
|
||||
/// dragging a connector's end onto a shape still shows that connector. Nor for a member of a
|
||||
/// drawing canvas: its position is the canvas's, and the canvas stays.
|
||||
pub(crate) fn dragged_off_page(drag: Option<&ObjectDrag>) -> Option<(usize, Pos)> {
|
||||
let d = drag?;
|
||||
if !d.drag.moved || d.end.is_some() || d.drag.grab != Grab::Move {
|
||||
if !d.drag.moved || d.end.is_some() || d.drag.grab != Grab::Move || d.object.member.is_some() {
|
||||
return None;
|
||||
}
|
||||
Some((d.object.page, d.object.pos()))
|
||||
@@ -480,15 +617,18 @@ fn grab_at(app: &WordApp, layout: &DocLayout, pages: &[Rect], scale: f32, at: Po
|
||||
return selected_picture;
|
||||
}
|
||||
// Selectable: body objects (table cells too) or the header/footer's own objects;
|
||||
// notes' objects aren't yet.
|
||||
layout.object_at_in(page, x, y, EDGE / scale.max(0.01), story).map(|o| (o, Grab::Move))
|
||||
// notes' objects aren't yet. Inside a canvas, the member there.
|
||||
let edge = EDGE / scale.max(0.01);
|
||||
let o = layout.object_at_in(page, x, y, edge, story)?;
|
||||
Some((member_at(app, &o, x, y, edge).unwrap_or(o), Grab::Move))
|
||||
})
|
||||
}
|
||||
|
||||
/// Objects that can be dragged to a new place: those in the own paragraphs of the body or of a
|
||||
/// header/footer.
|
||||
fn movable(app: &WordApp, o: &ObjectHit) -> bool {
|
||||
o.path.depth() == 0 && selectable_story(app, o.story)
|
||||
// A canvas member moves inside its canvas, wherever the canvas is.
|
||||
(o.member.is_some() || o.path.depth() == 0) && selectable_story(app, o.story)
|
||||
}
|
||||
|
||||
/// Whether a Behind Text object of this kind is grabbed by pressing its body. Pictures, shapes and
|
||||
@@ -541,7 +681,7 @@ fn preview_scale(px: f32, w: f32, h: f32, max_side: usize) -> f32 {
|
||||
/// `o` drawn alone over its rotated bounds, as a texture, at the canvas's `scale` (screen points
|
||||
/// per page point).
|
||||
fn preview(app: &WordApp, ctx: &egui::Context, layout: &DocLayout, o: &ObjectHit, scale: f32) -> Option<egui::TextureHandle> {
|
||||
let obj = app.session.doc.para_at(&o.pos()).and_then(|p| p.object_at(o.off))?;
|
||||
let obj = hit_object(app, o)?;
|
||||
let page = layout.pages.get(o.page)?;
|
||||
let mut opts = crate::canvas::screen_render_options();
|
||||
opts.dark = crate::canvas::dark_page(app);
|
||||
@@ -668,6 +808,13 @@ pub fn paint(app: &WordApp, painter: &Painter, t: &Tokens, layout: &DocLayout, p
|
||||
if let Some((o, editing)) = active(app, layout)
|
||||
&& let Some(f) = screen(pages, scale, o.page, o.rect)
|
||||
{
|
||||
// A picked canvas member: the canvas's frame around it, without handles.
|
||||
if o.member.is_some()
|
||||
&& let Some(c) = layout.object(&o.pos(), o.page)
|
||||
&& let Some(cf) = screen(pages, scale, c.page, c.rect)
|
||||
{
|
||||
frame::paint(painter, t, cf, c.spin.deg, false, false, false);
|
||||
}
|
||||
// Handles where it can be resized and turned (body and header/footer objects,
|
||||
// not charts): else a plain frame.
|
||||
let handles = selectable_story(app, o.story) && !fixed(app, &o);
|
||||
@@ -675,12 +822,13 @@ pub fn paint(app: &WordApp, painter: &Painter, t: &Tokens, layout: &DocLayout, p
|
||||
}
|
||||
}
|
||||
|
||||
/// The dashed insertion cursor where a dragged In Line with Text object will land.
|
||||
/// The insertion cursor where a dragged In Line with Text object will land: solid and twice as
|
||||
/// wide as the caret, so it stands out as Word's does (#4195).
|
||||
fn paint_drop_caret(d: &ObjectDrag, painter: &Painter, layout: &DocLayout, pages: &[Rect], scale: f32) {
|
||||
let Some((pos, color)) = &d.drop_at else { return };
|
||||
let Some(c) = layout.caret_on(pos, d.drag.to_page) else { return };
|
||||
let Some(r) = screen(pages, scale, c.page, wordcraft_geom::Rect::new(c.x, c.top, 0.0, c.height)) else { return };
|
||||
painter.extend(egui::Shape::dashed_line(&[r.left_top(), r.left_bottom()], egui::Stroke::new(1.5, *color), 2.0, 2.0));
|
||||
painter.line_segment([r.left_top(), r.left_bottom()], egui::Stroke::new(3.0, *color));
|
||||
}
|
||||
|
||||
/// A connector's end being dragged: a line from its other end to the pointer, and the connection
|
||||
@@ -740,7 +888,11 @@ pub fn cursor(app: &WordApp, layout: &DocLayout, pages: &[Rect], scale: f32, at:
|
||||
if connector_end_at(app, layout, pages, scale, at).is_some() {
|
||||
return Some(egui::CursorIcon::Crosshair);
|
||||
}
|
||||
grab_at(app, layout, pages, scale, at).map(|(o, g)| hover_cursor(app, &o, g))
|
||||
let in_member_text = |o: &ObjectHit| {
|
||||
crate::canvas::page_at(pages, layout, scale, at).is_some_and(|(_, x, y)| o.member.is_some() && in_text(o, x, y, EDGE / scale.max(0.01)))
|
||||
};
|
||||
grab_at(app, layout, pages, scale, at)
|
||||
.map(|(o, g)| if g == Grab::Move && !fixed(app, &o) && in_member_text(&o) { egui::CursorIcon::Text } else { hover_cursor(app, &o, g) })
|
||||
}
|
||||
|
||||
/// The cursor over part `g` of object `o`: none of a drag for an object that won't move there (a
|
||||
|
||||
@@ -500,7 +500,7 @@ fn alt_pressed_during_a_drag_unsnaps_and_keeps_keytips_off() {
|
||||
assert_eq!(h.state().ui.keytips, crate::keytips::Phase::Off, "Alt during a drag isn't a keytip tap");
|
||||
}
|
||||
|
||||
/// #2233, #2234: dragging an In Line with Text picture into another paragraph shows a dashed
|
||||
/// #2233, #2234: dragging an In Line with Text picture into another paragraph shows a bold
|
||||
/// insertion cursor at the text under the pointer (no alignment guides), and the drop puts the
|
||||
/// picture there, still inline and selected. Dropped back where it was, nothing changes.
|
||||
#[test]
|
||||
@@ -551,6 +551,13 @@ fn an_inline_picture_drags_to_the_insertion_cursor_and_stays_inline() {
|
||||
let d = h.state().canvas.obj_drag.as_ref().expect("dragging");
|
||||
assert_eq!(d.drop_at.as_ref().map(|(p, _)| p.clone()), Some(target.clone()), "the insertion cursor is at the text under the pointer");
|
||||
assert!(d.guides.is_empty(), "an inline object doesn't snap to guides");
|
||||
// #4195: solid and twice the caret's width, so it can be seen, as in Word.
|
||||
let (top, bottom) = (at(&mut h, c.x, c.top), at(&mut h, c.x, c.top + c.height));
|
||||
let bold = h.output().shapes.iter().any(|s| {
|
||||
matches!(&s.shape, egui::Shape::LineSegment { points: [a, b], stroke }
|
||||
if a.distance(top) < 0.5 && b.distance(bottom) < 0.5 && stroke.width == 3.0)
|
||||
});
|
||||
assert!(bold, "a bold insertion cursor from the line's top to its bottom");
|
||||
button(&h, end, false);
|
||||
steps(&mut h, 3);
|
||||
assert!(h.state().canvas.obj_drag.is_none());
|
||||
@@ -1010,3 +1017,90 @@ fn dragging_a_large_picture_renders_a_preview_within_the_gpu_texture_limit() {
|
||||
let [width, height] = preview.size();
|
||||
assert!(width <= max_side && height <= max_side, "preview is {width}×{height}, texture limit is {max_side}");
|
||||
}
|
||||
|
||||
/// #2637: inside a Drawing Canvas the mouse works on the members, as in Word. A click on a
|
||||
/// shape picks that shape (the canvas stays the selection in the text) and shows its frame;
|
||||
/// dragging it moves it inside the canvas; a click in a text box member's text puts the
|
||||
/// caret there. Before, the canvas took every click, as one static object.
|
||||
#[test]
|
||||
fn clicking_and_dragging_inside_a_canvas_works_on_its_members() {
|
||||
use egui::{Event, PointerButton};
|
||||
use wordcraft_engine::cmd::canvas::selected_member;
|
||||
let mut a = WordApp::new(wordcraft_engine::Session::new(Document::new()), crate::Services::default());
|
||||
a.run("view.commentsPane", json!({"value": false})).unwrap();
|
||||
a.run("insert.canvas", json!({"width": 400, "height": 300})).unwrap();
|
||||
a.run("insert.shape", json!({"kind": "rectangle", "width": 100, "height": 40})).unwrap();
|
||||
a.run("canvas.member", json!({"index": 0, "x": 40, "y": 30})).unwrap();
|
||||
a.run("insert.textBox", json!({"text": "Label"})).unwrap();
|
||||
a.run("select.objects", json!({"index": 0})).unwrap();
|
||||
a.run("canvas.member", json!({"index": 1, "x": 200, "y": 100, "width": 150, "height": 80})).unwrap();
|
||||
let mut h = egui_kittest::Harness::builder().with_size(egui::vec2(1440.0, 900.0)).with_step_dt(1.0 / 60.0).build_ui_state(
|
||||
|ui, app: &mut WordApp| {
|
||||
let ctx = ui.ctx().clone();
|
||||
app.logic(&ctx);
|
||||
app.ui(ui);
|
||||
},
|
||||
a,
|
||||
);
|
||||
for _ in 0..6 {
|
||||
h.step();
|
||||
}
|
||||
let frame = h.state_mut().session.layout().object(&Pos::body(0, 0), 0).expect("the canvas is laid out").rect;
|
||||
let at = |h: &mut egui_kittest::Harness<'_, WordApp>, x: f32, y: f32| {
|
||||
crate::canvas::page_to_screen(h.state_mut(), 0, frame.x + x, frame.y + y).unwrap()
|
||||
};
|
||||
let button = |at: Pos2, pressed: bool| Event::PointerButton { pos: at, button: PointerButton::Primary, pressed, modifiers: Default::default() };
|
||||
let members =
|
||||
|h: &egui_kittest::Harness<'_, WordApp>| match h.state().session.doc.body.first().and_then(|b| b.as_para()).and_then(|p| p.objects.first()) {
|
||||
Some(InlineObject::Group { children, .. }) => children.iter().map(|c| (c.x, c.y)).collect::<Vec<_>>(),
|
||||
o => panic!("{o:?}"),
|
||||
};
|
||||
|
||||
// A click on the rectangle picks it.
|
||||
let on_rect = at(&mut h, 90.0, 50.0);
|
||||
h.event(Event::PointerMoved(on_rect));
|
||||
h.event(button(on_rect, true));
|
||||
h.step();
|
||||
h.event(button(on_rect, false));
|
||||
for _ in 0..3 {
|
||||
h.step();
|
||||
}
|
||||
assert_eq!(selected_member(&h.state().session).map(|(_, i, _)| i), Some(0), "the rectangle is picked, not the whole canvas");
|
||||
let shown = {
|
||||
let app = h.state_mut();
|
||||
let layout = app.session.layout();
|
||||
active(app, &layout).map(|(o, _)| (o.member, o.rect))
|
||||
};
|
||||
assert_eq!(shown, Some((Some(0), wordcraft_geom::Rect::new(frame.x + 40.0, frame.y + 30.0, 100.0, 40.0))), "its frame is the one shown");
|
||||
|
||||
// Dragged 50 pt right and 20 pt down, it moves inside the canvas; the canvas stays put.
|
||||
let to = at(&mut h, 140.0, 70.0);
|
||||
h.event(Event::PointerMoved(on_rect));
|
||||
h.event(button(on_rect, true));
|
||||
h.step();
|
||||
for p in [on_rect + egui::vec2(0.0, 30.0), to + egui::vec2(0.0, 30.0), to] {
|
||||
h.event(Event::PointerMoved(p));
|
||||
h.step();
|
||||
}
|
||||
h.event(button(to, false));
|
||||
for _ in 0..3 {
|
||||
h.step();
|
||||
}
|
||||
let moved = members(&h);
|
||||
let near = |(x, y): (f32, f32), (ex, ey): (f32, f32)| (x - ex).abs() < 0.01 && (y - ey).abs() < 0.01;
|
||||
assert!(near(moved[0], (90.0, 50.0)) && near(moved[1], (200.0, 100.0)), "{moved:?}");
|
||||
assert_eq!(h.state_mut().session.layout().object(&Pos::body(0, 0), 0).map(|o| o.rect), Some(frame));
|
||||
|
||||
// A click in the text box's text puts the caret in it.
|
||||
let in_box = at(&mut h, 275.0, 140.0);
|
||||
h.event(Event::PointerMoved(in_box));
|
||||
h.event(button(in_box, true));
|
||||
h.step();
|
||||
h.event(button(in_box, false));
|
||||
for _ in 0..3 {
|
||||
h.step();
|
||||
}
|
||||
let story = h.state().session.sel.focus.story;
|
||||
let is_box = matches!(story, StoryRef::Part(id) if h.state().session.doc.parts.get(&id).is_some_and(|p| p.kind == PartKind::TextBox));
|
||||
assert!(is_box, "the caret is in the text box's text: {story:?}");
|
||||
}
|
||||
|
||||
+10
-3
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+9
-2
File diff suppressed because one or more lines are too long
@@ -126,6 +126,6 @@ modules:
|
||||
# the two equal).
|
||||
- type: git
|
||||
url: https://github.com/storytold/craft-i18n
|
||||
commit: 9d6d54d6d9ff603cc41e12e5f6ba51af3554ab66
|
||||
commit: 1e515422afdda2f317af430c1f9d68a5f83935b4
|
||||
dest: craft-i18n
|
||||
- cargo-sources.json
|
||||
|
||||
Reference in New Issue
Block a user