mirror of
https://github.com/storytold/pdfcraft.git
synced 2026-11-09 12:32:52 +00:00
Merge upstream/main into fix/render-patterned-stencils-share-cells
This commit is contained in:
commit
ff2654227f
11 files changed
+386
-125
No files matched your search
Generated
+10
@@ -1303,6 +1303,15 @@ dependencies = [
|
||||
"zip",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "craft-web-host"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=2e2312ddaf21bc3d85cd3395d0955c7d239306f6#2e2312ddaf21bc3d85cd3395d0955c7d239306f6"
|
||||
dependencies = [
|
||||
"wasm-bindgen",
|
||||
"web-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "craft-winprint-gdi"
|
||||
version = "0.1.0"
|
||||
@@ -4729,6 +4738,7 @@ dependencies = [
|
||||
name = "pdfcraft-web"
|
||||
version = "0.18.1"
|
||||
dependencies = [
|
||||
"craft-web-host",
|
||||
"eframe",
|
||||
"egui",
|
||||
"js-sys",
|
||||
|
||||
@@ -31,6 +31,7 @@
|
||||
> **Last reviewed:** 2026-10-29 | **Last updated:** 2026-10-29 | **Change:** minor (M2: scans keep their strokes at screen size and are smoothed when zoomed; M7 #2517: a narrowed then widened paragraph keeps the typed spaces the wrap consumed, after save and reopen; estimates unchanged)
|
||||
> **Last reviewed:** 2026-10-28 | **Last updated:** 2026-10-28 | **Change:** minor (M10, #4119 partial: Enhance scanned file cleans scanned pages in place: background removal, edge shadow removal, despeckle; estimates unchanged)
|
||||
> **Last reviewed:** 2026-10-27 | **Last updated:** 2026-10-27 | **Change:** minor (M10: OCR model downloads use verified ONNX pairs with legacy discovery fallback; estimates unchanged)
|
||||
> **Last reviewed:** 2026-10-25 | **Last updated:** 2026-10-25 | **Change:** minor (web: same-document host mode on the canvas, `data-host="page"`; estimates unchanged)
|
||||
> **Last reviewed:** 2026-10-25 | **Last updated:** 2026-10-25 | **Change:** minor (M14 localization current state: Arabic logical catalog, explicit RTL label direction and mirrored stock chrome; native/browser RTL workflows unqualified. Native release Hans face via LXGW WenKai Lite; estimates unchanged)
|
||||
> **Last reviewed:** 2026-10-23 · **Last updated:** 2026-10-23 · **Change:** minor (M7, #2636 partial: pasting added text boxes and images in the engine and through `content_copy`; estimates unchanged)
|
||||
> **Last reviewed:** 2026-10-23 · **Last updated:** 2026-10-23 · **Change:** minor (M11: image optimization accounts for page-local UserUnit; estimates unchanged)
|
||||
@@ -273,6 +274,7 @@ Ranked; detail and the full milestone table in [docs/roadmap.md](docs/roadmap.md
|
||||
- **2026-10-29 (M2):** Scanned pages look like the paper again. An image that doesn't ask for /Interpolate was shrunk by nearest-neighbour (since #635), which kept one sample in N per axis, so the text of a 300 dpi scan at 100% lost most of its strokes; it is now area-averaged to whole device pixels (on a 13-page 300 dpi scan every sample at 96–192 dpi is within 1 of the exact area average; it was up to 255 off). Enlarged, an image of page-scan size is smoothed bilinearly, so a zoomed scan has no staircases; icons, QR codes and pixel art keep hard pixels. Estimates unchanged.
|
||||
- **2026-10-26 (M11, JBIG2 symbols):** Lossless JBIG2 now stores each distinct mark of a black-and-white image once, as a symbol, and places its exact copies, when that is smaller than the generic region: on six pages of rendered 300 dpi text 28% of the generic region's size and 17% of Group 4's, and the same bits back through two independent decoders (hayro-jbig2 and Poppler). Scans, whose letters are seldom identical, gain little until refinement coding; a dictionary shared between pages (`/JBIG2Globals`) is next. Estimates unchanged.
|
||||
- **2026-10-25 (M5, comment replies):** One reply can be deleted from the Comments panel without its thread. On the selected card each reply has its own "…" menu (Edit, Copy text, Delete); Delete takes that reply with its pop-up and the replies to it, as `comment_delete` already did headlessly, and the comment and its other replies stay. The card stays selected, and the panel's editor stays open, on what they were on though the delete moved it in `/Annots`. Undo and Redo close the editor, since either can move what it was open on. A locked reply can't be deleted. Estimates unchanged.
|
||||
- **2026-10-25 (web, same-document host mode):** A page that loads the PdfCraft web module into its own document (no iframe) can opt into the host bridge with `data-host="page"` on the app's canvas — the exact value `page`; any other value keeps the normal app with downloads, unless a same-origin framed `?host=parent` selects the parent bridge (unframed or cross-origin, `?host=parent` also keeps the normal app). It is the `?host=parent` bridge with the transport swapped: the same five messages travel as non-bubbling `CustomEvent`s on the canvas (`detail` holds the parent messages' fields without `type`; the host answers with a `pdfcraft:open` event), so saves reach the host instead of downloading and dirty and start-up-failure reporting are unchanged. Unit tests cover the opt-in rule; no parity or estimate changes.
|
||||
- **2026-10-24 (M10, #4158 partial):** Word, HTML, RTF and PowerPoint export keep a block that continues another on its line (an italic case name, a link, a superscript) as a run in its own style inside that paragraph, with the source's word space, instead of a paragraph of its own. Style changes that wrap onto the next line still split paragraphs (the reading blocks keep style boundaries). Estimates unchanged.
|
||||
- **2026-10-24 (M3, window move on Wayland, #3970 follow-up):** After a move or resize by PdfCraft's own title bar or edges, the button release given back to egui goes before the pointer leaving the window, so hover no longer sticks at the old place until the pointer moves. Found by CodeRabbit in review of #3970; a unit test covers it. Estimates unchanged.
|
||||
- **2026-10-24 (M14, #4253):** The annotation quick bar remembers Keep tool selected across restarts, including unpinning. Old and malformed settings keep safe defaults; the opt-in UI control channel reads and changes the same preference. Estimates unchanged.
|
||||
@@ -303,6 +305,7 @@ Ranked; detail and the full milestone table in [docs/roadmap.md](docs/roadmap.md
|
||||
- **2026-10-24 (M5, move preview):** A comment being moved is drawn where it goes while it is dragged (a rectangle or oval showed only its blue frame): the comment is rendered alone once as the drag starts and laid over the page, which shows it where it was until the move is applied. Engine `Document::comment_layer`; synthetic engine and UI regressions. Estimates unchanged.
|
||||
- **2026-10-24 (M5, Escape in Fill & Sign):** Escape leaves a Fill & Sign tool as it leaves a comment tool: the first press closes the open type box, the next goes back to Select, so the Text tool no longer opens a box at every click with no way out by the keyboard. A synthetic UI regression covers the Text and Checkmark tools. Estimates unchanged.
|
||||
- **2026-10-23 (M5, ortho):** Shift while drawing holds a line or arrow level or plumb, whichever is nearer (a CAD drawing's ortho mode), and each new side of connected lines, a polygon or a cloud from the corner before; dragging an end or corner with Shift is held the same way (it turned to 45° steps). `measure::snap::ortho`; `comment_add ortho`. Estimates unchanged.
|
||||
- **2026-10-24 (M3, arrow keys on a wide page):** → / ← scroll a page wider than the window (a plan zoomed in) sideways a line at a time and turn the page at its edge, as ↓ / ↑ do in single-page view; ⌘→ / ⌘← turn it at once, and a page that fits across turns as before (#185). A synthetic viewer regression covers both. Estimates unchanged.
|
||||
- **2026-10-22 (M4, #3916 partial):** Document edits and Undo/Redo cancel an Organize page drag. Pointer-release regressions check exact page order and save/reopen.
|
||||
- **2026-10-22 (M10, #3752 paper):** CUPS jobs ask for the chosen paper in every size. The 13 sizes the Print dialog sent no paper for (A6, A0–A2, ARCH, ANSI C–E) use PWG 5101.1 names, checked against a synthetic PPD with `cupsfilter`, and `doc_print` to a printer sends the paper too. The macOS tray half (Auto Select instead of the PPD's default tray) is a craft-print change that needs a pin bump here. Estimates unchanged.
|
||||
|
||||
|
||||
@@ -11,10 +11,12 @@ workspace = true
|
||||
|
||||
[dependencies]
|
||||
pdfcraft-ui-egui = { workspace = true }
|
||||
# The host messages (storytold/craft-libs): one contract and one bridge for the web builds.
|
||||
craft-web-host = { git = "https://github.com/storytold/craft-libs", rev = "2e2312ddaf21bc3d85cd3395d0955c7d239306f6", version = "0.1.0" }
|
||||
eframe = { workspace = true, features = ["default_fonts", "glow", "persistence", "accesskit"] }
|
||||
egui = { workspace = true, features = ["default_fonts", "monochrome_emoji_fonts"] }
|
||||
|
||||
[target.'cfg(target_arch = "wasm32")'.dependencies]
|
||||
wasm-bindgen-futures = "0.4"
|
||||
web-sys = { version = "0.3", features = ["Window", "Document", "Element", "HtmlCanvasElement", "HtmlElement", "Location", "Url", "UrlSearchParams", "Response", "Event", "MessageEvent", "EventTarget"] }
|
||||
web-sys = { version = "0.3", features = ["Window", "Document", "Element", "HtmlCanvasElement", "HtmlElement", "Location", "Url", "UrlSearchParams", "Response", "Event", "MessageEvent", "EventTarget", "CustomEvent", "CustomEventInit"] }
|
||||
js-sys = "0.3"
|
||||
+81
-110
@@ -5,18 +5,11 @@
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
fn main() {
|
||||
use eframe::wasm_bindgen::JsCast;
|
||||
use pdfcraft_ui_egui::PdfCraftApp;
|
||||
|
||||
let options = eframe::WebOptions { renderer: eframe::Renderer::Glow, ..Default::default() };
|
||||
wasm_bindgen_futures::spawn_local(async move {
|
||||
let Some(canvas) = web_sys::window()
|
||||
.and_then(|w| w.document())
|
||||
.and_then(|d| d.get_element_by_id("pdfcraft"))
|
||||
.and_then(|e| e.dyn_into::<web_sys::HtmlCanvasElement>().ok())
|
||||
else {
|
||||
return;
|
||||
};
|
||||
let Some(canvas) = app_canvas() else { return };
|
||||
let started = eframe::WebRunner::new()
|
||||
.start(
|
||||
canvas,
|
||||
@@ -54,11 +47,11 @@ fn main() {
|
||||
// eframe never raises a close request on a browser reload or tab close, so `guard_quit` never
|
||||
// prompts; ask the browser to confirm instead while a document has unsaved work (#812).
|
||||
warn_before_unload(app.unsaved_flag.clone());
|
||||
// `?host=parent`: a same-origin page embedding PdfCraft in an iframe (such
|
||||
// as the Nextcloud app) passes files in and takes saves back (`host` below).
|
||||
if query_param("host").as_deref() == Some("parent")
|
||||
&& let Some(bridge) = host::Bridge::new()
|
||||
{
|
||||
// Host modes (`host` below, over `craft-web-host`): `?host=parent`, a
|
||||
// same-origin page embedding PdfCraft in an iframe (such as the Nextcloud
|
||||
// app), or `data-host="page"` on the canvas, a host that runs the module in
|
||||
// its own document.
|
||||
if let Some(bridge) = app_canvas().as_ref().and_then(host::Bridge::connect) {
|
||||
// `?author=`: the host's name for the user signs their comments,
|
||||
// unless they chose a name in PdfCraft (the web default is "Guest").
|
||||
if let Some(author) = query_param("author").map(|a| a.trim().to_string()).filter(|a| !a.is_empty())
|
||||
@@ -81,9 +74,7 @@ fn main() {
|
||||
.await;
|
||||
if let Err(e) = started {
|
||||
eframe::web_sys::console::error_1(&e);
|
||||
if query_param("host").as_deref() == Some("parent")
|
||||
&& let Some(bridge) = host::Bridge::new()
|
||||
{
|
||||
if let Some(bridge) = app_canvas().as_ref().and_then(host::Bridge::connect) {
|
||||
bridge.post_failed(&e.as_string().unwrap_or_else(|| format!("{e:?}")));
|
||||
}
|
||||
}
|
||||
@@ -123,136 +114,116 @@ async fn fetch_bytes(url: &str) -> Result<Vec<u8>, String> {
|
||||
Ok(js_sys::Uint8Array::new(&buf).to_vec())
|
||||
}
|
||||
|
||||
/// The `postMessage` bridge to a same-origin parent page. Messages are plain objects with a
|
||||
/// `type`:
|
||||
/// The app's canvas: the one element `index.html` and every embedding page must provide, with
|
||||
/// the id the app looks up.
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
fn app_canvas() -> Option<web_sys::HtmlCanvasElement> {
|
||||
use eframe::wasm_bindgen::JsCast;
|
||||
web_sys::window()?.document()?.get_element_by_id("pdfcraft").and_then(|e| e.dyn_into::<web_sys::HtmlCanvasElement>().ok())
|
||||
}
|
||||
|
||||
/// Host modes: how the page embedding PdfCraft passes files in and takes saves back. The messages
|
||||
/// and the two transports are `craft-web-host`'s (storytold/craft-libs), the same contract as the
|
||||
/// other Craft apps' bridges, with the `pdfcraft:` prefix. This module is PdfCraft's part of it.
|
||||
///
|
||||
/// - `pdfcraft:ready` (to the parent): the app is listening; send files now.
|
||||
/// - `pdfcraft:open` (from the parent): `{ name, bytes }`, where `bytes` is an `ArrayBuffer` or
|
||||
/// `Uint8Array` holding a PDF. It opens in a tab, like a `?file=` URL.
|
||||
/// - `pdfcraft:save` (to the parent): `{ name, saveAs, bytes }` when the user saves; `bytes` is
|
||||
/// a transferred `ArrayBuffer`. The parent stores the file (and reports its own errors).
|
||||
/// - `pdfcraft:dirty` (to the parent): `{ dirty }` whenever unsaved work appears or goes away.
|
||||
/// - `pdfcraft:failed` (to the parent): `{ error }` when the app couldn't start.
|
||||
///
|
||||
/// Only the parent window on the app's own origin is heard or answered. With a parent on
|
||||
/// another origin there is no bridge: the app runs as if unframed and saves download.
|
||||
/// - `?host=parent` is the `postMessage` bridge for a same-origin page that embeds PdfCraft in an
|
||||
/// iframe (such as the Nextcloud app). With a parent on another origin there is no bridge: the
|
||||
/// app runs as if unframed and saves download (#2167). `data-host="page"` on the app's canvas
|
||||
/// is for a host that loads the module into its own document, with no iframe: the same messages
|
||||
/// travel as DOM `CustomEvent`s on that canvas. `?host=parent` wins when both are present.
|
||||
/// - `pdfcraft:ready` once the app is listening; send files now. PdfCraft reads bytes, so it
|
||||
/// says `file: false` and a host sends `bytes`.
|
||||
/// - `pdfcraft:open` (from the host): `{ name, bytes }` holding a PDF. It opens in a tab, like a
|
||||
/// `?file=` URL; a file over [`host::MAX_OPEN_BYTES`] is refused before it is copied.
|
||||
/// - `pdfcraft:save` (to the host): `{ name, saveAs, bytes }` when the user saves. The host stores
|
||||
/// the file (and reports its own errors).
|
||||
/// - `pdfcraft:dirty` whenever unsaved work appears or goes away, `pdfcraft:failed` when the app
|
||||
/// couldn't start.
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
mod host {
|
||||
use eframe::wasm_bindgen::{JsCast, JsValue, closure::Closure};
|
||||
use std::rc::Rc;
|
||||
|
||||
use craft_web_host::web::{FileData, Host, Message};
|
||||
|
||||
/// The app id in the host messages (`pdfcraft:ready`).
|
||||
const APP: &str = "pdfcraft";
|
||||
|
||||
/// The name a file gets when the host sent none (the crate says `document` for a blank name).
|
||||
const DEFAULT_NAME: &str = "document.pdf";
|
||||
|
||||
/// The largest file the host may hand over to open, checked before it is copied (the app's own readers have their own limits).
|
||||
const MAX_OPEN_BYTES: u64 = 512 << 20;
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct Bridge {
|
||||
window: web_sys::Window,
|
||||
parent: web_sys::Window,
|
||||
origin: String,
|
||||
host: Rc<Host>,
|
||||
}
|
||||
|
||||
impl Bridge {
|
||||
/// `None` when the page isn't framed (there is no parent to talk to), or when the parent
|
||||
/// is on another origin: messages only go to this origin, so the browser would drop
|
||||
/// every save without an error while the document showed as saved (#2167).
|
||||
pub fn new() -> Option<Self> {
|
||||
let window = web_sys::window()?;
|
||||
let parent = window.parent().ok().flatten()?;
|
||||
if js_sys::Object::is(&parent, &window) {
|
||||
return None;
|
||||
}
|
||||
let origin = window.location().origin().ok()?;
|
||||
// Reading another origin's location throws, which is `None` too.
|
||||
if parent.location().origin().ok()? != origin {
|
||||
return None;
|
||||
}
|
||||
Some(Self { window, parent, origin })
|
||||
}
|
||||
|
||||
fn post(&self, message: &js_sys::Object, transfer: Option<&JsValue>) -> Result<(), String> {
|
||||
let sent = match transfer {
|
||||
Some(t) => self.parent.post_message_with_transfer(message, &self.origin, &js_sys::Array::of1(t)),
|
||||
None => self.parent.post_message(message, &self.origin),
|
||||
};
|
||||
sent.map_err(|e| e.as_string().unwrap_or_else(|| format!("{e:?}")))
|
||||
/// The bridge for this page, or `None` when PdfCraft isn't hosted: no `?host=parent` and
|
||||
/// no `data-host="page"` on the canvas, an unframed `?host=parent` page, or a parent on
|
||||
/// another origin (messages only go to this origin, so the browser would drop every save
|
||||
/// without an error while the document showed as saved, #2167).
|
||||
pub fn connect(canvas: &web_sys::HtmlCanvasElement) -> Option<Self> {
|
||||
Some(Self { host: Rc::new(Host::connect(APP, canvas)?) })
|
||||
}
|
||||
|
||||
pub fn post_ready(&self) {
|
||||
if let Some(m) = message(&[("type", "pdfcraft:ready".into()), ("version", env!("CARGO_PKG_VERSION").into())]) {
|
||||
let _ = self.post(&m, None);
|
||||
}
|
||||
let _ = self.host.post_ready(env!("CARGO_PKG_VERSION"), false);
|
||||
}
|
||||
|
||||
/// The app couldn't start (no WebGL, for example): let the parent say so.
|
||||
/// The app couldn't start (no WebGL, for example): let the host say so.
|
||||
pub fn post_failed(&self, error: &str) {
|
||||
if let Some(m) = message(&[("type", "pdfcraft:failed".into()), ("error", error.into())]) {
|
||||
let _ = self.post(&m, None);
|
||||
}
|
||||
let _ = self.host.post_failed(error, None);
|
||||
}
|
||||
|
||||
/// Hear `pdfcraft:open` from the parent and queue the file like a `?file=` download.
|
||||
/// Hear `pdfcraft:open` and queue the file like a `?file=` download.
|
||||
pub fn listen(&self, inbox: pdfcraft_ui_egui::Inbox, ctx: egui::Context) {
|
||||
let parent = self.parent.clone();
|
||||
let origin = self.origin.clone();
|
||||
let on_message = Closure::<dyn FnMut(web_sys::MessageEvent)>::new(move |event: web_sys::MessageEvent| {
|
||||
if event.origin() != origin || !event.source().is_some_and(|s| js_sys::Object::is(&s, &parent)) {
|
||||
return;
|
||||
self.host.listen(move |message| {
|
||||
if let Message::Open { name, data } = message {
|
||||
queue_open(&name, &data, &inbox, &ctx);
|
||||
}
|
||||
let data = event.data();
|
||||
if string(&data, "type").as_deref() != Some("pdfcraft:open") {
|
||||
return;
|
||||
}
|
||||
let name = string(&data, "name").filter(|n| !n.trim().is_empty()).unwrap_or_else(|| "document.pdf".to_string());
|
||||
let Ok(bytes) = js_sys::Reflect::get(&data, &JsValue::from_str("bytes")) else { return };
|
||||
let bytes = if let Some(array) = bytes.dyn_ref::<js_sys::Uint8Array>() {
|
||||
array.to_vec()
|
||||
} else if bytes.is_instance_of::<js_sys::ArrayBuffer>() {
|
||||
js_sys::Uint8Array::new(&bytes).to_vec()
|
||||
} else {
|
||||
return;
|
||||
};
|
||||
if let Ok(mut q) = inbox.lock() {
|
||||
q.push((name, bytes));
|
||||
}
|
||||
ctx.request_repaint();
|
||||
});
|
||||
if self.window.add_event_listener_with_callback("message", on_message.as_ref().unchecked_ref()).is_ok() {
|
||||
// The listener lives as long as the page.
|
||||
on_message.forget();
|
||||
}
|
||||
}
|
||||
|
||||
pub fn save_fn(&self) -> pdfcraft_ui_egui::HostSaveFn {
|
||||
let bridge = self.clone();
|
||||
Box::new(move |name: &str, bytes: &[u8], save_as: bool| {
|
||||
let buffer = js_sys::Uint8Array::from(bytes).buffer();
|
||||
let m =
|
||||
message(&[("type", "pdfcraft:save".into()), ("name", name.into()), ("saveAs", save_as.into()), ("bytes", buffer.clone().into())])
|
||||
.ok_or_else(|| "couldn't build the message".to_string())?;
|
||||
bridge.post(&m, Some(&buffer))
|
||||
})
|
||||
let host = self.host.clone();
|
||||
Box::new(move |name: &str, bytes: &[u8], save_as: bool| host.post_save(name, bytes, None, save_as))
|
||||
}
|
||||
|
||||
pub fn dirty_fn(&self) -> pdfcraft_ui_egui::HostDirtyFn {
|
||||
let bridge = self.clone();
|
||||
let host = self.host.clone();
|
||||
// A host starts out clean: the first report is a change only when it is dirty.
|
||||
let mut last = false;
|
||||
Box::new(move |dirty: bool| {
|
||||
if dirty != last {
|
||||
last = dirty;
|
||||
if let Some(m) = message(&[("type", "pdfcraft:dirty".into()), ("dirty", dirty.into())]) {
|
||||
let _ = bridge.post(&m, None);
|
||||
}
|
||||
host.post_dirty(dirty);
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
fn message(fields: &[(&str, JsValue)]) -> Option<js_sys::Object> {
|
||||
let m = js_sys::Object::new();
|
||||
for (key, value) in fields {
|
||||
js_sys::Reflect::set(&m, &JsValue::from_str(key), value).ok()?;
|
||||
/// The `pdfcraft:open` payload, `{ name, bytes }` where `bytes` holds a PDF: queue it like a
|
||||
/// `?file=` download and wake the app. The crate has cleaned the name (and cut it to 255
|
||||
/// characters); a blank one comes as `document` and becomes `document.pdf`.
|
||||
fn queue_open(name: &str, data: &FileData, inbox: &pdfcraft_ui_egui::Inbox, ctx: &egui::Context) {
|
||||
let name = if name == "document" { DEFAULT_NAME } else { name }.to_string();
|
||||
let FileData::Bytes(view) = data else {
|
||||
eframe::web_sys::console::warn_1(&format!("PdfCraft: host file {name} came as a File; PdfCraft reads bytes only; not opened").into());
|
||||
return;
|
||||
};
|
||||
// A file from the host is untrusted input: refuse an oversized one before copying it into the app's memory.
|
||||
if data.size() > MAX_OPEN_BYTES as f64 {
|
||||
eframe::web_sys::console::warn_1(
|
||||
&format!("PdfCraft: host file is {} bytes, over the {} MiB host open limit; not opened", data.size(), MAX_OPEN_BYTES >> 20).into(),
|
||||
);
|
||||
return;
|
||||
}
|
||||
Some(m)
|
||||
}
|
||||
|
||||
fn string(data: &JsValue, key: &str) -> Option<String> {
|
||||
js_sys::Reflect::get(data, &JsValue::from_str(key)).ok()?.as_string()
|
||||
if let Ok(mut q) = inbox.lock() {
|
||||
q.push((name, view.to_vec()));
|
||||
}
|
||||
ctx.request_repaint();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -183,3 +183,6 @@ sync_github_name = true
|
||||
|
||||
[people.Combiler01]
|
||||
sync_github_name = true
|
||||
|
||||
[people.helenkwok]
|
||||
real_name = "Helen Kwok"
|
||||
@@ -492,6 +492,10 @@ pub struct DocView {
|
||||
pub forward: Vec<usize>,
|
||||
/// Pending navigation: page and fraction down the page to align with the viewport top.
|
||||
pub goto: Option<(usize, f32)>,
|
||||
/// With `goto`: → / ← turned the page at an edge, so a page or spread wider than the window
|
||||
/// starts at the edge the reader comes from (`false`: its left, after →; `true`: its right,
|
||||
/// after ←) instead of keeping the column.
|
||||
pub(crate) goto_side: Option<bool>,
|
||||
/// Pending position within that page from a destination ([`Self::go_to_dest`]): the point
|
||||
/// (fractions of the displayed page; `None` leaves that axis to `goto`) to put `rel` points
|
||||
/// from the viewport's top-left corner.
|
||||
@@ -499,6 +503,12 @@ pub struct DocView {
|
||||
/// Pending keyboard scrolling, in points down (negative: up): ↓ / ↑, and Page Down /
|
||||
/// Page Up where the pages scroll.
|
||||
pub key_scroll: f32,
|
||||
/// Pending keyboard scrolling sideways, in points right (negative: left): → / ← on a page
|
||||
/// wider than the window.
|
||||
pub key_side: f32,
|
||||
/// As last drawn: whether the view was at its left and at its right edge (both when the
|
||||
/// pages fit across), so → / ← turn the page there and scroll sideways elsewhere.
|
||||
side_edges: Option<(bool, bool)>,
|
||||
/// Single-page view, as last drawn: the page shown and whether the view was at its top and
|
||||
/// at its bottom, so ↑ / ↓ turn the page there instead of doing nothing (#273).
|
||||
single_edges: Option<(usize, bool, bool)>,
|
||||
@@ -928,8 +938,11 @@ impl DocView {
|
||||
back: Vec::new(),
|
||||
forward: Vec::new(),
|
||||
goto: None,
|
||||
goto_side: None,
|
||||
goto_point: None,
|
||||
key_scroll: 0.0,
|
||||
key_side: 0.0,
|
||||
side_edges: None,
|
||||
single_edges: None,
|
||||
flash: None,
|
||||
compare_marks: Vec::new(),
|
||||
@@ -1162,6 +1175,7 @@ struct PageEnv<'a> {
|
||||
info: &'a DocInfo,
|
||||
initials: &'a Option<crate::fill_sign::SavedSig>,
|
||||
key_scroll: f32,
|
||||
key_side: f32,
|
||||
middle_gesture: bool,
|
||||
modal: bool,
|
||||
navigation_enabled: bool,
|
||||
@@ -1211,6 +1225,9 @@ struct PageOut {
|
||||
page_action: Option<PageMenuAction>,
|
||||
primary_down: bool,
|
||||
refused: Option<String>,
|
||||
/// The current page or spread where it is drawn, every page of it: one off screen (the second
|
||||
/// page of a zoomed spread) has no screen transform, but still decides where the row ends.
|
||||
row_on_screen: Option<Rect>,
|
||||
stamp_placed: bool,
|
||||
}
|
||||
|
||||
@@ -1597,6 +1614,7 @@ fn draw_pages(
|
||||
hand,
|
||||
info,
|
||||
key_scroll,
|
||||
key_side,
|
||||
middle_gesture,
|
||||
modal,
|
||||
navigation_enabled,
|
||||
@@ -1626,7 +1644,7 @@ fn draw_pages(
|
||||
// ScrollArea ignores wheel input during a widget drag. Defer the wheel contribution
|
||||
// until after the text widgets when a selection owns that drag.
|
||||
let wheel_delta = if selection_wheel { Vec2::ZERO } else { precise_scroll + coarse_scroll };
|
||||
let delta = auto_delta + edge_delta - vec2(0.0, key_scroll) + wheel_delta + glide;
|
||||
let delta = auto_delta + edge_delta - vec2(key_side, key_scroll) + wheel_delta + glide;
|
||||
if delta != Vec2::ZERO && delta.is_finite() {
|
||||
ui.scroll_with_delta_animation(delta, egui::style::ScrollAnimation::none());
|
||||
}
|
||||
@@ -1779,6 +1797,7 @@ fn draw_pages(
|
||||
page_action: _,
|
||||
primary_down: _,
|
||||
refused,
|
||||
row_on_screen: _,
|
||||
stamp_placed,
|
||||
} = page_out;
|
||||
let mut page_action: Option<PageMenuAction> = None;
|
||||
@@ -1831,6 +1850,9 @@ fn draw_pages(
|
||||
if view.continuous {
|
||||
view.current = current;
|
||||
}
|
||||
// The row as it is now: in continuous view the current page may have changed above.
|
||||
let row_on_screen =
|
||||
shown_row(view, env.rects).iter().filter_map(|&i| env.rects.get(i)).map(|r| r.translate(origin.to_vec2())).reduce(Rect::union);
|
||||
resp.context_menu(|ui| {
|
||||
let is_crop_over_image = tool == QuickTool::EditText
|
||||
&& view.image_selection.clone().is_some_and(|sel| {
|
||||
@@ -2006,6 +2028,7 @@ fn draw_pages(
|
||||
page_action,
|
||||
primary_down: ptr.primary_down,
|
||||
refused,
|
||||
row_on_screen,
|
||||
stamp_placed,
|
||||
};
|
||||
(wanted, visible_now)
|
||||
@@ -2468,6 +2491,7 @@ fn page_scroll_area(ui: &egui::Ui, view: &mut DocView, geometry: &ScrollGeometry
|
||||
let point = pos2(r.left() + fx * r.width(), r.top() - y_shift + fy * r.height());
|
||||
scroll = scroll.scroll_offset(anchored_scroll(point, rel, vec2(content_w, content_h), avail.size()));
|
||||
} else if let Some((page, frac)) = view.goto.take() {
|
||||
let side = view.goto_side.take();
|
||||
let page = page.min(rects.len() - 1);
|
||||
let r = rects[page];
|
||||
// Clamped here: egui clamps only after drawing, so a page that fits was drawn MARGIN - page_gap()
|
||||
@@ -2483,7 +2507,27 @@ fn page_scroll_area(ui: &egui::Ui, view: &mut DocView, geometry: &ScrollGeometry
|
||||
// quick-action bar as the layout does.
|
||||
let (x, w) = (view.last_scroll.x, avail.width());
|
||||
let (left, right) = (r.left() - SIDE, r.right() + SIDE);
|
||||
let target = if right - left <= w { if left >= x && right <= x + w { x } else { r.center().x - w / 2.0 } } else { x.clamp(left, right - w) };
|
||||
// Turned by → / ← at an edge (`goto_side`): a page or spread wider than the window starts at
|
||||
// the edge the reader comes from.
|
||||
let row = shown_row(view, rects).iter().filter_map(|&i| rects.get(i)).copied().reduce(Rect::union).unwrap_or(r);
|
||||
let (row_left, row_right) = (row.left() - SIDE, row.right() + SIDE);
|
||||
let target = match side {
|
||||
Some(from_right) if row_right - row_left > w => {
|
||||
if from_right {
|
||||
row_right - w
|
||||
} else {
|
||||
row_left
|
||||
}
|
||||
}
|
||||
_ if right - left <= w => {
|
||||
if left >= x && right <= x + w {
|
||||
x
|
||||
} else {
|
||||
r.center().x - w / 2.0
|
||||
}
|
||||
}
|
||||
_ => x.clamp(left, right - w),
|
||||
};
|
||||
if (target - x).abs() > 0.5 {
|
||||
scroll = scroll.horizontal_scroll_offset(target.clamp(0.0, (content_w - w).max(0.0)));
|
||||
}
|
||||
@@ -2733,6 +2777,7 @@ struct GlideCx {
|
||||
now: f64,
|
||||
may_glide: bool,
|
||||
key_scroll: f32,
|
||||
key_side: f32,
|
||||
auto_delta: Vec2,
|
||||
zoom_delta: f32,
|
||||
edge_delta: Vec2,
|
||||
@@ -2746,11 +2791,11 @@ struct GlideCx {
|
||||
/// automatic flash uses `goto`, not these deltas, so its programmatic scroll is never mistaken for
|
||||
/// a reader action. Returns this frame's glide.
|
||||
fn step_glide(ui: &egui::Ui, view: &mut DocView, avail: Rect, cx: &GlideCx) -> Vec2 {
|
||||
let GlideCx { now, may_glide, key_scroll, auto_delta, zoom_delta, edge_delta, wheel_scrolled } = *cx;
|
||||
let GlideCx { now, may_glide, key_scroll, key_side, auto_delta, zoom_delta, edge_delta, wheel_scrolled } = *cx;
|
||||
let over_page = ui.rect_contains_pointer(avail);
|
||||
ui.input(|i| view.fling.read(&i.events, now, may_glide && over_page));
|
||||
let interrupted = glide_interrupted(ui);
|
||||
if !may_glide || interrupted || key_scroll != 0.0 || auto_delta != Vec2::ZERO || zoom_delta != 1.0 {
|
||||
if !may_glide || interrupted || key_scroll != 0.0 || key_side != 0.0 || auto_delta != Vec2::ZERO || zoom_delta != 1.0 {
|
||||
view.fling.stop();
|
||||
}
|
||||
let glide = view.fling.step(now);
|
||||
@@ -2832,7 +2877,8 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) {
|
||||
};
|
||||
// Keyboard scrolling applies to this frame's page view only, never later.
|
||||
let key_scroll = std::mem::take(&mut app.views[index].key_scroll);
|
||||
if key_scroll != 0.0 {
|
||||
let key_side = std::mem::take(&mut app.views[index].key_side);
|
||||
if key_scroll != 0.0 || key_side != 0.0 {
|
||||
app.views[index].end_find_following();
|
||||
}
|
||||
// The Search panel closed: its search moves to the find bar.
|
||||
@@ -2932,7 +2978,7 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) {
|
||||
read_wheel(ui, view, avail, single_paging, edge_turned, wheel_enabled, animates);
|
||||
let may_glide = !paging_owns_vertical && !middle_gesture && navigation_enabled && unobstructed && animates;
|
||||
let wheel_scrolled = precise_scroll != Vec2::ZERO || line_base != Vec2::ZERO || coarse_scroll != Vec2::ZERO || has_page;
|
||||
let motion = GlideCx { now, may_glide, key_scroll, auto_delta, zoom_delta, edge_delta, wheel_scrolled };
|
||||
let motion = GlideCx { now, may_glide, key_scroll, key_side, auto_delta, zoom_delta, edge_delta, wheel_scrolled };
|
||||
let glide = step_glide(ui, view, avail, &motion);
|
||||
// The wheel may have turned the page above: draw the row it turned to. Measured from the old
|
||||
// row, the jump to the new page scrolled both out of view, a blank frame on every turn forward.
|
||||
@@ -3039,6 +3085,7 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) {
|
||||
info,
|
||||
initials: &initials,
|
||||
key_scroll,
|
||||
key_side,
|
||||
middle_gesture,
|
||||
modal,
|
||||
navigation_enabled,
|
||||
@@ -3089,6 +3136,7 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) {
|
||||
page_action,
|
||||
primary_down,
|
||||
mut refused,
|
||||
row_on_screen,
|
||||
stamp_placed,
|
||||
} = page_out;
|
||||
view.single_edges = visible_pages.first().copied().filter(|_| !view.continuous).map(|page| {
|
||||
@@ -3098,6 +3146,15 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) {
|
||||
let max_y = (out.content_size.y - out.inner_rect.height()).max(0.0);
|
||||
(page, out.state.clamped_offset().y <= MARGIN - view.page_gap() + 1.0, out.state.clamped_offset().y >= max_y - 1.0)
|
||||
});
|
||||
// Within a point, as above: at an edge of the scrolled content, or of the current page or
|
||||
// spread shown whole that way (in continuous view the content is as wide as the widest page,
|
||||
// and a narrower current page shows whole inside it).
|
||||
let max_x = (out.content_size.x - out.inner_rect.width()).max(0.0);
|
||||
let inner = out.inner_rect;
|
||||
view.side_edges = Some((
|
||||
out.state.clamped_offset().x <= 1.0 || row_on_screen.is_some_and(|r| r.left() >= inner.left() - 1.0),
|
||||
out.state.clamped_offset().x >= max_x - 1.0 || row_on_screen.is_some_and(|r| r.right() <= inner.right() + 1.0),
|
||||
));
|
||||
|
||||
view.viewport_screen = out.inner_rect;
|
||||
view.auto_scroll.paint(ui, avail);
|
||||
|
||||
@@ -424,14 +424,39 @@ pub fn shortcuts(view: &mut DocView, ctx: &egui::Context, speed: crate::scroll_s
|
||||
}
|
||||
}
|
||||
// As in Acrobat: → / ← go to the next / previous page in every layout (#185), and so do
|
||||
// ⌘Page Down / ⌘Page Up, or plain Page Down / Page Up in single-page view.
|
||||
// ⌘Page Down / ⌘Page Up, or plain Page Down / Page Up in single-page view. On a page wider
|
||||
// than the window (a plan zoomed in) → / ← scroll sideways a line first, and turn the page
|
||||
// once there is nothing left to scroll that way, as ↓ / ↑ do in single-page view; ⌘→ / ⌘←
|
||||
// turn it at once.
|
||||
let command = ctx.input(|i| i.modifiers.command);
|
||||
let scrolls = view.continuous;
|
||||
if key(Key::ArrowRight) || (key(Key::PageDown) && (command || !scrolls)) {
|
||||
let line = KEY_SCROLL_LINE * speed.factor();
|
||||
// Only an up-to-date view counts: no jump pending.
|
||||
let sideways = |view: &DocView, right: bool| {
|
||||
!command && view.goto.is_none() && view.side_edges.is_some_and(|(left_edge, right_edge)| !if right { right_edge } else { left_edge })
|
||||
};
|
||||
// Turned at an edge, the next page starts at its left and the previous one at its right, as
|
||||
// reading on across: on two wide pages → doesn't turn twice without showing the next one's left.
|
||||
let at_edge = |view: &mut DocView, right: bool| {
|
||||
if !command && view.goto.is_some() {
|
||||
view.goto_side = Some(!right);
|
||||
}
|
||||
};
|
||||
if key(Key::ArrowRight) && sideways(view, true) {
|
||||
view.key_side += line;
|
||||
} else if key(Key::ArrowRight) || (key(Key::PageDown) && (command || !scrolls)) {
|
||||
view.step_page(true);
|
||||
if key(Key::ArrowRight) {
|
||||
at_edge(view, true);
|
||||
}
|
||||
}
|
||||
if key(Key::ArrowLeft) || (key(Key::PageUp) && (command || !scrolls)) {
|
||||
if key(Key::ArrowLeft) && sideways(view, false) {
|
||||
view.key_side -= line;
|
||||
} else if key(Key::ArrowLeft) || (key(Key::PageUp) && (command || !scrolls)) {
|
||||
view.step_page(false);
|
||||
if key(Key::ArrowLeft) {
|
||||
at_edge(view, false);
|
||||
}
|
||||
}
|
||||
// ↓ / ↑ scroll a line, and Page Down / Page Up a screen where the pages scroll. In
|
||||
// single-page view, ↓ / ↑ turn the page once there is nothing left to scroll that way, as
|
||||
@@ -439,7 +464,6 @@ pub fn shortcuts(view: &mut DocView, ctx: &egui::Context, speed: crate::scroll_s
|
||||
// The Scroll speed preference sets how far ↓ / ↑ go as well as the wheel (#985); Page Down /
|
||||
// Page Up keep a screen.
|
||||
if !command {
|
||||
let line = KEY_SCROLL_LINE * speed.factor();
|
||||
let screen = (view.viewport_h - KEY_SCROLL_LINE).max(KEY_SCROLL_LINE);
|
||||
let steps = [(Key::ArrowDown, line), (Key::ArrowUp, -line), (Key::PageDown, screen), (Key::PageUp, -screen)];
|
||||
for (k, by) in steps {
|
||||
|
||||
@@ -1484,6 +1484,138 @@ fn up_and_down_turn_pages_in_single_page_view() {
|
||||
assert_eq!(press(&mut h, Key::ArrowDown, 1), 0, "scrolled up from the bottom of page 1");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn left_and_right_scroll_a_page_wider_than_the_window_before_turning_it() {
|
||||
// A plan zoomed in: → / ← turned the page (#185) and so never showed its other side; the
|
||||
// view moved sideways only by the scroll bar, the wheel or the Hand.
|
||||
use egui::{Key, Modifiers};
|
||||
let mut h = harness();
|
||||
h.state_mut().active = Some(0);
|
||||
h.state_mut().views[0].set_zoom(12.0);
|
||||
h.run_steps(6);
|
||||
let press = |h: &mut Harness<'static, PdfCraftApp>, modifiers, key| {
|
||||
h.key_press_modifiers(modifiers, key);
|
||||
h.run_steps(4);
|
||||
let v = &h.state().views[0];
|
||||
(v.current, v.page_screen_rect(v.current).map(|r| r.left()))
|
||||
};
|
||||
let start = h.state().views[0].page_screen_rect(0).map(|r| r.left()).expect("on screen");
|
||||
let (page, left) = press(&mut h, Modifiers::NONE, Key::ArrowRight);
|
||||
assert_eq!(page, 0, "→ scrolls the wide page rather than turning it");
|
||||
assert!(left.is_some_and(|l| l < start - 10.0), "the page moved left: {left:?} from {start}");
|
||||
// At its right edge the next → turns the page.
|
||||
let mut n = 1;
|
||||
while press(&mut h, Modifiers::NONE, Key::ArrowRight).0 == 0 {
|
||||
n += 1;
|
||||
assert!(n < 500, "→ never reached the right edge");
|
||||
}
|
||||
assert!(n > 3, "→ scrolled across page 1 before turning ({n} presses)");
|
||||
assert_eq!(h.state().views[0].current, 1);
|
||||
// Page 2 starts at its left edge, as reading on across: → scrolls it rather than turning
|
||||
// again at once.
|
||||
let shown = |h: &Harness<'static, PdfCraftApp>, page: usize| {
|
||||
let v = &h.state().views[0];
|
||||
(v.page_screen_rect(page).expect("on screen"), v.viewport_rect())
|
||||
};
|
||||
let (r, view) = shown(&h, 1);
|
||||
assert!(r.left() >= view.left() && r.right() > view.right(), "page 2 from its left edge: {r:?} in {view:?}");
|
||||
assert_eq!(press(&mut h, Modifiers::NONE, Key::ArrowRight).0, 1, "→ scrolls page 2 from its left edge");
|
||||
// ← goes back to that edge and turns back: page 1 comes at its right edge, so the next ← scrolls it.
|
||||
let mut n = 1;
|
||||
while press(&mut h, Modifiers::NONE, Key::ArrowLeft).0 == 1 {
|
||||
n += 1;
|
||||
assert!(n < 50, "← never turned back from page 2");
|
||||
}
|
||||
assert_eq!(h.state().views[0].current, 0);
|
||||
let (r, view) = shown(&h, 0);
|
||||
assert!(r.right() <= view.right() && r.left() < view.left(), "page 1 from its right edge: {r:?} in {view:?}");
|
||||
assert_eq!(press(&mut h, Modifiers::NONE, Key::ArrowLeft).0, 0, "← scrolls page 1 from its right edge");
|
||||
// ⌘→ turns at once.
|
||||
assert_eq!(press(&mut h, Modifiers::COMMAND, Key::ArrowRight).0, 1, "⌘→ turns the page at once");
|
||||
assert_eq!(press(&mut h, Modifiers::COMMAND, Key::ArrowRight).0, 2);
|
||||
// A page that fits across: → turns pages as before.
|
||||
h.state_mut().views[0].fit = pdfcraft_ui_egui::canvas::Fit::Width;
|
||||
h.run_steps(6);
|
||||
assert_eq!(press(&mut h, Modifiers::NONE, Key::ArrowRight).0, 3);
|
||||
}
|
||||
|
||||
/// Continuous view with pages of different widths: the content is as wide as the widest page, but
|
||||
/// on a narrow page that shows whole → turns the page; on the wide one it scrolls first.
|
||||
#[test]
|
||||
fn left_and_right_follow_the_current_pages_width_in_continuous_view() {
|
||||
use egui::Key;
|
||||
let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).build_eframe(|_cc| {
|
||||
let mut app = PdfCraftApp::new();
|
||||
app.set_option("language", "en").unwrap();
|
||||
app.open_bytes("mixed.pdf", None, sized_fixture(&[(200, 1200), (3000, 1200), (200, 1200)])).unwrap();
|
||||
app
|
||||
});
|
||||
crate::support::settle(&mut h);
|
||||
h.state_mut().active = Some(0);
|
||||
assert!(h.state().views[0].continuous);
|
||||
// Each page taller than the window, so one is the current page.
|
||||
h.state_mut().views[0].set_zoom(1.0);
|
||||
h.run_steps(6);
|
||||
h.state_mut().views[0].go_to_page(0);
|
||||
h.run_steps(6);
|
||||
let press = |h: &mut Harness<'static, PdfCraftApp>| {
|
||||
h.key_press(Key::ArrowRight);
|
||||
h.run_steps(4);
|
||||
let v = &h.state().views[0];
|
||||
(v.current, v.page_screen_rect(1).map(|r| r.left()))
|
||||
};
|
||||
assert_eq!(h.state().views[0].current, 0);
|
||||
let (page, start) = press(&mut h);
|
||||
assert_eq!(page, 1, "→ on the narrow page shown whole turns it");
|
||||
let (page, left) = press(&mut h);
|
||||
assert_eq!(page, 1, "→ on the wide page scrolls it");
|
||||
assert!(left.zip(start).is_some_and(|(l, s)| l < s - 10.0), "the wide page moved left: {left:?} from {start:?}");
|
||||
}
|
||||
|
||||
/// A zoomed two-page spread scrolled so its first page ends inside the window and the second is
|
||||
/// still wholly to the right of it: → scrolls on to the second page rather than turning the
|
||||
/// spread. (Read from the pages on screen alone, the spread seemed to end with its first page.)
|
||||
#[test]
|
||||
fn right_scrolls_to_a_spreads_second_page_off_screen_before_turning() {
|
||||
use egui::Key;
|
||||
// The page gap (points on screen).
|
||||
const GAP: f32 = 14.0;
|
||||
let build = |zoom: f32| {
|
||||
let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).build_eframe(move |_cc| {
|
||||
let mut app = PdfCraftApp::new();
|
||||
app.set_option("language", "en").unwrap();
|
||||
app.open_bytes("spread.pdf", None, sized_fixture(&[(600, 800); 4])).unwrap();
|
||||
app.set_option("layout", "two-page").unwrap();
|
||||
// Before the first layout: zoomed about the content's top left, scrolled to it.
|
||||
app.views[0].set_zoom(zoom);
|
||||
app
|
||||
});
|
||||
crate::support::settle(&mut h);
|
||||
h.state_mut().active = Some(0);
|
||||
h.run_steps(4);
|
||||
h
|
||||
};
|
||||
let shown =
|
||||
|h: &Harness<'static, PdfCraftApp>| (h.state().views[0].viewport_rect(), h.state().views[0].page_screen_rect(0).expect("page 1 on screen"));
|
||||
// Zoom until page 1's right edge sits half a gap inside the window's.
|
||||
let mut zoom = 2.0;
|
||||
let mut h = build(zoom);
|
||||
for _ in 0..3 {
|
||||
let (inner, first) = shown(&h);
|
||||
zoom *= (inner.right() - GAP / 2.0 - first.left()) / first.width();
|
||||
h = build(zoom);
|
||||
}
|
||||
let (inner, first) = shown(&h);
|
||||
assert!(first.right() <= inner.right() && first.right() + GAP > inner.right(), "the spread's gap at the window's edge: {first:?} in {inner:?}");
|
||||
assert!(h.state().views[0].page_screen_rect(1).is_none(), "its second page off screen");
|
||||
assert!(h.state().views[0].layout == pdfcraft_ui_egui::canvas::PageLayout::TwoUp && !h.state().views[0].continuous);
|
||||
h.key_press(Key::ArrowRight);
|
||||
h.run_steps(4);
|
||||
let v = &h.state().views[0];
|
||||
assert!(v.current < 2, "→ doesn't turn the spread: page {}", v.current + 1);
|
||||
assert!(v.page_screen_rect(1).is_some(), "it scrolls the second page into view");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn v_h_and_space_pick_the_quick_tools() {
|
||||
// The toolbar's tooltips promise "Select (V)" and "Hand (H)", but the keys did nothing.
|
||||
|
||||
+3
-2
@@ -1,6 +1,6 @@
|
||||
# UI and interaction parity
|
||||
|
||||
> **Last reviewed:** 2026-10-21 · **Last updated:** 2026-10-21 · **Change:** minor (Pages inspector thumbnail size slider and buttons #3808; full screen presents one page at a time #1459; the selected field nudges with the arrow keys in Prepare a form #2608) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS)
|
||||
> **Last reviewed:** 2026-10-21 · **Last updated:** 2026-10-24 · **Change:** minor (→ / ← scroll a page wider than the window before turning it) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS)
|
||||
|
||||
How PdfCraft's tools, panels, shortcuts and feel compare with Acrobat Pro's current interface (the
|
||||
2023+ "new Acrobat": All tools pane on the left, quick-action toolbar, Home view). Behaviour is
|
||||
@@ -44,7 +44,7 @@ No dump of Acrobat's menu tree has been diffed against the registry yet (gap 24)
|
||||
| Zoom: wheel, pinch, keys, marquee, fit page/width/height/visible | yes | yes | shipped |
|
||||
| Fixed zoom percentages match Acrobat's physical size | yes | differ (#739, documented in #736) | gap |
|
||||
| Dynamic zoom, loupe, pan-and-zoom window | yes | no | planned |
|
||||
| Hand tool, middle-button pan, autoscroll | yes | hand tool; Linux autoscroll | partial |
|
||||
| Hand tool, middle-button pan, autoscroll | yes | hand tool; Linux autoscroll; → / ← scroll a page wider than the window before turning it | partial |
|
||||
| Text selection, column select | yes, Alt-drag columns | reading-order selection; Alt/Option-drag or Edit ▸ Column select (#740) | shipped |
|
||||
| Select and move several objects at once | yes | on one page: Shift/Ctrl/⌘-click and marquee, one undo step (#844); not across pages, no group resize | partial |
|
||||
| Moved comments drawn where they go while dragged | yes | yes: the comment's own pixels follow the pointer, the page shows it where it was until release; stamps and Fill & Sign marks also hide the original | shipped |
|
||||
@@ -83,6 +83,7 @@ No dump of Acrobat's menu tree has been diffed against the registry yet (gap 24)
|
||||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-10-24 | minor | → / ← scroll a page wider than the window sideways before turning it, by the current page's or spread's edges in continuous view; ⌘→ / ⌘← turn at once |
|
||||
| 2026-10-21 | minor | Pages inspector thumbnail sizing (#3808), separate from page and Organize zoom; view setting retained on reload and exposed through UI control. Not persisted between sessions. |
|
||||
| 2026-10-15 | minor | A selected field nudges with the arrow keys in Prepare a form: a point, Shift ten points, one undo step per press (#2608) |
|
||||
| 2026-10-14 | minor | Full screen shows one fitted page (or spread) at a time on black with click and key paging, as Acrobat's Full Screen Mode (#379, #1459, #1353) |
|
||||
|
||||
@@ -78,6 +78,64 @@ Local test: `python3 -m http.server 8765` inside the folder, then open http://lo
|
||||
downloads) are blocked.
|
||||
- Don't send `X-Frame-Options: DENY` or a `frame-ancestors` CSP that excludes the embedding page.
|
||||
|
||||
## Same-document host mode (no iframe)
|
||||
|
||||
A host application can also run PdfCraft from its own document: it loads the wasm-bindgen module
|
||||
itself and gives PdfCraft one of its own canvases, so there is no iframe and no `?host=parent`.
|
||||
It opts in with `data-host="page"` on that canvas — the exact value `page` — and the canvas
|
||||
keeps the id the app looks up (`id="pdfcraft"`).
|
||||
|
||||
The page and the app then talk with DOM `CustomEvent`s on the canvas instead of `postMessage`.
|
||||
The app dispatches the same messages it would post to a parent — `pdfcraft:ready`,
|
||||
`pdfcraft:save`, `pdfcraft:dirty`, `pdfcraft:failed` — with the message fields (without the
|
||||
`type`) as the event's `detail`, and it opens a file when the page dispatches a `pdfcraft:open`
|
||||
event with `detail: { name, bytes }`. Events do not bubble, so listen on the canvas itself.
|
||||
|
||||
Add the listeners before `init`: the app announces itself with `pdfcraft:ready` while `init` is
|
||||
still running, and a file sent in reply would otherwise be missed.
|
||||
|
||||
```js
|
||||
import init from "./pdfcraft-web-<hash>.js"; // the wasm-bindgen glue shipped with the app
|
||||
|
||||
const canvas = document.getElementById("pdfcraft");
|
||||
canvas.dataset.host = "page"; // or <canvas id="pdfcraft" data-host="page">
|
||||
|
||||
canvas.addEventListener("pdfcraft:ready", () => {
|
||||
// The app is listening; hand it a document now, if you have one.
|
||||
canvas.dispatchEvent(new CustomEvent("pdfcraft:open", {
|
||||
detail: { name: "handout.pdf", bytes: pdfBytes }, // ArrayBuffer or Uint8Array
|
||||
}));
|
||||
});
|
||||
canvas.addEventListener("pdfcraft:save", (event) => {
|
||||
const { name, saveAs, bytes } = event.detail; // bytes is an ArrayBuffer
|
||||
// Store the file under `name`; `saveAs` is true for Save As. Report your own errors.
|
||||
});
|
||||
canvas.addEventListener("pdfcraft:dirty", (event) => {
|
||||
unsaved = event.detail.dirty; // warn before leaving, as you like
|
||||
});
|
||||
canvas.addEventListener("pdfcraft:failed", (event) => {
|
||||
showError(event.detail.error); // the app couldn't start
|
||||
});
|
||||
|
||||
await init({ module_or_path: "./pdfcraft-web-<hash>_bg.wasm" });
|
||||
```
|
||||
|
||||
- Saves are not downloads in this mode: the `bytes` in `pdfcraft:save` hold the saved PDF, and
|
||||
the host decides where the file goes.
|
||||
- `?author=<name>` still signs the user's comments, as with `?host=parent`.
|
||||
- An unframed page that uses `?host=parent` keeps the normal app with downloads.
|
||||
`data-host="page"` turns this mode on only when `?host=parent` is absent: if `?host=parent` is
|
||||
present it takes precedence, so a same-origin framed page uses the parent bridge, while an
|
||||
unframed or cross-origin page gets no bridge and keeps normal downloads.
|
||||
|
||||
The messages and both transports are the shared contract of [`craft-web-host`](https://github.com/storytold/craft-libs/tree/main/crates/web-host) (storytold/craft-libs#420), the same for every Craft app: its README lists every message, including `saved`, with the compatibility notes. In `pdfcraft:ready`, `version` is the contract's (`"1"`) and `appVersion` is this app's release. PdfCraft reads bytes, so a host sends `bytes`, not a `File`.
|
||||
|
||||
### Trust and limits
|
||||
|
||||
- **Who is trusted.** In page host mode the page and PdfCraft share one document, so any script on the page can drive the app: open files in it and receive what it saves. Use this mode only for a page you control. The iframe mode (`?host=parent`) answers only the parent window, on the page's own origin.
|
||||
- **Files from the host are untrusted input.** An opened file goes through the same reading as one picked with File ▸ Open or dropped on the window, with the same format limits; before it is copied into the app's memory, its size is checked against the host open limit (512 MiB) and a larger file is refused, and a name is cut to 255 characters.
|
||||
- **The event and message names are the interface.** `pdfcraft:ready`, `pdfcraft:open`, `pdfcraft:save`, `pdfcraft:dirty` and `pdfcraft:failed`, and the fields they carry (the same as the `?host=parent` messages), stay stable; changing one is a breaking change.
|
||||
|
||||
## Renderer selection and fallback flags
|
||||
|
||||
PdfCraft renders with wgpu. It uses **WebGPU** when the browser has it and falls back to
|
||||
|
||||
@@ -1154,8 +1154,8 @@ milestone = "M3"
|
||||
status = "shipped"
|
||||
commands = []
|
||||
tools = []
|
||||
evidence = ["crates/ui-egui/tests/it/control.rs::state_and_view_options", "crates/ui-egui/tests/it/ui.rs::theme_and_view_options_apply", "crates/ui-egui/tests/it/viewer.rs::two_page_view_steps_a_spread_at_a_time", "crates/ui-egui/tests/it/viewer.rs::arrow_and_page_keys_move_through_a_scrolling_document"]
|
||||
notes = "in two-page view, next and previous page move a whole spread (with or without a cover page); → / ← turn pages in every layout, ↓ / ↑ scroll a line and Page Down / Page Up a screen (the page in single-page view), as in Acrobat; in single-page view ↓ / ↑ turn the page once there is nothing left to scroll that way, like the wheel"
|
||||
evidence = ["crates/ui-egui/tests/it/control.rs::state_and_view_options", "crates/ui-egui/tests/it/ui.rs::theme_and_view_options_apply", "crates/ui-egui/tests/it/viewer.rs::two_page_view_steps_a_spread_at_a_time", "crates/ui-egui/tests/it/viewer.rs::arrow_and_page_keys_move_through_a_scrolling_document", "crates/ui-egui/tests/it/viewer.rs::left_and_right_scroll_a_page_wider_than_the_window_before_turning_it"]
|
||||
notes = "in two-page view, next and previous page move a whole spread (with or without a cover page); → / ← turn pages in every layout (on a page wider than the window, a plan zoomed in, they scroll sideways a line first and turn the page at its edge; ⌘→ / ⌘← turn at once), ↓ / ↑ scroll a line and Page Down / Page Up a screen (the page in single-page view), as in Acrobat; in single-page view ↓ / ↑ turn the page once there is nothing left to scroll that way, like the wheel"
|
||||
|
||||
[[feature]]
|
||||
id = "view.goto-page-label"
|
||||
|
||||
Reference in new issue
Block a user