blitz-pdf 0.1.7 → 0.1.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (57) hide show
  1. package/README.md +44 -5
  2. package/THIRD_PARTY_NOTICES.md +1 -1
  3. package/dist/api.d.ts +2 -1
  4. package/dist/api.d.ts.map +1 -1
  5. package/dist/api.js +28 -3
  6. package/dist/api.js.map +1 -1
  7. package/dist/browser.d.ts +2 -1
  8. package/dist/browser.d.ts.map +1 -1
  9. package/dist/browser.js +1 -1
  10. package/dist/browser.js.map +1 -1
  11. package/dist/node.d.ts +2 -1
  12. package/dist/node.d.ts.map +1 -1
  13. package/dist/node.js +6 -2
  14. package/dist/node.js.map +1 -1
  15. package/dist/types.d.ts +14 -0
  16. package/dist/types.d.ts.map +1 -1
  17. package/dist/worker-host.d.ts +12 -5
  18. package/dist/worker-host.d.ts.map +1 -1
  19. package/dist/worker-host.js +187 -20
  20. package/dist/worker-host.js.map +1 -1
  21. package/dist/worker-protocol.d.ts +5 -0
  22. package/dist/worker-protocol.d.ts.map +1 -1
  23. package/dist/worker-runtime.d.ts +1 -1
  24. package/dist/worker-runtime.d.ts.map +1 -1
  25. package/dist/worker-runtime.js +22 -6
  26. package/dist/worker-runtime.js.map +1 -1
  27. package/licenses/RUST_DEPENDENCIES.txt +34 -40
  28. package/package.json +1 -1
  29. package/src/api.ts +29 -4
  30. package/src/browser.ts +2 -1
  31. package/src/node.ts +7 -2
  32. package/src/types.ts +15 -0
  33. package/src/worker-host.ts +176 -23
  34. package/src/worker-protocol.ts +5 -5
  35. package/src/worker-runtime.ts +20 -6
  36. package/vendor/README.md +19 -5
  37. package/vendor/blitz-dom/Cargo.toml +6 -6
  38. package/vendor/blitz-dom/src/accessibility.rs +175 -2
  39. package/vendor/blitz-dom/src/config.rs +8 -10
  40. package/vendor/blitz-dom/src/document.rs +66 -3
  41. package/vendor/blitz-dom/src/events/keyboard.rs +2 -6
  42. package/vendor/blitz-dom/src/layout/construct.rs +5 -300
  43. package/vendor/blitz-dom/src/layout/inline/build.rs +309 -0
  44. package/vendor/blitz-dom/src/layout/{inline.rs → inline/layout.rs} +1 -1
  45. package/vendor/blitz-dom/src/layout/inline/mod.rs +4 -0
  46. package/vendor/blitz-dom/src/layout/mod.rs +3 -1
  47. package/vendor/blitz-dom/src/layout/replaced.rs +13 -4
  48. package/vendor/blitz-dom/src/mutator.rs +47 -0
  49. package/vendor/blitz-dom/src/node/element.rs +17 -0
  50. package/vendor/blitz-dom/src/node/mod.rs +1 -0
  51. package/vendor/blitz-dom/src/node/node.rs +46 -5
  52. package/vendor/blitz-dom/src/node/text.rs +11 -11
  53. package/vendor/blitz-dom/src/resolve.rs +2 -2
  54. package/vendor/blitz-dom/src/stylo.rs +30 -22
  55. package/vendor/blitz-dom/src/util.rs +1 -1
  56. package/vendor/blitz-paint/Cargo.toml +4 -4
  57. package/wasm/blitz_pdf_bg.wasm +2 -2
@@ -1,10 +1,10 @@
1
1
  import type { RenderOptions } from "./render.js";
2
2
 
3
3
  export type HostMessage =
4
- | { type: "render"; module: WebAssembly.Module; html: string; options: RenderOptions; external: boolean }
5
- | { type: "assets"; data: Uint8Array[] };
4
+ | { type: "render"; id: number; module: WebAssembly.Module; html: string; options: RenderOptions; external: boolean }
5
+ | { type: "assets"; id: number; data: Uint8Array[] };
6
6
 
