@downdraft/engine 0.4.0 → 0.4.2

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.
@@ -11,11 +11,12 @@
11
11
  export const isBun = typeof globalThis.Bun !== "undefined";
12
12
  /**
13
13
  * True inside a `bun build --compile` binary. In compiled executables
14
- * `Bun.main` is `/$bunfs/root/<entry>` instead of a real filesystem path —
14
+ * `Bun.main` is `/$bunfs/root/<entry>` (`B:\~BUN\root\<entry>` on Windows)
15
+ * instead of a real filesystem path —
15
16
  * this is a global and stays correct even in bundles where a packager has
16
17
  * rewritten a module's own `import.meta.url` to the staged asset tree.
17
18
  */
18
- export const isCompiledBinary = isBun && /^\/\$bunfs\//.test(globalThis.Bun?.main ?? "");
19
+ export const isCompiledBinary = isBun && /^\/\$bunfs\/|^B:[/\\]~BUN[/\\]/i.test(globalThis.Bun?.main ?? "");
19
20
  /**
20
21
  * Dev mode. Resolved from (in priority order):
21
22
  * 1. DOWNDRAFT_DEV=1 env var
@@ -277,6 +277,10 @@ export class HtmlUiHost {
277
277
  p.sabI32 = null;
278
278
  if (this.focusedPanel === id)
279
279
  this.focusedPanel = null;
280
+ if (this.pointerDownOn === id) {
281
+ this.pointerDownOn = null;
282
+ this.onPressChange?.(null);
283
+ }
280
284
  this.send({ type: "destroy", id });
281
285
  }
282
286
  /** Register a `ui://` resource (fonts/images/css) served to every doc. */
@@ -292,6 +296,10 @@ export class HtmlUiHost {
292
296
  set.add(fn);
293
297
  return () => { set.delete(fn); };
294
298
  }
