@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.
- package/core/src/platform/runtime.ts +3 -2
- package/dist/.dd-manifest.json +822 -822
- package/dist/core/src/platform/runtime.js +3 -2
- package/dist/modules/html-ui/src/host.js +17 -1
- package/libraries/blitz-ui/native-osr/src/lib.rs +289 -6
- package/libraries/blitz-ui/native-osr/src/mutate.rs +28 -13
- package/modules/html-ui/src/host.ts +13 -1
- package/package.json +2 -2
|
@@ -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>`
|
|
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
|
|
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 (
|
|
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,
|
|
30
|
-
|
|
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
|
-
|
|
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
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
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
|
|
307
|
-
|
|
308
|
-
doc.
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
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 (
|
|
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.
|
|
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.
|
|
867
|
+
"@downdraft/platform-native": "^0.4.2"
|
|
868
868
|
},
|
|
869
869
|
"peerDependenciesMeta": {
|
|
870
870
|
"@downdraft/platform-native": {
|