7
7
  export type WorkerMessage =
8
- | { type: "assets"; urls: string[] }
9
- | { type: "done"; pdf: Uint8Array }
10
- | { type: "error"; error: unknown };
8
+ | { type: "assets"; id: number; urls: string[] }
9
+ | { type: "done"; id: number; pdf: Uint8Array }
10
+ | { type: "error"; id: number; error: unknown };
@@ -7,29 +7,43 @@ export interface WorkerEndpoint {
7
7
  onMessage(handler: (message: HostMessage) => void): void;
8
8
  }
9
9
 
10
- /** Worker side of `renderPdfInWorker`: one render per worker; asset bytes come from the host. */
10
+ /** One document at a time; retain the initialized engine and its bundled-font cache. */
11
11
  export function serve(endpoint: WorkerEndpoint): void {
12
+ let initialized = false;
13
+ let activeId: number | undefined;
12
14
  let receiveAssets: ((data: Uint8Array[]) => void) | undefined;
13
15
  endpoint.onMessage(async message => {
14
16
  if (message.type === "assets") {
17
+ if (message.id !== activeId) return;
15
18
  const receive = receiveAssets;
16
19
  receiveAssets = undefined;
17
20
  receive?.(message.data);
18
21
  return;
19
22
  }
23
+ if (activeId !== undefined) {
24
+ endpoint.post({ type: "error", id: message.id, error: new Error("Worker already has an active document") }, []);
25
+ return;
26
+ }
27
+ activeId = message.id;
20
28
  try {
21
- initSync({ module: message.module });
29
+ if (!initialized) {
30
+ initSync({ module: message.module });
31
+ initialized = true;
32
+ }
22
33
  const load = message.external
23
34
  ? (urls: string[]) => new Promise<Uint8Array[]>(resolve => {
24
35
  receiveAssets = resolve;
25
- endpoint.post({ type: "assets", urls }, []);
36
+ endpoint.post({ type: "assets", id: message.id, urls }, []);
26
37
  })
27
38
  : undefined;
28
39
  const pdf = await render(message.html, message.options, load);
29
- endpoint.post({ type: "done", pdf }, [pdf.buffer as ArrayBuffer]);
40
+ endpoint.post({ type: "done", id: message.id, pdf }, [pdf.buffer as ArrayBuffer]);
30
41
  } catch (error) {
31
- try { endpoint.post({ type: "error", error }, []); }
32
- catch { endpoint.post({ type: "error", error: new Error(String(error)) }, []); }
42
+ try { endpoint.post({ type: "error", id: message.id, error }, []); }
43
+ catch { endpoint.post({ type: "error", id: message.id, error: new Error(String(error)) }, []); }
44
+ } finally {
45
+ activeId = undefined;
46
+ receiveAssets = undefined;
33
47
  }
34
48
  });
35
49
  }
package/vendor/README.md CHANGED
@@ -1,6 +1,20 @@
1
+ # Upstream versions
2
+
3
+ Checked against upstream on 2026-10-21:
4
+
5
+ - Blitz DOM and paint: `0.3.0-beta.4`, revision
6
+ `f8723f495c7c6bfb611a923a21ed5c8e0fe95d55` from `DioxusLabs/blitz` main.
7
+ All sibling Blitz crates use the same revision and its Taffy pin
8
+ `4e59968eda29ceeb0bbaeb3993c8cd38baa3d0a6`.
9
+ - Stylo `0.22.0`, Krilla `0.8.2`, and Fontique `0.12.0` are the latest
10
+ published crates.io releases. Their sources were compared with the release
11
+ archives to confirm that only the patches and packaging files below differ.
12
+
1
13
  # Stylo paged-media patch
2
14
 
3
- `stylo/` is the unmodified crates.io `stylo` 0.22.0 source except for:
15
+ `stylo/` contains the crates.io `stylo` 0.22.0 source (original crate checksum
16
+ `7a255e46af2478628c48daab3774090caabd7812b1dd3ca98801c2b9c53491ed`),
17
+ with the following changes:
4
18
 
5
19
  - `properties/longhands.toml`: enable `break-before`, `break-after`, and
