blitz-pdf 0.1.3 → 0.1.4

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 (64) hide show
  1. package/README.md +6 -6
  2. package/THIRD_PARTY_NOTICES.md +5 -2
  3. package/docs/layout-regressions.md +65 -0
  4. package/docs/pagination-regressions.md +7 -6
  5. package/package.json +1 -1
  6. package/vendor/README.md +35 -0
  7. package/vendor/blitz-dom/Cargo.toml +113 -0
  8. package/vendor/blitz-dom/LICENSE-APACHE +176 -0
  9. package/vendor/blitz-dom/LICENSE-MIT +23 -0
  10. package/vendor/blitz-dom/assets/default.css +1142 -0
  11. package/vendor/blitz-dom/assets/moz-bullet-font.otf +0 -0
  12. package/vendor/blitz-dom/src/accessibility.rs +255 -0
  13. package/vendor/blitz-dom/src/config.rs +97 -0
  14. package/vendor/blitz-dom/src/cssom.rs +971 -0
  15. package/vendor/blitz-dom/src/debug.rs +206 -0
  16. package/vendor/blitz-dom/src/document.rs +3395 -0
  17. package/vendor/blitz-dom/src/events/driver.rs +427 -0
  18. package/vendor/blitz-dom/src/events/focus.rs +40 -0
  19. package/vendor/blitz-dom/src/events/ime.rs +31 -0
  20. package/vendor/blitz-dom/src/events/keyboard.rs +174 -0
  21. package/vendor/blitz-dom/src/events/mod.rs +315 -0
  22. package/vendor/blitz-dom/src/events/pointer.rs +849 -0
  23. package/vendor/blitz-dom/src/font_metrics.rs +177 -0
  24. package/vendor/blitz-dom/src/form.rs +463 -0
  25. package/vendor/blitz-dom/src/html.rs +38 -0
  26. package/vendor/blitz-dom/src/iframe.rs +186 -0
  27. package/vendor/blitz-dom/src/layout/construct.rs +1328 -0
  28. package/vendor/blitz-dom/src/layout/damage.rs +642 -0
  29. package/vendor/blitz-dom/src/layout/inline.rs +1160 -0
  30. package/vendor/blitz-dom/src/layout/list.rs +237 -0
  31. package/vendor/blitz-dom/src/layout/mod.rs +969 -0
  32. package/vendor/blitz-dom/src/layout/paint_tree.rs +462 -0
  33. package/vendor/blitz-dom/src/layout/replaced.rs +439 -0
  34. package/vendor/blitz-dom/src/layout/table.rs +967 -0
  35. package/vendor/blitz-dom/src/layout/text_transform.rs +979 -0
  36. package/vendor/blitz-dom/src/layout/writing_mode.rs +437 -0
  37. package/vendor/blitz-dom/src/lib.rs +160 -0
  38. package/vendor/blitz-dom/src/mutator.rs +1818 -0
  39. package/vendor/blitz-dom/src/net.rs +649 -0
  40. package/vendor/blitz-dom/src/node/attributes.rs +60 -0
  41. package/vendor/blitz-dom/src/node/custom_widget.rs +178 -0
  42. package/vendor/blitz-dom/src/node/element.rs +1106 -0
  43. package/vendor/blitz-dom/src/node/mod.rs +30 -0
  44. package/vendor/blitz-dom/src/node/node.rs +2036 -0
  45. package/vendor/blitz-dom/src/node/scrollbar.rs +237 -0
  46. package/vendor/blitz-dom/src/node/serialize.rs +225 -0
  47. package/vendor/blitz-dom/src/node/stylo_data.rs +142 -0
  48. package/vendor/blitz-dom/src/node/svg.rs +265 -0
  49. package/vendor/blitz-dom/src/node/text.rs +804 -0
  50. package/vendor/blitz-dom/src/query_selector.rs +275 -0
  51. package/vendor/blitz-dom/src/resolve.rs +474 -0
  52. package/vendor/blitz-dom/src/resolved_style.rs +797 -0
  53. package/vendor/blitz-dom/src/scrolling.rs +780 -0
  54. package/vendor/blitz-dom/src/selection.rs +122 -0
  55. package/vendor/blitz-dom/src/stylo.rs +1518 -0
  56. package/vendor/blitz-dom/src/stylo_device.rs +88 -0
  57. package/vendor/blitz-dom/src/stylo_to_cursor_icon.rs +49 -0
  58. package/vendor/blitz-dom/src/stylo_to_kurbo.rs +129 -0
  59. package/vendor/blitz-dom/src/stylo_to_parley.rs +655 -0
  60. package/vendor/blitz-dom/src/traversal.rs +600 -0
  61. package/vendor/blitz-dom/src/tree.rs +136 -0
  62. package/vendor/blitz-dom/src/url.rs +63 -0
  63. package/vendor/blitz-dom/src/util.rs +256 -0
  64. package/wasm/blitz_pdf_bg.wasm +0 -0