299
+ /** Fires when a pointer press on a panel starts (panelId) and ends (null)
300
+ * — lets consumers hold DOM mutations stable across a click so a rebuild
301
+ * can't destroy the pressed element (or a just-focused input) mid-gesture. */
302
+ onPressChange = null;
295
303
  /** True when the pointer is inside any panel rect (UI owns the cursor).
296
304
  * Optional coords hit-test a specific point instead of the last seen
297
305
  * pointer position — the right check inside pointer-event handlers, where
@@ -436,13 +444,21 @@ export class HtmlUiHost {
436
444
  return;
437
445
  }
438
446
  ctrl.stopPropagation();
447
+ // A missed release (window blur mid-press) leaves pointerDownOn
448
+ // set — the new press supersedes it, so release the stale latch.
449
+ if (this.pointerDownOn)
450
+ this.onPressChange?.(null);
439
451
  this.pointerDownOn = p.id;
452
+ this.onPressChange?.(p.id);
440
453
  this.focusedPanel = p.id;
441
454
  const c = local(p, e);
442
455
  input(p.id, { kind: "down", ...c, button: btn(e.button), mods: modsOf(e) });
443
456
  }, -10), bus.onPointerUp((e, ctrl) => {
444
457
  const downOn = this.pointerDownOn ? this.panels.get(this.pointerDownOn) : null;
458
+ const hadDown = this.pointerDownOn !== null;
445
459
  this.pointerDownOn = null;
460
+ if (hadDown)
461
+ this.onPressChange?.(null);
446
462
  const p = downOn ?? this.panelAt(e.clientX, e.clientY);
447
463
  if (!p)
448
464
  return;
@@ -637,7 +653,7 @@ export class HtmlUiHost {
637
653
  else if (ev.t === "blur")
638
654
  p.editing = false;
639
655
  p.handlers.forEach((fn) => fn(ev));
640
- if ((ev.t === "click" || ev.t === "dblclick" || ev.t === "contextmenu") && ev.d?.action) {
656
+ if (ev.t === "click" && ev.d?.action) {
641
657
  const d = ev.d;
642
658
  (this.actionHandlers.get(d.action) ?? []).forEach((fn) => fn(d, ev));
643
659
  }
@@ -22,13 +22,13 @@ use std::time::Instant;
22
22
  use anyrender::ImageRenderer;
23
23
  use anyrender_vello_cpu::VelloCpuImageRenderer;
24
24
  use atomic_refcell::AtomicRefCell;
25
- use blitz_dom::{BaseDocument, Document, DocumentConfig, EventDriver, FontContext};
25
+ use blitz_dom::{BaseDocument, Document, DocumentConfig, EventDriver, FontContext, NodeId};
26
26
  use blitz_html::HtmlDocument;
27
27
  use blitz_paint::paint_scene;
28
28
  use blitz_traits::events::{
29
- BlitzKeyEvent, BlitzPointerEvent, BlitzPointerId, BlitzWheelDelta, BlitzWheelEvent,
30
- KeyState, MouseEventButton, MouseEventButtons, PointerCoords, PointerDetails,
31
- UiEvent,
29
+ BlitzFocusEvent, BlitzKeyEvent, BlitzPointerEvent, BlitzPointerId, BlitzWheelDelta,
30
+ BlitzWheelEvent, DomEvent, DomEventData, KeyState, MouseEventButton, MouseEventButtons,
31
+ PointerCoords, PointerDetails, UiEvent,
32
32
  };
33
33
  use blitz_traits::shell::{ColorScheme, ShellProvider, Viewport};
34
34
  use keyboard_types::{Code, Key, Location, Modifiers};
@@ -183,12 +183,17 @@ pub struct OsrDoc {
183
183
  frame_buf: Option<BoundBuf>,
184
184
  /// Currently-pressed mouse buttons (mirrors winit shell bookkeeping).
185
185
  buttons: MouseEventButtons,
186
+ /// Node under the last main-button press, via element_from_point. Blitz's
187
+ /// own mousedown_node_id stays None over background (hit() misses the
188
+ /// root), which click reconciliation needs to distinguish "press on page
189
+ /// chrome" from "no press at all".
190
+ press_node: Option<NodeId>,
186
191
  /// Last pointer position in physical px — wheel events reuse it.
187
192
  pointer_pos: (f64, f64),
188
193
  /// Active-pointer list shared into every dispatched event (multi-touch).
189
194
  active_pointers: Arc<AtomicRefCell<Vec<BlitzPointerEvent>>>,
190
195
  /// DOM events captured during dispatch — drained by dd_osr_poll_events.
191
- events: EventQueue,
196
+ pub(crate) events: EventQueue,
192
197
  /// Owned buffer returned by dd_osr_poll_events (JSON array).
193
198
  events_buf: Vec<u8>,
194
199
  /// Owned buffer for string-returning FFI calls (dd_osr_get_attr).
@@ -266,7 +271,14 @@ pub(crate) fn doc_enter(handle: *mut OsrDoc) -> Option<DocGuard> {
266
271
  fn doc_has_damage(d: &OsrDoc) -> bool {
267
272
  let doc = d.doc.inner();
268
273
  let root = doc.root_node();
269
- root.has_damaged_descendants() || root.damage().is_some_and(|dmg| !dmg.is_empty())
274
+ // has_damaged_descendants covers post-restyle layout/paint damage;
275
+ // has_dirty_descendants covers queued restyle work — pseudo-state flips
276
+ // (focus/active from clicks, :hover from moves) mark ancestors dirty but
277
+ // set no damage until resolve() restyles, and without this check a
278
+ // focus-ring change waits invisibly for the next event to flag damage.
279
+ root.has_damaged_descendants()
280
+ || root.has_dirty_descendants()
281
+ || root.damage().is_some_and(|dmg| !dmg.is_empty())
270
282
  }
271
283
 
272
284
  /// Fold pending repaint signals into `d.dirty`. Called after input dispatch
@@ -385,6 +397,7 @@ pub extern "C" fn dd_osr_init(
385
397
  stats: [0.0; 8],
386
398
  frame_buf: None,
387
399
  buttons: MouseEventButtons::None,
400
+ press_node: None,
388
401
  pointer_pos: (0.0, 0.0),
389
402
  active_pointers: Arc::new(AtomicRefCell::new(Vec::new())),
390
403
  events: new_event_queue(),
@@ -424,10 +437,17 @@ pub extern "C" fn dd_osr_set_html(handle: *mut OsrDoc, html_ptr: *const u8, html
424
437
  };
425
438
  let (w, h) = d.doc.inner().viewport().window_size;
426
439
  let scale = d.doc.inner().viewport().scale_f64() as f32;
440
+ // Focus dies with the doc — emit the blur so the host's editing
441
+ // state clears (the event queue is wiped with the rebuild, so this
442
+ // has to go back in after the clear).
443
+ let prev_focus = d.doc.inner().get_focussed_node_id();
427
444
  d.doc = build_doc(w, h, scale, html, Arc::clone(&d.redraw));
428
445
  d.buttons = MouseEventButtons::None;
429
446
  d.active_pointers.borrow_mut().clear();
430
447
  d.events.borrow_mut().clear();
448
+ if let Some(f) = prev_focus {
449
+ push_blur_event(d, f);
450
+ }
431
451
  d.prev_pixels.clear();
432
452
  d.dirty = true;
433
453
  0
@@ -778,6 +798,23 @@ pub extern "C" fn dd_osr_pointer(
778
798
  d.pointer_pos = (x, y);
779
799
  let mut event = make_pointer_event(d, x, y, dom_button(button));
780
800
  event.mods = kbt_modifiers(mods);
801
+ // Click reconciliation applies only to main-button releases that
802
+ // weren't scrollbar drags — checked before dispatch, which consumes
803
+ // the drag state. `had_down` records whether a main press is still
804
+ // outstanding (before arm 2 clears it) — a stray release must not
805
+ // synthesize a click.
806
+ let reconcile = kind == 2
807
+ && event.button == MouseEventButton::Main
808
+ && d.doc.inner().scrollbar_drag_target().is_none();
809
+ let main_down = kind == 1 && event.button == MouseEventButton::Main;
810
+ let events_before = if reconcile { d.events.borrow().len() } else { 0 };
811
+ let had_down = reconcile && d.buttons.contains(MouseEventButtons::Primary);
812
+ // Snapshot focus: a popped cross-subtree click may still have run
813
+ // blitz's clear-focus default action — it gets restored after.
814
+ let focus_before =
815
+ reconcile.then(|| d.doc.inner().get_focussed_node_id()).flatten();
816
+ // Cloned for reconcile_click — the event itself moves into ui_event.
817
+ let up_event = reconcile.then(|| event.clone());
781
818
  let ui_event = match kind {
782
819
  1 => {
783
820
  d.buttons |= event.button.into();
@@ -795,11 +832,257 @@ pub extern "C" fn dd_osr_pointer(
795
832
  _ => UiEvent::PointerMove(event),
796
833
  };
797
834
  dispatch(d, ui_event);
835
+ if main_down {
836
+ // Record the press target post-dispatch (layout is current now).
837
+ // mousedown_node_id misses root-element hits; element_from_point
838
+ // returns the root over background, so the release side can tell
839
+ // a real press from a stray up.
840
+ let doc = d.doc.inner();
841
+ let scale = doc.viewport().scale_f64();
842
+ d.press_node = doc
843
+ .get_mousedown_node_id()
844
+ .or_else(|| doc.element_from_point((x / scale) as f32, (y / scale) as f32));
845
+ }
846
+ if let Some(ev) = up_event {
847
+ let up_node = {
848
+ let doc = d.doc.inner();
849
+ let scale = doc.viewport().scale_f64();
850
+ doc.element_from_point((x / scale) as f32, (y / scale) as f32)
851
+ .or_else(|| doc.get_hover_node_id())
852
+ };
853
+ let down_node = d.press_node.or_else(|| d.doc.inner().get_mousedown_node_id());
854
+ d.press_node = None;
855
+ reconcile_click(d, &ev, events_before, had_down, focus_before, down_node, up_node);
856
+ }
798
857
  fold_redraw(d);
799
858
  0
800
859
  })
801
860
  }
802
861
 
862
+ /// Is `anc` an ancestor-or-self of `id`?
863
+ fn subtree_contains(doc: &BaseDocument, anc: NodeId, mut id: NodeId) -> bool {
864
+ loop {
865
+ if id == anc {
866
+ return true;
867
+ }
868
+ match doc.get_node(id).and_then(|n| n.parent) {
869
+ Some(p) => id = p,
870
+ None => return false,
871
+ }
872
+ }
873
+ }
874
+
875
+ /// Press and release landing inside the same element subtree — either
876
+ /// direction — counts as one click. Descendant-down → ancestor-up covers
877
+ /// release on a small control's own padding/children; ancestor-down →
878
+ /// descendant-up covers press on a container's bare area.
879
+ fn same_subtree(doc: &BaseDocument, a: NodeId, b: NodeId) -> bool {
880
+ subtree_contains(doc, a, b) || subtree_contains(doc, b, a)
881
+ }
882
+
883
+ /// A click whose release landed outside the pressed node's own subtree must
884
+ /// not move DOM focus — real DOMs drive focus from pointerdown, not the
885
+ /// release — but blitz's handle_click may already have run its clear-focus
886
+ /// default action. Restore the pre-release focus, re-emitting blur/focus so
887
+ /// host-side editing state stays consistent.
888
+ fn restore_focus(d: &mut OsrDoc, focus_before: Option<NodeId>) {
889
+ let Some(prev) = focus_before else { return };
890
+ let moved = d.doc.inner().get_focussed_node_id();
891
+ if moved == focus_before || d.doc.inner().get_node(prev).is_none() {
892
+ return;
893
+ }
894
+ d.doc.inner_mut().set_focus_to(prev);
895
+ emit_focus_change(d, moved);
896
+ }
897
+
898
+ /// Dispatch one DomEvent through an EventDriver so both the DOM-event queue
899
+ /// and blitz's default actions (focus changes, checkbox toggles, labels,
900
+ /// dblclick synthesis) run as they would for a natively emitted event.
901
+ fn dispatch_dom_event(d: &mut OsrDoc, event: DomEvent) {
902
+ let mut driver = EventDriver::new(
903
+ &mut d.doc,
904
+ QueueingHandler {
905
+ queue: std::rc::Rc::clone(&d.events),
906
+ },
907
+ );
908
+ driver.handle_dom_event(event);
909
+ }
910
+
911
+ /// Push a serialized blur for a node that no longer exists. DomEvent
912
+ /// dispatch on a removed id would panic, but the host still needs the blur
913
+ /// to clear its editing state when a mutation drops the focused element.
914
+ pub(crate) fn push_blur_event(d: &mut OsrDoc, node: NodeId) {
915
+ let mut q = d.events.borrow_mut();
916
+ if q.len() >= 4096 {
917
+ q.pop_front();
918
+ }
919
+ q.push_back(format!("{{\"t\":\"blur\",\"n\":{}}}", node.as_u64()));
920
+ }
921
+
922
+ /// Emit DOM blur/focus events for a focus transition that happened outside
923
+ /// the click path (programmatic dd_osr_focus, node removal). `prev` is the
924
+ /// pre-change focused node; the new focus is read from the doc. Nodes that
925
+ /// were removed get a raw JSON blur instead of a real DomEvent.
926
+ pub(crate) fn emit_focus_change(d: &mut OsrDoc, prev: Option<NodeId>) {
927
+ let cur = d.doc.inner().get_focussed_node_id();
928
+ if prev == cur {
929
+ return;
930
+ }
931
+ if let Some(old) = prev {
932
+ if d.doc.inner().get_node(old).is_some() {
933
+ dispatch_dom_event(d, DomEvent::new(old, DomEventData::Blur(BlitzFocusEvent)));
934
+ dispatch_dom_event(d, DomEvent::new(old, DomEventData::FocusOut(BlitzFocusEvent)));
935
+ } else {
936
+ push_blur_event(d, old);
937
+ }
938
+ }
939
+ if let Some(new) = cur {
940
+ if d.doc.inner().get_node(new).is_some() {
941
+ dispatch_dom_event(d, DomEvent::new(new, DomEventData::Focus(BlitzFocusEvent)));
942
+ dispatch_dom_event(d, DomEvent::new(new, DomEventData::FocusIn(BlitzFocusEvent)));
943
+ }
944
+ }
945
+ }
946
+
947
+ /// Releases that drift off the pressed element onto an ancestor by less than
948
+ /// this many logical px are treated as click jitter and retargeted to the
949
+ /// pressed node. Bigger outward drifts keep the browser-like outcome — the
950
+ /// click lands on the ancestor container itself.
951
+ const RELEASE_JITTER_PX: f32 = 8.0;
952
+
953
+ /// Node border-box rect in document space: [x, y, w, h], matching
954
+ /// PointerCoords.page_x/page_y.
955
+ fn node_page_rect(doc: &BaseDocument, id: NodeId) -> Option<[f32; 4]> {
956
+ let node = doc.get_node(id)?;
957
+ let pos = node.unrounded_absolute_position(0.0, 0.0);
958
+ let size = node.final_layout().size;
959
+ Some([
960
+ pos.x as f32,
961
+ pos.y as f32,
962
+ size.width as f32,
963
+ size.height as f32,
964
+ ])
965
+ }
966
+
967
+ /// Drop `click`/`dblclick` events this dispatch pushed onto the queue — used
968
+ /// when the native click's target (or existence) is being overridden.
969
+ fn pop_queued_clicks(d: &mut OsrDoc, events_before: usize) {
970
+ let mut q = d.events.borrow_mut();
971
+ let mut i = events_before;
972
+ while i < q.len() {
973
+ let s = &q[i];
974
+ if s.starts_with("{\"t\":\"click\"") || s.starts_with("{\"t\":\"dblclick\"") {
975
+ q.remove(i);
976
+ } else {
977
+ i += 1;
978
+ }
979
+ }
980
+ }
981
+
982
+ /// Post-dispatch click reconciliation for main-button releases.
983
+ ///
984
+ /// Blitz gates `click` on `drag_mode == None`: >2px of pointer travel during
985
+ /// a press enters `Selecting` and eats the click entirely — real-world
986
+ /// mouse jitter — including the focus-clearing default action. And when no
987
+ /// drag mode starts (e.g. the pressed node has user-select:none) it emits
988
+ /// the click on the release node even when the press began on a different
989
+ /// element, so a press-drag-release onto another control activates it.
990
+ ///
991
+ /// Policy here follows nearest-common-ancestor click semantics: a click is
992
+ /// synthesized iff a main-button press was outstanding and press and release
993
+ /// hit the same element subtree. When the press node contains the release
994
+ /// (press on chrome, release on a child control), the click targets the
995
+ /// PRESS node — controls don't activate for gestures that started outside
996
+ /// them. When the release lands on an ancestor of the press (a jittered
997
+ /// release just outside a small control's border), it retargets to the press
998
+ /// node within RELEASE_JITTER_PX; further out it's a genuine container
999
+ /// click. Releases on unrelated subtrees — or with no press at all — drop
1000
+ /// the click entirely.
1001
+ fn reconcile_click(
1002
+ d: &mut OsrDoc,
1003
+ event: &BlitzPointerEvent,
1004
+ events_before: usize,
1005
+ had_down: bool,
1006
+ focus_before: Option<NodeId>,
1007
+ down: Option<NodeId>,
1008
+ up: Option<NodeId>,
1009
+ ) {
1010
+ let emitted = {
1011
+ let q = d.events.borrow();
1012
+ (events_before..q.len()).any(|i| q[i].starts_with("{\"t\":\"click\""))
1013
+ };
1014
+ let related = had_down
1015
+ && match (down, up) {
1016
+ (Some(a), Some(b)) => same_subtree(&d.doc.inner(), a, b),
1017
+ _ => false,
1018
+ };
1019
+ // The release-side focus move blitz may have applied is only undone when
1020
+ // the press began inside the element that held focus (dragging out of a
1021
+ // focused input keeps it focused in a real DOM) — or when there was no
1022
+ // press at all (stray release). A press that started outside the focused
1023
+ // node legitimately steals focus, like mousedown does in a real DOM.
1024
+ let press_in_focused = match (down, focus_before) {
1025
+ (Some(dn), Some(f)) => subtree_contains(&d.doc.inner(), dn, f),
1026
+ _ => false,
1027
+ };
1028
+ let undo_focus = down.is_none() || press_in_focused;
1029
+
1030
+ if related {
1031
+ let (down, up) = (down.unwrap(), up.unwrap());
1032
+ // Release strictly on an ancestor of the press: jitter forgiveness
1033
+ // retargets to the press node when the release point is near its
1034
+ // rect; a far release clicks the ancestor itself.
1035
+ let released_onto_ancestor = !subtree_contains(&d.doc.inner(), down, up);
1036
+ let near_press = released_onto_ancestor
1037
+ && node_page_rect(&d.doc.inner(), down).is_some_and(|r| {
1038
+ let px = event.coords.page_x;
1039
+ let py = event.coords.page_y;
1040
+ px >= r[0] - RELEASE_JITTER_PX
1041
+ && px <= r[0] + r[2] + RELEASE_JITTER_PX
1042
+ && py >= r[1] - RELEASE_JITTER_PX
1043
+ && py <= r[1] + r[3] + RELEASE_JITTER_PX
1044
+ });
1045
+ let target = if released_onto_ancestor && !near_press {
1046
+ up
1047
+ } else {
1048
+ down
1049
+ };
1050
+ if target == up {
1051
+ // Suppressed-by-drag clicks on the right target get re-fired;
1052
+ // emitted ones stand.
1053
+ if !emitted {
1054
+ dispatch_dom_event(
1055
+ d,
1056
+ DomEvent::new(up, DomEventData::Click(event.clone())),
1057
+ );
1058
+ }
1059
+ } else {
1060
+ // Retarget: drop the emitted ancestor/release-node click, then
1061
+ // dispatch the click on the press node — its default action also
1062
+ // settles focus the way mousedown semantics dictate.
1063
+ if emitted {
1064
+ pop_queued_clicks(d, events_before);
1065
+ }
1066
+ dispatch_dom_event(d, DomEvent::new(down, DomEventData::Click(event.clone())));
1067
+ }
1068
+ // A far release onto an ancestor still cleared focus through the
1069
+ // kept ancestor click — restore it for drag-outs that began inside
1070
+ // the focused element.
1071
+ if released_onto_ancestor && !near_press && undo_focus {
1072
+ restore_focus(d, focus_before);
1073
+ }
1074
+ } else {
1075
+ // Unrelated subtrees or a release with no press at all: the emitted
1076
+ // click (and the dblclick it may have spawned) is void.
1077
+ if emitted {
1078
+ pop_queued_clicks(d, events_before);
1079
+ }
1080
+ if undo_focus {
1081
+ restore_focus(d, focus_before);
1082
+ }
1083
+ }
1084
+ }
1085
+
803
1086
  /// Wheel event at the last pointer position. Deltas are in pixels using
804
1087
  /// Blitz/winit semantics (+y = scroll up); the JS wrapper negates DOM
805
1088
  /// WheelEvent deltas before crossing the FFI.
@@ -10,7 +10,7 @@ use blitz_dom::{
10
10
  ns,
11
11
  };
12
12
 
13
- use crate::{OsrDoc, doc_enter, ffi, read_str};
13
+ use crate::{OsrDoc, doc_enter, emit_focus_change, ffi, read_str};
14
14
 
15
15
  fn attr_qname(name: &str) -> QualName {
16
16
  QualName::new(None, ns!(), LocalName::from(name))
@@ -40,10 +40,19 @@ fn with_node_mutator(
40
40
  if d.doc.inner().get_node(id).is_none() {
41
41
  return -1; // stale/missing node — no-op
42
42
  }
43
- let mut doc = d.doc.inner_mut();
44
- let mut mutator = DocumentMutator::new(&mut doc);
45
- let r = f(&mut mutator, id);
46
- drop(mutator); // flush mutations
43
+ // Mutations can drop the focused node (innerHTML rebuilds, child
44
+ // trims) — blitz clears focus_node_id silently, which would leave
45
+ // the host's editing state latched on a dead element. Diff and emit
46
+ // the blur.
47
+ let prev_focus = d.doc.inner().get_focussed_node_id();
48
+ let r = {
49
+ let mut doc = d.doc.inner_mut();
50
+ let mut mutator = DocumentMutator::new(&mut doc);
51
+ let r = f(&mut mutator, id);
52
+ drop(mutator); // flush mutations
53
+ r
54
+ };
55
+ emit_focus_change(d, prev_focus);
47
56
  d.dirty = true;
48
57
  r
49
58
  })
@@ -296,6 +305,8 @@ pub extern "C" fn dd_osr_out_len(handle: *mut OsrDoc) -> usize {
296
305
  }
297
306
 
298
307
  /// Focus a node programmatically (mirrors click-focus). Pass 0 to blur.
308
+ /// Emits the DOM blur/focus events a click would, so host-side editing
309
+ /// state tracks programmatic focus too.
299
310
  #[no_mangle]
300
311
  pub extern "C" fn dd_osr_focus(handle: *mut OsrDoc, node: u64) -> c_int {
301
312
  ffi(-1, || {
@@ -303,16 +314,20 @@ pub extern "C" fn dd_osr_focus(handle: *mut OsrDoc, node: u64) -> c_int {
303
314
  return -1;
304
315
  };
305
316
  let d = g.get();
306
- let mut doc = d.doc.inner_mut();
307
- if node == 0 {
308
- doc.clear_focus();
309
- } else {
310
- let id = node_id(node);
311
- if doc.get_node(id).is_none() {
312
- return -1;
317
+ let prev = d.doc.inner().get_focussed_node_id();
318
+ {
319
+ let mut doc = d.doc.inner_mut();
320
+ if node == 0 {
321
+ doc.clear_focus();
322
+ } else {
323
+ let id = node_id(node);
324
+ if doc.get_node(id).is_none() {
325
+ return -1;
326
+ }
327
+ doc.set_focus_to(id);
313
328
  }
314
- doc.set_focus_to(id);
315
329
  }
330
+ emit_focus_change(d, prev);
316
331
  d.dirty = true;
317
332
  0
318
333
  })
@@ -446,6 +446,7 @@ export class HtmlUiHost {
446
446
  p.sab = null;
447
447
  p.sabI32 = null;
448
448
  if (this.focusedPanel === id) this.focusedPanel = null;
449
+ if (this.pointerDownOn === id) { this.pointerDownOn = null; this.onPressChange?.(null); }
449
450
  this.send({ type: "destroy", id });
450
451
  }
451
452
 
@@ -463,6 +464,11 @@ export class HtmlUiHost {
463
464
  return () => { set.delete(fn); };
464
465
  }
465
466
 
467
+ /** Fires when a pointer press on a panel starts (panelId) and ends (null)
468
+ * — lets consumers hold DOM mutations stable across a click so a rebuild
469
+ * can't destroy the pressed element (or a just-focused input) mid-gesture. */
470
+ onPressChange: ((panelId: string | null) => void) | null = null;
471
+
466
472
  /** True when the pointer is inside any panel rect (UI owns the cursor).
467
473
  * Optional coords hit-test a specific point instead of the last seen
468
474
  * pointer position — the right check inside pointer-event handlers, where
@@ -597,14 +603,20 @@ export class HtmlUiHost {
597
603
  return;
598
604
  }
599
605
  ctrl.stopPropagation();
606
+ // A missed release (window blur mid-press) leaves pointerDownOn
607
+ // set — the new press supersedes it, so release the stale latch.
608
+ if (this.pointerDownOn) this.onPressChange?.(null);
600
609
  this.pointerDownOn = p.id;
610
+ this.onPressChange?.(p.id);
601
611
  this.focusedPanel = p.id;
602
612
  const c = local(p, e);
603
613
  input(p.id, { kind: "down", ...c, button: btn(e.button), mods: modsOf(e) });
604
614
  }, -10),
605
615
  bus.onPointerUp((e: PointerEvent, ctrl: InputEventControl) => {
606
616
  const downOn = this.pointerDownOn ? this.panels.get(this.pointerDownOn) : null;
617
+ const hadDown = this.pointerDownOn !== null;
607
618
  this.pointerDownOn = null;
619
+ if (hadDown) this.onPressChange?.(null);
608
620
  const p = downOn ?? this.panelAt(e.clientX, e.clientY);
609
621
  if (!p) return;
610
622
  ctrl.stopPropagation();
@@ -787,7 +799,7 @@ export class HtmlUiHost {
787
799
  if (ev.t === "focus") p.editing = ev.g === "input" || ev.g === "textarea";
788
800
  else if (ev.t === "blur") p.editing = false;
789
801
  p.handlers.forEach((fn) => fn(ev));
790
- if ((ev.t === "click" || ev.t === "dblclick" || ev.t === "contextmenu") && ev.d?.action) {
802
+ if (ev.t === "click" && ev.d?.action) {
791
803
  const d = ev.d;
792
804
  (this.actionHandlers.get(d.action) ?? []).forEach((fn) => fn(d, ev));
793
805
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@downdraft/engine",
3
- "version": "0.4.0",
3
+ "version": "0.4.2",
4
4
  "repository": {
5
5
  "type": "git",
6
6
  "url": "https://github.com/knackstedt/downdraft.git",
@@ -864,7 +864,7 @@
864
864
  "zustand": "^5.0.2"
865
865
  },
866
866
  "peerDependencies": {
867
- "@downdraft/platform-native": "^0.4.0"
867
+ "@downdraft/platform-native": "^0.4.2"
868
868
  },
869
869
  "peerDependenciesMeta": {
870
870
  "@downdraft/platform-native": {