6
20
  `break-inside` and the `@page size` descriptor for Servo; add inherited
@@ -44,7 +58,7 @@ Node/browser checks, and `npm run test:pdfa` against the rebuilt WASM.
44
58
  # Blitz print layout patch
45
59
 
46
60
  `blitz-dom/` contains `packages/blitz-dom` from Blitz revision
47
- `e72650a6e67941852fddf025295cfee30c39ea32`, with its MIT/Apache-2.0 licenses.
61
+ `f8723f495c7c6bfb611a923a21ed5c8e0fe95d55`, with its MIT/Apache-2.0 licenses.
48
62
  Its standalone manifest expands the upstream workspace dependencies and pins
49
63
  the sibling Blitz crates to the same revision.
50
64
 
@@ -54,11 +68,11 @@ the sibling Blitz crates to the same revision.
54
68
  The PDF body and margin templates disable rounding and supply the exact
55
69
  printable dimensions, including after CSS page setup changes them. Layout,
56
70
  transforms, painting, and pagination share the resulting geometry.
57
- - `layout/construct.rs`: use baseline alignment for the root of an inline
71
+ - `layout/inline/build.rs`: use baseline alignment for the root of an inline
58
72
  formatting context. A table cell's `vertical-align: middle` aligns the cell
59
73
  content within its row; applying it again to its own text inflated every line.
60
74
  Descendant inline alignment and the cell's placement remain intact.
61
- - `layout/inline.rs`: let empty inline contexts compute their size and margin
75
+ - `layout/inline/layout.rs`: let empty inline contexts compute their size and margin
62
76
  collapse normally. The empty-text shortcut prevented margins from collapsing
63
77
  through paragraphs containing empty spans and discarded explicit heights.
64
78
  - `layout/table.rs`: keep `max-width` as an intrinsic measurement constraint,
@@ -79,7 +93,7 @@ table columns, anonymous cells, and inheritance after CSS page-size changes.
79
93
  # Blitz Unicode paint notifications
80
94
 
81
95
  `blitz-paint/` contains `packages/blitz-paint` from Blitz revision
82
- `e72650a6e67941852fddf025295cfee30c39ea32`, with its MIT/Apache-2.0 licenses.
96
+ `f8723f495c7c6bfb611a923a21ed5c8e0fe95d55`, with its MIT/Apache-2.0 licenses.
83
97
  Its standalone manifest expands the upstream workspace dependencies and pins
84
98
  sibling crates to the same revision.
85
99
 
@@ -2,7 +2,7 @@
2
2
  name = "blitz-dom"
3
3
  description = "Blitz DOM implementation"
4
4
  documentation = "https://docs.rs/blitz-dom"
5
- version = "0.3.0-beta.3"
5
+ version = "0.3.0-beta.4"
6
6
  license = "MIT OR Apache-2.0"
7
7
  homepage = "https://github.com/dioxuslabs/blitz"
8
8
  repository = "https://github.com/dioxuslabs/blitz"
@@ -45,9 +45,9 @@ log-phase-times = ["debug_timer/enable"]
45
45
 
46
46
  [dependencies]
47
47
  # Blitz dependencies
48
- blitz-traits = { version = "=0.3.0-beta.3", default-features = false, git = "https://github.com/DioxusLabs/blitz", rev = "e72650a6e67941852fddf025295cfee30c39ea32" }
49
- stylo_taffy = { version = "=0.3.0-beta.3", default-features = false, git = "https://github.com/DioxusLabs/blitz", rev = "e72650a6e67941852fddf025295cfee30c39ea32", features = ["default"] }
50
- debug_timer = { version = "0.1.2", git = "https://github.com/DioxusLabs/blitz", rev = "e72650a6e67941852fddf025295cfee30c39ea32" }
48
+ blitz-traits = { version = "=0.3.0-beta.4", default-features = false, git = "https://github.com/DioxusLabs/blitz", rev = "f8723f495c7c6bfb611a923a21ed5c8e0fe95d55" }
49
+ stylo_taffy = { version = "=0.3.0-beta.4", default-features = false, git = "https://github.com/DioxusLabs/blitz", rev = "f8723f495c7c6bfb611a923a21ed5c8e0fe95d55", features = ["default"] }
50
+ debug_timer = { version = "0.1.2", git = "https://github.com/DioxusLabs/blitz", rev = "f8723f495c7c6bfb611a923a21ed5c8e0fe95d55" }
51
51
 