@@ -0,0 +1,849 @@
1
+ use blitz_traits::node_id::NodeId;
2
+ use std::collections::VecDeque;
3
+
4
+ use web_time::{Duration, Instant, SystemTime, UNIX_EPOCH};
5
+
6
+ use blitz_traits::{
7
+ events::{
8
+ BlitzInputEvent, BlitzPointerEvent, BlitzPointerId, BlitzWheelDelta, BlitzWheelEvent,
9
+ DomEvent, DomEventData, MouseEventButton, MouseEventButtons,
10
+ },
11
+ navigation::NavigationOptions,
12
+ };
13
+ use keyboard_types::Modifiers;
14
+ use markup5ever::local_name;
15
+ use style::values::computed::{Overflow, TouchAction, UserSelect};
16
+ use style_dom::ElementState;
17
+ use taffy::AbsoluteAxis;
18
+
19
+ use crate::{
20
+ BaseDocument,
21
+ node::{ScrollbarRef, SpecialElementData},
22
+ scrolling::{FlingState, ScrollAnimationState},
23
+ };
24
+
25
+ use super::focus::generate_focus_events;
26
+
27
+ #[derive(Debug, Clone, PartialEq)]
28
+ pub(crate) struct PanState {
29
+ pub(crate) target: NodeId,
30
+ pub(crate) last_x: f32,
31
+ pub(crate) last_y: f32,
32
+ /// Whether horizontal panning is permitted by the `touch-action` property.
33
+ pub(crate) allow_x: bool,
34
+ /// Whether vertical panning is permitted by the `touch-action` property.
35
+ pub(crate) allow_y: bool,
36
+ pub(crate) samples: VecDeque<PanSample>,
37
+ }
38
+
39
+ #[derive(Debug, Clone, PartialEq)]
40
+ pub(crate) struct PanSample {
41
+ pub(crate) time: u64,
42
+ pub(crate) dx: f32,
43
+ pub(crate) dy: f32,
44
+ }
45
+
46
+ #[derive(Debug, Clone, Copy, PartialEq)]
47
+ pub(crate) struct ScrollbarDragState {
48
+ /// The thumb being dragged
49
+ pub(crate) scrollbar: ScrollbarRef,
50
+ /// Last pointer position along the drag axis, in page coordinates
51
+ pub(crate) last_pos: f32,
52
+ }
53
+
54
+ #[derive(Debug, Clone, PartialEq)]
55
+ pub(crate) enum DragMode {
56
+ /// We are not currently dragging
57
+ None,
58
+ /// We are currently dragging a selection (probably mouse)
59
+ Selecting,
60
+ /// We are currently panning the document with a drag (probably touch)
61
+ Panning(PanState),
62
+ /// We are currently dragging a scrollbar thumb
63
+ ScrollbarDrag(ScrollbarDragState),
64
+ }
65
+
66
+ impl DragMode {
67
+ pub(crate) fn take(&mut self) -> DragMode {
68
+ std::mem::replace(self, DragMode::None)
69
+ }
70
+ }
71
+
72
+ impl PanState {
73
+ fn update(&mut self, time_ms: u64, screen_x: f32, screen_y: f32) -> (f64, f64) {
74
+ // Constrain panning to the axes permitted by the `touch-action` property. Positions are
75
+ // still tracked on both axes so that deltas remain correct after a disallowed movement.
76
+ let dx = if self.allow_x {
77
+ (screen_x - self.last_x) as f64
78
+ } else {
79
+ 0.0
80
+ };
81
+ let dy = if self.allow_y {
82
+ (screen_y - self.last_y) as f64
83
+ } else {
84
+ 0.0
85
+ };
86
+ self.last_x = screen_x;
87
+ self.last_y = screen_y;
88
+
89
+ self.samples.push_back(PanSample {
90
+ time: time_ms,
91
+ // TODO: account for scroll delta not applied due to clamping
92
+ dx: dx as f32,
93
+ dy: dy as f32,
94
+ });
95
+
96
+ // Remove samples older than 100ms
97
+ if self.samples.len() > 50 && time_ms - self.samples.front().unwrap().time > 100 {
98
+ let idx = self
99
+ .samples
100
+ .partition_point(|sample| time_ms - sample.time > 100);
101
+ // FIXME: use truncate_front once stable
102
+ for _ in 0..idx {
103
+ self.samples.pop_front();
104
+ }
105
+ }
106
+
107
+ (dx, dy)
108
+ }
109
+
110
+ fn generate_fling(&self, time_ms: u64) -> Option<FlingState> {
111
+ // Generate "fling"
112
+ if let Some(last_sample) = self.samples.back()
113
+ && time_ms - last_sample.time < 100
114
+ {
115
+ let idx = self
116
+ .samples
117
+ .partition_point(|sample| time_ms - sample.time > 100);
118
+
119
+ // Compute pan_time. Will always be <= 100ms as we ignore samples older than that.
120
+ let pan_start_time = self.samples[idx].time;
121
+ let pan_time = (time_ms - pan_start_time) as f32;
122
+
123
+ // Avoid division by 0
124
+ if pan_time > 0.0 {
125
+ let (pan_x, pan_y) = self
126
+ .samples
127
+ .iter()
128
+ .skip(idx)
129
+ .fold((0.0, 0.0), |(dx, dy), sample| {
130
+ (dx + sample.dx, dy + sample.dy)
131
+ });
132
+
133
+ let x_velocity = if pan_x.abs() > pan_y.abs() {
134
+ pan_x / pan_time
135
+ } else {
136
+ 0.0
137
+ };
138
+
139
+ let y_velocity = if pan_y.abs() > pan_x.abs() {
140
+ pan_y / pan_time
141
+ } else {
142
+ 0.0
143
+ };
144
+
145
+ return Some(FlingState {
146
+ target: self.target,
147
+ last_seen_time: time_ms as f64,
148
+ x_velocity: x_velocity as f64 * 2.0,
149
+ y_velocity: y_velocity as f64 * 2.0,
150
+ });
151
+ }
152
+ }
153
+
154
+ None
155
+ }
156
+ }
157
+
158
+ /// Compute which axes a touch pan gesture starting on `node_id` is allowed to scroll, according to
159
+ /// the `touch-action` property.
160
+ ///
161
+ /// Per the Pointer Events spec the effective behaviour is the intersection of the `touch-action`
162
+ /// values of the target and its ancestors up to and including the nearest ancestor that implements
163
+ /// the pan (the nearest scroll container which can actually scroll on that axis), so the walk stops
164
+ /// per-axis at that scroller: `touch-action` values on elements *above* it do not restrict pans it
165
+ /// handles. `touch-action: none` blocks panning entirely, `pan-x`/`pan-y` restrict it to a single
166
+ /// axis, and `auto` / `manipulation` permit both.
167
+ fn touch_action_pan_axes(doc: &BaseDocument, node_id: NodeId) -> (bool, bool) {
168
+ let pan_x_flags = TouchAction::AUTO | TouchAction::MANIPULATION | TouchAction::PAN_X;
169
+ let pan_y_flags = TouchAction::AUTO | TouchAction::MANIPULATION | TouchAction::PAN_Y;
170
+
171
+ let mut allow_x = true;
172
+ let mut allow_y = true;
173
+ // Whether the nearest scroller for the axis has been reached (its own `touch-action` counts,
174
+ // but its ancestors' do not).
175
+ let mut done_x = false;
176
+ let mut done_y = false;
177
+ let mut current = Some(node_id);
178
+ while let Some(id) = current {
179
+ let node = &doc.nodes[id];
180
+ if let Some(style) = node.primary_styles() {
181
+ let touch_action = style.clone_touch_action();
182
+ if !done_x {
183
+ allow_x &= touch_action.intersects(pan_x_flags);
184
+ }
185
+ if !done_y {
186
+ allow_y &= touch_action.intersects(pan_y_flags);
187
+ }
188
+
189
+ let scrolls_x = matches!(style.clone_overflow_x(), Overflow::Scroll | Overflow::Auto)
190
+ && node.final_layout().scroll_width() > 0.0;
191
+ let scrolls_y = matches!(style.clone_overflow_y(), Overflow::Scroll | Overflow::Auto)
192
+ && node.final_layout().scroll_height() > 0.0;
193
+ done_x |= scrolls_x;
194
+ done_y |= scrolls_y;
195
+
196
+ if (done_x || !allow_x) && (done_y || !allow_y) {
197
+ break;
198
+ }
199
+ }
200
+ current = node.parent;
201
+ }
202
+
203
+ (allow_x, allow_y)
204
+ }
205
+
206
+ pub(crate) fn handle_pointermove<F: FnMut(DomEvent)>(
207
+ doc: &mut BaseDocument,
208
+ target: NodeId,
209
+ event: &BlitzPointerEvent,
210
+ mut dispatch_event: F,
211
+ ) -> bool {
212
+ let x = event.page_x();
213
+ let y = event.page_y();
214
+ let buttons = event.buttons;
215
+
216
+ let mut changed = doc.set_hover_to(x, y);
217
+
218
+ // Check if we've moved enough to be considered a selection drag (2px threshold)
219
+ if buttons != MouseEventButtons::None && doc.drag_mode == DragMode::None {
220
+ let dx = x - doc.mousedown_position.x;
221
+ let dy = y - doc.mousedown_position.y;
222
+ if dx.abs() > 2.0 || dy.abs() > 2.0 {
223
+ match event.id {
224
+ BlitzPointerId::Mouse | BlitzPointerId::Pen => {
225
+ if let Some(mousedown_node_id) = doc.mousedown_node_id {
226
+ let node = &doc.nodes[mousedown_node_id];
227
+ if let Some(style) = node.primary_styles() {
228
+ let user_select = style.clone_user_select();
229
+ if user_select == UserSelect::None {
230
+ // Do nothing. Continue with rest of function
231
+ } else if user_select == UserSelect::Auto {
232
+ if let Some(parent) = node.parent {
233
+ let node = &doc.nodes[parent];
234
+ if let Some(style) = node.primary_styles() {
235
+ let user_select = style.clone_user_select();
236
+ if user_select == UserSelect::None {
237
+ // Do nothing. Continue with rest of function
238
+ } else {
239
+ doc.drag_mode = DragMode::Selecting;
240
+ }
241
+ }
242
+ }
243
+ }
244
+ }
245
+ }
246
+ }
247
+ BlitzPointerId::Finger(_) => {
248
+ let (allow_x, allow_y) = touch_action_pan_axes(doc, target);
249
+ // If `touch-action` forbids panning on both axes (e.g. `touch-action: none`)
250
+ // there is nothing to scroll, so don't enter the panning drag mode.
251
+ if allow_x || allow_y {
252
+ doc.drag_mode = DragMode::Panning(PanState {
253
+ target,
254
+ last_x: event.screen_x(),
255
+ last_y: event.screen_y(),
256
+ allow_x,
257
+ allow_y,
258
+ samples: VecDeque::with_capacity(200),
259
+ });
260
+ }
261
+ }
262
+ }
263
+ }
264
+ }
265
+
266
+ if let DragMode::Panning(state) = &mut doc.drag_mode {
267
+ let time_ms = SystemTime::now()
268
+ .duration_since(UNIX_EPOCH)
269
+ .unwrap()
270
+ .as_millis() as u64;
271
+
272
+ let target = state.target;
273
+ let (dx, dy) = state.update(time_ms, event.screen_x(), event.screen_y());
274
+
275
+ let has_changed = doc.scroll_chain_by(Some(target), dx, dy, &mut dispatch_event);
276
+ return has_changed;
277
+ }
278
+
279
+ if let DragMode::ScrollbarDrag(state) = &mut doc.drag_mode {
280
+ let ScrollbarRef { node_id, axis } = state.scrollbar;
281
+ let pos = match axis {
282
+ AbsoluteAxis::Horizontal => x,
283
+ AbsoluteAxis::Vertical => y,
284
+ };
285
+ let delta_px = (pos - state.last_pos) as f64;
286
+ state.last_pos = pos;
287
+
288
+ // Thumb px -> content px. scroll_by uses wheel-delta semantics
289
+ // (positive delta decreases the offset), so negate.
290
+ let ratio = doc.nodes[node_id].scrollbar_drag_ratio(axis);
291
+ let (dx, dy) = match axis {
292
+ AbsoluteAxis::Horizontal => (-delta_px * ratio, 0.0),
293
+ AbsoluteAxis::Vertical => (0.0, -delta_px * ratio),
294
+ };
295
+ let has_changed = doc.scroll_chain_by(Some(node_id), dx, dy, &mut dispatch_event);
296
+ return has_changed;
297
+ }
298
+
299
+ let Some(hit) = doc.hit(x, y) else {
300
+ return changed;
301
+ };
302
+
303
+ if changed {
304
+ dispatch_event(DomEvent::new(
305
+ hit.node_id,
306
+ DomEventData::MouseEnter(event.clone()),
307
+ ));
308
+ }
309
+
310
+ // `target` is the event's canonicalized target (never a layout-generated
311
+ // node), while the hit may be an anonymous block (e.g. bare text wrapped
312
+ // in an anonymous box). Compare against the hit's canonical DOM ancestor
313
+ // so selection drags keep working over anonymous blocks.
314
+ if doc.nearest_non_anonymous_ancestor(hit.node_id) != Some(target) {
315
+ return changed;
316
+ }
317
+
318
+ let node = &mut doc.nodes[target];
319
+ let Some(el) = node.data.downcast_element_mut() else {
320
+ // Handle text selection extension for non-element nodes
321
+ if buttons != MouseEventButtons::None
322
+ && doc.drag_mode == DragMode::Selecting
323
+ && doc.extend_text_selection_to_point(x, y)
324
+ {
325
+ changed = true;
326
+ }
327
+ return changed;
328
+ };
329
+
330
+ let disabled = el.attr(local_name!("disabled")).is_some();
331
+ if disabled {
332
+ return changed;
333
+ }
334
+
335
+ let final_layout = el.layout_data().final_layout;
336
+ if let SpecialElementData::TextInput(ref mut text_input_data) = el.special_data {
337
+ if buttons == MouseEventButtons::None {
338
+ return changed;
339
+ }
340
+
341
+ let mut content_box_offset = taffy::Point {
342
+ x: final_layout.padding.left + final_layout.border.left,
343
+ y: final_layout.padding.top + final_layout.border.top,
344
+ };
345
+ if !text_input_data.is_multiline {
346
+ let layout = text_input_data.editor.try_layout().unwrap();
347
+ let content_box_height = final_layout.content_box_height();
348
+ let input_height = layout.height() / layout.scale();
349
+ let y_offset = ((content_box_height - input_height) / 2.0).max(0.0);
350
+
351
+ content_box_offset.y += y_offset;
352
+ }
353
+
354
+ // Account for the input's scroll offset (stored in CSS pixels, scaled here to device
355
+ // pixels) when mapping the pointer location into the text content's coordinate space.
356
+ let scroll_offset = text_input_data.scroll_offset as f64 * doc.viewport.scale_f64();
357
+ let (scroll_x, scroll_y) = if text_input_data.is_multiline {
358
+ (0.0, scroll_offset)
359
+ } else {
360
+ (scroll_offset, 0.0)
361
+ };
362
+
363
+ let x = (hit.x - content_box_offset.x) as f64 * doc.viewport.scale_f64() + scroll_x;
364
+ let y = (hit.y - content_box_offset.y) as f64 * doc.viewport.scale_f64() + scroll_y;
365
+
366
+ text_input_data
367
+ .editor
368
+ .driver(&mut doc.font_ctx.lock().unwrap(), &mut doc.layout_ctx)
369
+ .extend_selection_to_point(x as f32, y as f32);
370
+
371
+ changed = true;
372
+ } else if event.is_mouse()
373
+ && buttons != MouseEventButtons::None
374
+ && doc.drag_mode == DragMode::Selecting
375
+ && doc.extend_text_selection_to_point(x, y)
376
+ {
377
+ changed = true;
378
+ }
379
+
380
+ changed
381
+ }
382
+
383
+ pub(crate) fn handle_pointerdown(
384
+ doc: &mut BaseDocument,
385
+ _target: NodeId,
386
+ x: f32,
387
+ y: f32,
388
+ button: MouseEventButton,
389
+ mods: Modifiers,
390
+ dispatch_event: &mut dyn FnMut(DomEvent),
391
+ ) {
392
+ // Compute click count using the previous mousedown position (before updating)
393
+ // This handles both double-click detection and text input word/line selection
394
+ // TODO: For text inputs, only increment click count if click maps to the same/similar caret position
395
+ doc.click_count = if doc
396
+ .last_mousedown_time
397
+ .map(|t| t.elapsed() < Duration::from_millis(500))
398
+ .unwrap_or(false)
399
+ && (doc.mousedown_position.x - x).abs() <= 2.0
400
+ && (doc.mousedown_position.y - y).abs() <= 2.0
401
+ {
402
+ doc.click_count + 1
403
+ } else {
404
+ 1
405
+ };
406
+
407
+ // Update mousedown tracking for next click and selection drag detection
408
+ doc.last_mousedown_time = Some(Instant::now());
409
+ doc.mousedown_position = taffy::Point { x, y };
410
+ doc.drag_mode = DragMode::None;
411
+ doc.scroll_animation = ScrollAnimationState::None;
412
+
413
+ let Some(hit) = doc.hit(x, y) else {
414
+ // Clear text selection when clicking outside any element
415
+ doc.clear_text_selection();
416
+ return;
417
+ };
418
+
419
+ // Scrollbar thumb drags take precedence over content interactions and
420
+ // are not dispatched to the page (matching native scrollbars). A
421
+ // faded-out thumb doesn't capture: the click goes to the content.
422
+ if button == MouseEventButton::Main {
423
+ if let (_, Some(scrollbar)) = doc.hit_with_scrollbar(x, y)
424
+ && doc.scrollbar_opacity(scrollbar.node_id) > 0.0
425
+ {
426
+ doc.drag_mode = DragMode::ScrollbarDrag(ScrollbarDragState {
427
+ scrollbar,
428
+ last_pos: match scrollbar.axis {
429
+ AbsoluteAxis::Horizontal => x,
430
+ AbsoluteAxis::Vertical => y,
431
+ },
432
+ });
433
+ doc.shell_provider.request_redraw();
434
+ return;
435
+ }
436
+ }
437
+
438
+ // Use hit.node_id for determining the actual clicked element.
439
+ // This may differ from `target` for anonymous blocks (which are layout children
440
+ // but not DOM children), so we use the hit result for text selection.
441
+ let actual_target = hit.node_id;
442
+
443
+ // Check what kind of element we're dealing with and extract needed info
444
+ enum ClickTarget {
445
+ TextInput {
446
+ content_box_offset: taffy::Point<f32>,
447
+ /// Scroll offset of the input along each axis, in scaled (device) pixels.
448
+ scroll: taffy::Point<f64>,
449
+ },
450
+ Disabled,
451
+ SelectableText,
452
+ }
453
+
454
+ let click_target = {
455
+ let node = &doc.nodes[actual_target];
456
+ match node.data.downcast_element() {
457
+ Some(el) if el.has_attr(local_name!("disabled")) => ClickTarget::Disabled,
458
+ Some(el) => {
459
+ if let SpecialElementData::TextInput(ref text_input_data) = el.special_data {
460
+ let mut content_box_offset = taffy::Point {
461
+ x: node.final_layout().padding.left + node.final_layout().border.left,
462
+ y: node.final_layout().padding.top + node.final_layout().border.top,
463
+ };
464
+ if !text_input_data.is_multiline {
465
+ let layout = text_input_data.editor.try_layout().unwrap();
466
+ let content_box_height = node.final_layout().content_box_height();
467
+ let input_height = layout.height() / layout.scale();
468
+ let y_offset = ((content_box_height - input_height) / 2.0).max(0.0);
469
+ content_box_offset.y += y_offset;
470
+ }
471
+ // `scroll_offset` is stored in CSS pixels; scale it to device pixels to
472
+ // match the editor's coordinate space.
473
+ let scroll_offset =
474
+ text_input_data.scroll_offset as f64 * doc.viewport.scale_f64();
475
+ let scroll = if text_input_data.is_multiline {
476
+ taffy::Point {
477
+ x: 0.0,
478
+ y: scroll_offset,
479
+ }
480
+ } else {
481
+ taffy::Point {
482
+ x: scroll_offset,
483
+ y: 0.0,
484
+ }
485
+ };
486
+ ClickTarget::TextInput {
487
+ content_box_offset,
488
+ scroll,
489
+ }
490
+ } else {
491
+ ClickTarget::SelectableText
492
+ }
493
+ }
494
+ None => ClickTarget::SelectableText,
495
+ }
496
+ };
497
+
498
+ match click_target {
499
+ ClickTarget::Disabled => (),
500
+ ClickTarget::SelectableText => {
501
+ // Handle text selection for non-input elements
502
+ if let Some((inline_root_id, byte_offset)) = doc.find_text_position(x, y) {
503
+ doc.set_text_selection(inline_root_id, byte_offset, inline_root_id, byte_offset);
504
+ doc.shell_provider.request_redraw();
505
+ } else {
506
+ doc.clear_text_selection();
507
+ }
508
+ }
509
+ ClickTarget::TextInput {
510
+ content_box_offset,
511
+ scroll,
512
+ } => {
513
+ // Clear general text selection when focusing a text input
514
+ doc.clear_text_selection();
515
+
516
+ let tx = (hit.x - content_box_offset.x) as f64 * doc.viewport.scale_f64() + scroll.x;
517
+ let ty = (hit.y - content_box_offset.y) as f64 * doc.viewport.scale_f64() + scroll.y;
518
+
519
+ // Now get mutable access to the text input
520
+ let click_count = doc.click_count;
521
+ let node = &mut doc.nodes[actual_target];
522
+ let el = node.data.downcast_element_mut().unwrap();
523
+ if let SpecialElementData::TextInput(ref mut text_input_data) = el.special_data {
524
+ let mut font_ctx = doc.font_ctx.lock().unwrap();
525
+ let mut driver = text_input_data
526
+ .editor
527
+ .driver(&mut font_ctx, &mut doc.layout_ctx);
528
+
529
+ match click_count {
530
+ 1 => {
531
+ if mods.shift() {
532
+ driver.shift_click_extension(tx as f32, ty as f32);
533
+ } else {
534
+ driver.move_to_point(tx as f32, ty as f32);
535
+ }
536
+ }
537
+ 2 => driver.select_word_at_point(tx as f32, ty as f32),
538
+ _ => driver.select_hard_line_at_point(tx as f32, ty as f32),
539
+ }
540
+
541
+ drop(font_ctx);
542
+ }
543
+
544
+ generate_focus_events(
545
+ doc,
546
+ &mut |doc| {
547
+ doc.set_focus_to(hit.node_id);
548
+ },
549
+ dispatch_event,
550
+ );
551
+ }
552
+ }
553
+ }
554
+
555
+ pub(crate) fn handle_pointerup<F: FnMut(DomEvent)>(
556
+ doc: &mut BaseDocument,
557
+ target: NodeId,
558
+ event: &BlitzPointerEvent,
559
+ mut dispatch_event: F,
560
+ ) {
561
+ if doc.devtools().highlight_hover {
562
+ let mut node = doc.get_node(target).unwrap();
563
+ if event.button == MouseEventButton::Secondary {
564
+ if let Some(parent_id) = node.layout_parent.get() {
565
+ node = doc.get_node(parent_id).unwrap();
566
+ }
567
+ }
568
+ doc.debug_log_node(node.id);
569
+ doc.devtools_mut().highlight_hover = false;
570
+ return;
571
+ }
572
+
573
+ // Reset Document's drag state to DragMode::None, storing the state
574
+ // locally for use within this function
575
+ let drag_mode = doc.drag_mode.take();
576
+
577
+ // Don't dispatch click if we were doing a text selection drag or panning
578
+ // the document with a touch
579
+ let do_click = drag_mode == DragMode::None;
580
+
581
+ // Repaint so a dragged scrollbar thumb drops its active styling, and
582
+ // restart its fade-out delay now that the drag no longer holds it shown
583
+ if let DragMode::ScrollbarDrag(state) = &drag_mode {
584
+ doc.show_scrollbars(state.scrollbar.node_id);
585
+ doc.shell_provider.request_redraw();
586
+ }
587
+
588
+ let time_ms = SystemTime::now()
589
+ .duration_since(UNIX_EPOCH)
590
+ .unwrap()
591
+ .as_millis() as u64;
592
+
593
+ if let DragMode::Panning(state) = &drag_mode {
594
+ if let Some(fling) = state.generate_fling(time_ms) {
595
+ doc.scroll_animation = ScrollAnimationState::Fling(fling);
596
+ doc.shell_provider.request_redraw();
597
+ }
598
+ }
599
+
600
+ // Dispatch a click event
601
+ if do_click && event.button == MouseEventButton::Main {
602
+ dispatch_event(DomEvent::new(target, DomEventData::Click(event.clone())));
603
+ }
604
+
605
+ // Dispatch a context menu event
606
+ if do_click && event.button == MouseEventButton::Secondary {
607
+ dispatch_event(DomEvent::new(
608
+ target,
609
+ DomEventData::ContextMenu(event.clone()),
610
+ ));
611
+ }
612
+ }
613
+
614
+ pub(crate) fn handle_click(
615
+ doc: &mut BaseDocument,
616
+ target: NodeId,
617
+ event: &BlitzPointerEvent,
618
+ dispatch_event: &mut dyn FnMut(DomEvent),
619
+ ) {
620
+ let double_click_event = event.clone();
621
+
622
+ let mut maybe_node_id = Some(target);
623
+ let matched = 'matched: {
624
+ while let Some(node_id) = maybe_node_id {
625
+ let maybe_element = {
626
+ let node = &mut doc.nodes[node_id];
627
+ node.data.downcast_element_mut()
628
+ };
629
+
630
+ let Some(el) = maybe_element else {
631
+ maybe_node_id = doc.nodes[node_id].parent;
632
+ continue;
633
+ };
634
+
635
+ let disabled = el.attr(local_name!("disabled")).is_some();
636
+ if disabled {
637
+ break 'matched true;
638
+ }
639
+
640
+ if let SpecialElementData::TextInput(_) = el.special_data {
641
+ break 'matched true;
642
+ }
643
+
644
+ match el.name.local {
645
+ local_name!("input") if el.attr(local_name!("type")) == Some("checkbox") => {
646
+ let mut is_checked = false;
647
+ doc.snapshot_node_and(node_id, ElementState::CHECKED, |node| {
648
+ if let Some(el) = node.element_data_mut() {
649
+ is_checked = BaseDocument::toggle_checkbox(el);
650
+ }
651
+ node.mark_ancestors_dirty();
652
+ });
653
+ let value = is_checked.to_string();
654
+ dispatch_event(DomEvent::new(
655
+ node_id,
656
+ DomEventData::Input(BlitzInputEvent { value }),
657
+ ));
658
+ generate_focus_events(
659
+ doc,
660
+ &mut |doc| {
661
+ doc.set_focus_to(node_id);
662
+ },
663
+ dispatch_event,
664
+ );
665
+ break 'matched true;
666
+ }
667
+ local_name!("input") if el.attr(local_name!("type")) == Some("radio") => {
668
+ if let Some(radio_set) = el.attr(local_name!("name")).map(str::to_string) {
669
+ BaseDocument::toggle_radio(doc, radio_set, node_id);
670
+ } else if el.checkbox_input_checked().is_some() {
671
+ doc.snapshot_node_and(node_id, ElementState::CHECKED, |node| {
672
+ if let Some(el) = node.element_data_mut() {
673
+ el.set_checkbox_input_checked(true);
674
+ }
675
+ node.mark_ancestors_dirty();
676
+ });
677
+ }
678
+
679
+ // TODO: make input event conditional on value actually changing
680
+ let value = String::from("true");
681
+ dispatch_event(DomEvent::new(
682
+ node_id,
683
+ DomEventData::Input(BlitzInputEvent { value }),
684
+ ));
685
+
686
+ generate_focus_events(
687
+ doc,
688
+ &mut |doc| {
689
+ doc.set_focus_to(node_id);
690
+ },
691
+ dispatch_event,
692
+ );
693
+
694
+ break 'matched true;
695
+ }
696
+ // Activating the first <summary> of a <details> element toggles
697
+ // the details' `open` attribute (expand/collapse).
698
+ local_name!("summary") => {
699
+ if let Some(parent_id) = doc.nodes[node_id].parent {
700
+ let parent = &doc.nodes[parent_id];
701
+ let is_first_summary = parent
702
+ .data
703
+ .is_element_with_tag_name(&local_name!("details"))
704
+ && parent.children.iter().copied().find(|&child_id| {
705
+ doc.nodes[child_id]
706
+ .data
707
+ .is_element_with_tag_name(&local_name!("summary"))
708
+ }) == Some(node_id);
709
+
710
+ if is_first_summary {
711
+ doc.toggle_details_open(parent_id);
712
+ generate_focus_events(
713
+ doc,
714
+ &mut |doc| {
715
+ doc.set_focus_to(node_id);
716
+ },
717
+ dispatch_event,
718
+ );
719
+ break 'matched true;
720
+ }
721
+ }
722
+ }
723
+ // Clicking labels triggers click, and possibly input event, of associated input
724
+ local_name!("label") => {
725
+ if let Some(target_node_id) =
726
+ doc.label_bound_input_element(node_id).map(|n| n.id)
727
+ {
728
+ // Apply default click event action for target node
729
+ let target_node = doc.get_node_mut(target_node_id).unwrap();
730
+ let syn_event = target_node.synthetic_click_event_data(event.mods);
731
+ handle_click(doc, target_node_id, &syn_event, dispatch_event);
732
+ break 'matched true;
733
+ }
734
+ }
735
+ local_name!("a") => {
736
+ if let Some(href) = el.attr(local_name!("href")).map(str::to_string) {
737
+ if let Some(url) = doc.url.resolve_relative(&href) {
738
+ // If the link only differs from the current document URL by its
739
+ // fragment (this includes links whose href is just `#fragment`),
740
+ // perform in-page fragment navigation (scrolling) instead of a
741
+ // full navigation.
742
+ if url.fragment().is_some() && doc.url.is_same_document(&url) {
743
+ doc.scroll_to_fragment(url.fragment().unwrap_or_default());
744
+ } else {
745
+ doc.navigation_provider.navigate_to(NavigationOptions::new(
746
+ url,
747
+ None,
748
+ doc.id(),
749
+ ));
750
+ }
751
+ } else {
752
+ #[cfg(feature = "tracing")]
753
+ tracing::warn!("{href} is not parseable as a url. : {:?}", *doc.url);
754
+ }
755
+ break 'matched true;
756
+ } else {
757
+ #[cfg(feature = "tracing")]
758
+ tracing::info!("Clicked link without href: {:?}", el.attrs());
759
+ }
760
+ }
761
+ local_name!("input") | local_name!("button")
762
+ if el.is_submit_button() || el.attr(local_name!("type")) == Some("submit") =>
763
+ {
764
+ if let Some(form_owner) = doc.controls_to_form.get(&node_id) {
765
+ doc.submit_form(*form_owner, node_id);
766
+ }
767
+ }
768
+ #[cfg(feature = "file-input")]
769
+ local_name!("input") if el.attr(local_name!("type")) == Some("file") => {
770
+ use crate::qual_name;
771
+ //TODO: Handle accept attribute https://developer.mozilla.org/en-US/docs/Web/HTML/Reference/Attributes/accept by passing an appropriate filter
772
+ let multiple = el.attr(local_name!("multiple")).is_some();
773
+ let files = doc.shell_provider.open_file_dialog(multiple, None);
774
+
775
+ if let Some(file) = files.first() {
776
+ el.attrs
777
+ .set(qual_name!("value", html), &file.to_string_lossy());
778
+ }
779
+ let text_content = match files.len() {
780
+ 0 => "No Files Selected".to_string(),
781
+ 1 => files
782
+ .first()
783
+ .unwrap()
784
+ .file_name()
785
+ .unwrap_or_default()
786
+ .to_string_lossy()
787
+ .to_string(),
788
+ x => format!("{x} Files Selected"),
789
+ };
790
+
791
+ if files.is_empty() {
792
+ el.special_data = SpecialElementData::None;
793
+ } else {
794
+ el.special_data = SpecialElementData::FileInput(files.into())
795
+ }
796
+ let child_label_id = doc.nodes[node_id].children[1];
797
+ let child_text_id = doc.nodes[child_label_id].children[0];
798
+ let text_data = doc.nodes[child_text_id]
799
+ .text_data_mut()
800
+ .expect("Text data not found");
801
+ text_data.content = text_content;
802
+ }
803
+ _ => {}
804
+ }
805
+
806
+ // No match. Recurse up to parent.
807
+ maybe_node_id = doc.nodes[node_id].parent;
808
+ }
809
+
810
+ // Didn't match anything
811
+ false
812
+ };
813
+
814
+ // If nothing is matched then clear focus
815
+ if !matched {
816
+ generate_focus_events(doc, &mut |doc| doc.clear_focus(), dispatch_event);
817
+ }
818
+
819
+ // Dispatch double-click event if this is the second click in quick succession
820
+ // (click_count was already computed in handle_mousedown)
821
+ if doc.click_count == 2 {
822
+ dispatch_event(DomEvent::new(
823
+ target,
824
+ DomEventData::DoubleClick(double_click_event),
825
+ ));
826
+ }
827
+ }
828
+
829
+ pub(crate) fn handle_wheel<F: FnMut(DomEvent)>(
830
+ doc: &mut BaseDocument,
831
+ _: NodeId,
832
+ event: BlitzWheelEvent,
833
+ mut dispatch_event: F,
834
+ ) {
835
+ let (scroll_x, scroll_y) = match event.delta {
836
+ BlitzWheelDelta::Lines(x, y) => (x * 20.0, y * 20.0),
837
+ BlitzWheelDelta::Pixels(x, y) => (x, y),
838
+ };
839
+
840
+ let has_changed = doc.scroll_chain_by(
841
+ doc.get_hover_node_id(),
842
+ scroll_x,
843
+ scroll_y,
844
+ &mut dispatch_event,
845
+ );
846
+ if has_changed {
847
+ doc.shell_provider.request_redraw();
848
+ }
849
+ }