52
52
  # Servo dependencies
53
53
  style = { version = "0.22.0", package = "stylo" }
@@ -64,7 +64,7 @@ smallvec = { version = "1" }
64
64
  thin-vec = { version = "0.2" }
65
65
 
66
66
  # DioxusLabs dependencies
67
- taffy = { git = "https://github.com/DioxusLabs/taffy", rev = "6c2106e3df79493d304f954dc2cd4a4a382981d3", default-features = false, features = ["std", "flexbox", "flexbox_balance", "grid", "block_layout", "content_size", "calc", "detailed_layout_info"] }
67
+ taffy = { git = "https://github.com/DioxusLabs/taffy", rev = "4e59968eda29ceeb0bbaeb3993c8cd38baa3d0a6", default-features = false, features = ["std", "flexbox", "flexbox_balance", "grid", "block_layout", "content_size", "calc", "detailed_layout_info"] }
68
68
  anyrender = { version = "0.14.0", optional = true }
69
69
 
70
70
  # Linebender/Fontations dependencies
@@ -100,7 +100,7 @@ percent-encoding = { version = "2.3.1" }
100
100
  url = { version = "2.5.0" }
101
101
 
102
102
  # Input
103
- keyboard-types = { version = "0.7" }
103
+ keyboard-types = { version = "0.8" }
104
104
  cursor-icon = { version = "1" }
105
105
  thread_local = { version = "1" }
106
106
 
@@ -1,4 +1,6 @@
1
- use crate::{BaseDocument, ElementData, Node as BlitzDomNode, local_name};
1
+ use crate::{
2
+ BaseDocument, ElementData, Node as BlitzDomNode, NodeData, NodeId as BlitzDomNodeId, local_name,
3
+ };
2
4
  use accesskit::{Node as AccessKitNode, NodeId, Role, TreeId, TreeInfo, TreeUpdate};
3
5
  use style::properties::longhands::visibility;
4
6
 
@@ -7,6 +9,8 @@ impl BaseDocument {
7
9
  let mut nodes = std::collections::HashMap::new();
8
10
  let mut window = AccessKitNode::new(Role::Window);
9
11
  let mut hidden_nodes = std::collections::HashSet::new();
12
+ let mut labelled_by_nodes = Vec::new();
13
+ let mut described_by_nodes = Vec::new();
10
14
 
11
15
  self.visit(|node_id, node| {
12
16
  if node.is_hidden_from_accessibility_tree()
@@ -25,9 +29,57 @@ impl BaseDocument {
25
29
  .unwrap_or(&mut window);
26
30
  let (id, builder) = self.build_accessibility_node(node, parent);
27
31
 
32
+ if node.attr(local_name!("aria-labelledby")).is_some() {
33
+ labelled_by_nodes.push(node_id);
34
+ }
35
+ if node.attr(local_name!("aria-describedby")).is_some() {
36
+ described_by_nodes.push(node_id);
37
+ }
28
38
  nodes.insert(node_id, (id, builder));
29
39
  });
30
40
 
41
+ let mut label = FlatText::default();
42
+ for node_id in labelled_by_nodes {
43
+ let Some(id_refs) = self.nodes[node_id].attr(local_name!("aria-labelledby")) else {
44
+ continue;
45
+ };
46
+ let Some((id, mut builder)) = nodes.remove(&node_id) else {
47
+ continue;
48
+ };
49
+ let mut has_target = false;
50
+ label.clear();
51
+ for target in self.referenced_dom_ids_to_node_ids(id_refs) {
52
+ if !has_target {
53
+ builder.clear_labelled_by();
54
+ has_target = true;
55
+ }
56
+ if target == node_id || nodes.contains_key(&target) {
57
+ builder.push_labelled_by(NodeId(target.as_u64()));
58
+ }
59
+ self.nodes[target].write_text_alternative(&mut label);
60
+ label.break_word();
61
+ }
62
+ if !label.text.is_empty() {
63
+ builder.set_label(label.text.as_str());
64
+ }
65
+ nodes.insert(node_id, (id, builder));
66
+ }
67
+
68
+ for node_id in described_by_nodes {
69
+ let Some(id_refs) = self.nodes[node_id].attr(local_name!("aria-describedby")) else {
70
+ continue;
71
+ };
72
+ let Some((id, mut builder)) = nodes.remove(&node_id) else {
73
+ continue;
74
+ };
75
+ for target in self.referenced_dom_ids_to_node_ids(id_refs) {
76
+ if target == node_id || nodes.contains_key(&target) {
77
+ builder.push_described_by(NodeId(target.as_u64()));
78
+ }
79
+ }
80
+ nodes.insert(node_id, (id, builder));
81
+ }
82
+
31
83
  let mut nodes: Vec<_> = nodes
32
84
  .into_iter()
33
85
  .map(|(_, (id, node))| (id, node))
@@ -43,6 +95,15 @@ impl BaseDocument {
43
95
  }
44
96
  }
45
97
 
98
+ fn referenced_dom_ids_to_node_ids<'a>(
99
+ &'a self,
100
+ nodes_reference: &'a str,
101
+ ) -> impl Iterator<Item = BlitzDomNodeId> + 'a {
102
+ nodes_reference
103
+ .split_ascii_whitespace()
104
+ .filter_map(|dom_id| self.nodes_to_id.get(dom_id)?.first().copied())
105
+ }
106
+
46
107
  fn build_accessibility_node(
47
108
  &self,
48
109
  node: &BlitzDomNode,
@@ -65,12 +126,48 @@ impl BaseDocument {
65
126
  .unwrap_or(Role::Unknown);
66
127
 
67
128
  builder.set_role(role);
68
- builder.set_html_tag(name);
69
129
 
70
130
  // https://www.w3.org/TR/wai-aria-1.2/#tree_exclusion
71
131
  if element_data.attr(local_name!("aria-hidden")) == Some("true") {
72
132
  builder.set_hidden();
73
133
  }
134
+
135
+ if let Some(aria_expanded) = element_data.attr(local_name!("aria-expanded"))
136
+ && let Ok(aria_expanded) = aria_expanded.parse()
137
+ {
138
+ builder.set_expanded(aria_expanded);
139
+ }
140
+
141
+ if element_data.attr(local_name!("aria-disabled")) == Some("true") {
142
+ builder.set_disabled();
143
+ }
144
+
145
+ if let Some(aria_selected) = element_data.attr(local_name!("aria-selected"))
146
+ && let Ok(aria_selected) = aria_selected.parse()
147
+ {
148
+ builder.set_selected(aria_selected);
149
+ }
150
+
151
+ if let Some(aria_label) = element_data.attr(local_name!("aria-label")) {
152
+ builder.set_label(aria_label);
153
+ }
154
+ if let Some(aria_description) = element_data.attr(local_name!("aria-description")) {
155
+ builder.set_description(aria_description);
156
+ }
157
+ if let Some(aria_level) = element_data.attr(local_name!("aria-level"))
158
+ && let Ok(aria_level) = aria_level.parse::<usize>()
159
+ // Accesskit levels are 0-based, while ARIA levels are 1-based
160
+ // See https://docs.rs/accesskit/latest/accesskit/struct.Node.html#method.level
161
+ && let Some(aria_level) = aria_level.checked_sub(1)
162
+ {
163
+ builder.set_level(aria_level);
164
+ } else if let Some(default_level) = default_level_for_tag(&name) {
165
+ builder.set_level(default_level);
166
+ } else if let Some(default_level) = default_level_for_role(role) {
167
+ builder.set_level(default_level);
168
+ }
169
+
170
+ builder.set_html_tag(name);
74
171
  } else if node.is_text_node() {
75
172
  builder.set_role(Role::TextRun);
76
173
  builder.set_value(node.text_content());
@@ -96,6 +193,59 @@ impl BlitzDomNode {
96
193
  })
97
194
  .unwrap_or(false)
98
195
  }
196
+
197
+ fn write_text_alternative(&self, out: &mut FlatText) {
198
+ match self
199
+ .attr(local_name!("aria-label"))
200
+ .filter(|aria_label| !aria_label.trim_ascii().is_empty())
201
+ {
202
+ Some(aria_label) => out.push(aria_label),
203
+ None => self.write_flat_text_content(out),
204
+ }
205
+ }
206
+
207
+ fn write_flat_text_content(&self, out: &mut FlatText) {
208
+ match &self.data {
209
+ NodeData::Text(data) => out.push(&data.content),
210
+ NodeData::Element(..) | NodeData::AnonymousBlock(..) => {
211
+ for &child_id in &self.children {
212
+ self.with(child_id).write_flat_text_content(out);
213
+ }
214
+ }
215
+ _ => {}
216
+ }
217
+ }
218
+ }
219
+
220
+ #[derive(Default)]
221
+ struct FlatText {
222
+ text: String,
223
+ pending_space: bool,
224
+ }
225
+
226
+ impl FlatText {
227
+ fn push(&mut self, text: &str) {
228
+ for (i, word) in text.split(|c: char| c.is_ascii_whitespace()).enumerate() {
229
+ self.pending_space |= i > 0;
230
+ if word.is_empty() {
231
+ continue;
232
+ }
233
+ if self.pending_space && !self.text.is_empty() {
234
+ self.text.push(' ');
235
+ }
236
+ self.pending_space = false;
237
+ self.text.push_str(word);
238
+ }
239
+ }
240
+
241
+ fn break_word(&mut self) {
242
+ self.pending_space = true;
243
+ }
244
+
245
+ fn clear(&mut self) {
246
+ self.text.clear();
247
+ self.pending_space = false;
248
+ }
99
249
  }
100
250
 
101
251
  fn role_from_name(name: &str) -> Option<Role> {
@@ -162,6 +312,29 @@ fn role_from_name(name: &str) -> Option<Role> {
162
312
  }
163
313
  }
164
314
 
315
+ fn default_level_for_tag(name: &str) -> Option<usize> {
316
+ // Accesskit levels are 0-based, while ARIA levels are 1-based
317
+ // See https://docs.rs/accesskit/latest/accesskit/struct.Node.html#method.level
318
+ match name {
319
+ "h1" => Some(0),
320
+ "h2" => Some(1),
321
+ "h3" => Some(2),
322
+ "h4" => Some(3),
323
+ "h5" => Some(4),
324
+ "h6" => Some(5),
325
+ _ => None,
326
+ }
327
+ }
328
+
329
+ fn default_level_for_role(role: Role) -> Option<usize> {
330
+ match role {
331
+ // ARIA specifies an implicit level of 2 for the heading role. Accesskit levels are 0-based,
332
+ // so this turns into level 1
333
+ Role::Heading => Some(1),
334
+ _ => None,
335
+ }
336
+ }
337
+
165
338
  fn role_from_element_data(element_data: &ElementData) -> Option<Role> {
166
339
  // <https://www.w3.org/TR/html-aam-1.0/>
167
340
  match &*element_data.name.local {
@@ -10,20 +10,18 @@ use style::media_queries::MediaType;
10
10
 
11
11
  /// Strategy for Stylo's style traversal during `resolve`.
12
12
  ///
13
- /// Two `Document`s resolving on [`StyleThreading::Parallel`] concurrently
14
- /// share Stylo's global thread pool and can panic with
15
- /// `already mutably borrowed` — see
16
- /// <https://github.com/DioxusLabs/blitz/issues/430>. Set
17
- /// [`StyleThreading::Sequential`] on documents that may resolve from a
18
- /// user thread while another `Parallel` resolve is in flight.
13
+ /// `Parallel` traversals are serialised process-wide (Stylo's global pool can
14
+ /// only run one traversal at a time), so documents resolving from several
15
+ /// threads never run their style traversals concurrently but also never
16
+ /// overlap: it only pays off when a single thread resolves all documents
17
+ /// (e.g. a UI thread, including nested sub-documents).
19
18
  #[derive(Default, Clone, Copy, PartialEq, Eq, Debug)]
20
19
  pub enum StyleThreading {
21
20
  /// Use Stylo's parallel traversal via its global rayon thread pool.
22
- /// Fastest for a single document; panics if another `Parallel` resolve
23
- /// is in flight on a different thread.
24
21
  Parallel,
25
22
  /// Run style traversal sequentially on the calling thread, bypassing
26
- /// the global pool. Safe to use from many user threads concurrently.
23
+ /// the global pool and its lock. Threads resolving their own documents
24
+ /// run independently of each other.
27
25
  #[default]
28
26
  Sequential,
29
27
  }
@@ -57,7 +55,7 @@ pub struct DocumentConfig {
57
55
  /// Defaults to [`MediaType::screen`].
58
56
  pub media_type: Option<MediaType>,
59
57
  /// Strategy for Stylo's style traversal.
60
- /// Defaults to [`StyleThreading::Parallel`].
58
+ /// Defaults to [`StyleThreading::Sequential`].
61
59
  pub style_threading: StyleThreading,
62
60
  /// Whether incremental layout is enabled. Defaults to `true`.
63
61
  ///
@@ -954,6 +954,21 @@ impl BaseDocument {
954
954
  if self.focus_node_id == Some(node_id) {
955
955
  let shell_provider = self.shell_provider.clone();
956
956
  self.nodes[node_id].blur(shell_provider);
957
+ // Nothing on the chain contains the focus anymore. Only the surviving
958
+ // ancestors need a snapshot for restyling: nodes in the removed subtree had
959
+ // theirs dropped already and may be freed entirely.
960
+ let mut maybe_id = Some(node_id);
961
+ while let Some(node) = maybe_id.and_then(|id| self.get_node(id)) {
962
+ let id = node.id;
963
+ maybe_id = node.parent;
964
+ if node.flags.is_in_document() {
965
+ self.snapshot_node_and(id, ElementState::FOCUS_WITHIN, |node| {
966
+ node.remove_focus_within()
967
+ });
968
+ } else {
969
+ self.nodes[id].remove_focus_within();
970
+ }
971
+ }
957
972
  self.focus_node_id = None;
958
973
  }
959
974
  if self.mousedown_node_id == Some(node_id) {
@@ -1699,7 +1714,13 @@ impl BaseDocument {
1699
1714
  self.snapshot_node_and(id, ElementState::FOCUS | ElementState::FOCUSRING, |node| {
1700
1715
  node.blur(shell_provider)
1701
1716
  });
1717
+ for &ancestor in self.node_element_ancestors(id).iter() {
1718
+ self.snapshot_node_and(ancestor, ElementState::FOCUS_WITHIN, |node| {
1719
+ node.remove_focus_within()
1720
+ });
1721
+ }
1702
1722
  self.focus_node_id = None;
1723
+ self.shell_provider.request_redraw();
1703
1724
  }
1704
1725
  }
1705
1726
 
@@ -1713,6 +1734,10 @@ impl BaseDocument {
1713
1734
  if Some(focus_node_id) == self.focus_node_id {
1714
1735
  return false;
1715
1736
  }
1737
+ // A node that is not in the document cannot be focused
1738
+ if !self.nodes[focus_node_id].flags.is_in_document() {
1739
+ return false;
1740
+ }
1716
1741
 
1717
1742
  #[cfg(feature = "tracing")]
1718
1743
  tracing::info!("Focussed node {focus_node_id}");
@@ -1733,8 +1758,32 @@ impl BaseDocument {
1733
1758
  |node| node.focus(shell_provider),
1734
1759
  );
1735
1760
 
1761
+ // Update :focus-within on the ancestor chains (shared prefix stays)
1762
+ let old_node_path = self.maybe_node_element_ancestors(self.focus_node_id);
1763
+ let new_node_path = self.node_element_ancestors(focus_node_id);
1764
+ let same_count = old_node_path
1765
+ .iter()
1766
+ .zip(&new_node_path)
1767
+ .take_while(|(o, n)| o == n)
1768
+ .count();
1769
+ for &id in old_node_path.iter().skip(same_count) {
1770
+ self.snapshot_node_and(id, ElementState::FOCUS_WITHIN, |node| {
1771
+ node.remove_focus_within()
1772
+ });
1773
+ }
1774
+ for &id in new_node_path.iter().skip(same_count) {
1775
+ self.snapshot_node_and(id, ElementState::FOCUS_WITHIN, |node| {
1776
+ node.add_focus_within()
1777
+ });
1778
+ }
1779
+
1736
1780
  self.focus_node_id = Some(focus_node_id);
1737
1781
 
1782
+ // The focus is styled (:focus, :focus-visible, :focus-within), so
1783
+ // moving it changes what is on screen. Nothing else asks for the
1784
+ // frame: a keyboard focus change touches no layout and no content.
1785
+ self.shell_provider.request_redraw();
1786
+
1738
1787
  true
1739
1788
  }
1740
1789
 
@@ -2211,11 +2260,15 @@ impl BaseDocument {
2211
2260
  // Prefer the precise hit node: `cursor` and `user-select` may be set on
2212
2261
  // a pseudo-element or resolved on an anonymous box, and text hits carry
2213
2262
  // is_text via the hit node. Fall back to the canonical hover node if
2214
- // the hit node has been removed (it is transient across resolves).
2215
- let node_id = self
2263
+ // the hit node has been removed (it is transient across resolves), and
2264
+ // to the default cursor if the pointer is over no box.
2265
+ let Some(node_id) = self
2216
2266
  .hover_hit_node_id
2217
2267
  .filter(|&id| self.nodes.contains_key(id))
2218
- .or(self.get_hover_node_id())?;
2268
+ .or(self.get_hover_node_id())
2269
+ else {
2270
+ return Some(CursorIcon::Default);
2271
+ };
2219
2272
  let node = &self.nodes[node_id];
2220
2273
 
2221
2274
  if let Some(subdoc) = node.subdoc().map(|doc| doc.inner())
@@ -2953,6 +3006,16 @@ mod hover_state_tests {
2953
3006
  assert_eq!(doc.get_hover_node_id(), Some(container));
2954
3007
  assert_eq!(doc.get_cursor(), Some(CursorIcon::Default));
2955
3008
  }
3009
+
3010
+ /// Hovering below the content hits no box: default cursor (`None` would hide it).
3011
+ #[test]
3012
+ fn hovering_below_content_reports_default_cursor() {
3013
+ let (mut doc, _) = make_doc();
3014
+
3015
+ doc.set_hover_to(5.0, 250.0);
3016
+ assert_eq!(doc.get_hover_node_id(), None);
3017
+ assert_eq!(doc.get_cursor(), Some(CursorIcon::Default));
3018
+ }
2956
3019
  }
2957
3020
 
2958
3021
  #[cfg(test)]
@@ -5,7 +5,7 @@ use blitz_traits::{
5
5
  SmolStr,
6
6
  events::{BlitzInputEvent, BlitzKeyEvent, DomEvent, DomEventData},
7
7
  };
8
- use keyboard_types::{Key, Modifiers};
8
+ use keyboard_types::{Key, Modifiers, NamedKey};
9
9
  use markup5ever::local_name;
10
10
 
11
11
  pub(super) enum KeyboardOrTextInputEvent {
@@ -20,9 +20,8 @@ pub(crate) fn handle_key_or_input_event<F: FnMut(DomEvent)>(
20
20
  mut dispatch_event: F,
21
21
  ) {
22
22
  if let KeyboardOrTextInputEvent::KeyPress(event) = &event {
23
- if event.key == Key::Tab {
23
+ if event.key == Key::Named(NamedKey::Tab) {
24
24
  let backwards = event.modifiers.contains(Modifiers::SHIFT);
25
- let old_focus = doc.focus_node_id;
26
25
  generate_focus_events(
27
26
  doc,
28
27
  &mut |doc| {
@@ -34,9 +33,6 @@ pub(crate) fn handle_key_or_input_event<F: FnMut(DomEvent)>(
34
33
  },
35
34
  &mut dispatch_event,
36
35
  );
37
- if doc.focus_node_id != old_focus {
38
- doc.shell_provider.request_redraw();
39
- }
40
36
  return;
41
37
  }
42
38