Skip to content

Commit 244e8dd

Browse files
committed
feat(cursor): move click impact to the cursor settings, for every camera
The zoom pane's per-region "Click impact" becomes a project-wide cursor setting (`cursor.clickImpact`). A fixed angle still rocks the screen and the moving camera still recoils. A flat screen, with no 3D camera or outside any zoom, is now pushed back on each click, on the same tap curve. A document that had it on a zoom turns the cursor setting on when opened.
1 parent c6bbc15 commit 244e8dd

47 files changed

Lines changed: 297 additions & 350 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

‎crates/compositor/src/frame_geometry.rs‎

Lines changed: 69 additions & 35 deletions
Large diffs are not rendered by default.

‎crates/compositor/src/regions.rs‎

Lines changed: 16 additions & 58 deletions
Original file line numberDiff line numberDiff line change
@@ -466,13 +466,8 @@ pub struct ZoomState {
466466
pub rotation: [f32; 3],
467467
/// À quel point un préset 3D est installé (0..1) : la force de la région quand elle en porte
468468
/// un, 0 sinon ; interpolé entre deux régions chaînées, et refermé en milieu de course
469-
/// quand leurs présets diffèrent. C'est la porte de `dynamic_tilt`.
469+
/// quand leurs présets diffèrent. C'est la porte de `dynamic_tilt` (`tilt_gate`).
470470
pub tilt: f32,
471-
/// Poids de l'impact du clic (0..1) : 1 sur une région qui l'active (`click_impact`),
472-
/// interpolé entre deux régions chaînées. Ne suffit pas seul : sous un angle fixe, l'impact
473-
/// passe aussi par la porte de `tilt` (`dynamic_tilt`) ; sous la caméra réelle, par son poids
474-
/// (l'œil recule, `camera::PRESS`).
475-
pub click_impact: f32,
476471
/// Poids de la caméra réelle (`camera.rs`, 0..1) : la force de la région `follow-cursor`, 0
477472
/// sinon. Jamais non nul en même temps que `rotation` (cf. la transition chaînée).
478473
pub camera: f32,
@@ -487,7 +482,6 @@ const IDENTITY_ZOOM: ZoomState = ZoomState {
487482
focus: [0.5, 0.5],
488483
rotation: [0.0, 0.0, 0.0],
489484
tilt: 0.0,
490-
click_impact: 0.0,
491485
camera: 0.0,
492486
aim: [0.5, 0.5],
493487
orbit: [0.5, 0.5],
@@ -573,16 +567,6 @@ fn weighted(p: [f32; 2], camera: f32) -> [f32; 2] {
573567
p.map(|a| 0.5 + (a - 0.5) * camera)
574568
}
575569

576-
/// 1 si la région active l'impact du clic, 0 sinon. Sous un angle fixe, le clic presse l'écran ;
577-
/// sous la caméra réelle, l'écran reste immobile et c'est l'œil qui recule.
578-
fn impact_flag(region: &SceneZoomRegion) -> f32 {
579-
if region.click_impact {
580-
1.0
581-
} else {
582-
0.0
583-
}
584-
}
585-
586570
/// Port de `easeConnectedPan` (TS) : cubic-bezier(0.1, 0, 0.2, 1).
587571
fn ease_connected_pan(t: f32) -> f32 {
588572
cubic_bezier(0.1, 0.0, 0.2, 1.0, t)
@@ -883,7 +867,6 @@ pub fn zoom_state_in(
883867
],
884868
rotation,
885869
tilt: lerp(fixed_flag(cur), fixed_flag(next), progress) * crossing,
886-
click_impact: lerp(impact_flag(cur), impact_flag(next), progress),
887870
camera,
888871
aim: weighted(mix(a.aim, b.aim), camera),
889872
orbit: weighted(mix(a.orbit, b.orbit), camera),
@@ -902,7 +885,6 @@ pub fn zoom_state_in(
902885
focus: resolve_focus(next, t, cursor, clock),
903886
rotation: fixed_rotation(next),
904887
tilt: fixed_flag(next),
905-
click_impact: impact_flag(next),
906888
camera,
907889
aim: weighted(seen.aim, camera),
908890
orbit: weighted(seen.orbit, camera),
@@ -956,7 +938,6 @@ pub fn zoom_state_in(
956938
focus: [ease(focus[0]), ease(focus[1])],
957939
rotation: lerp_rotation3d([0.0, 0.0, 0.0], fixed_rotation(r), strength),
958940
tilt: fixed_flag(r) * strength,
959-
click_impact: impact_flag(r),
960941
camera,
961942
aim: weighted(seen.aim, camera),
962943
orbit: weighted(seen.orbit, camera),
@@ -1389,7 +1370,7 @@ pub fn dynamic_tilt(
13891370
strength: f32,
13901371
impact: [f32; 3],
13911372
) -> [f32; 3] {
1392-
let gate = smoothstep(PARALLAX_GATE_START, 1.0, strength);
1373+
let gate = tilt_gate(strength);
13931374
if gate <= 0.0 {
13941375
return [0.0; 3];
13951376
}
@@ -1414,6 +1395,13 @@ pub fn dynamic_tilt(
14141395
clamp_dynamic_tilt(sum).map(|d| d * gate)
14151396
}
14161397

1398+
/// La porte de `dynamic_tilt` : 0 tant qu'un angle fixe n'est pas installé, 1 une fois en place.
1399+
/// Sur l'écran qu'elle laisse droit, l'impact du clic fait reculer l'écran à la place
1400+
/// (`frame_geometry::plan_frame`) : aucune bascule ne part de l'écran droit.
1401+
pub fn tilt_gate(strength: f32) -> f32 {
1402+
smoothstep(PARALLAX_GATE_START, 1.0, strength)
1403+
}
1404+
14171405
/// Durée de l'impact du clic : la fenêtre de `CursorTrack::bounce`, pour que le plan et le
14181406
/// pointeur lisent comme un seul contact.
14191407
pub const CLICK_IMPACT_WINDOW_S: f32 = 0.26;
@@ -1488,9 +1476,9 @@ pub fn click_impact(
14881476
]
14891477
}
14901478

1491-
/// Le recul de la caméra réelle au temps `t` : la somme des `tap` des mêmes clics que
1492-
/// `click_impact` (dans la fenêtre du clip, visibles), bornée à [−1, 1], −1 au contact. Sans leur
1493-
/// position : l'écran ne bascule pas, l'œil recule tout droit.
1479+
/// Le recul au temps `t`, celui de l'œil sous la caméra réelle ou celui de l'écran droit : la somme
1480+
/// des `tap` des mêmes clics que `click_impact` (dans la fenêtre du clip, visibles), bornée à
1481+
/// [−1, 1], −1 au contact. Sans leur position : rien ne bascule, tout recule droit.
14941482
pub fn click_press(
14951483
t: f32,
14961484
track: &CursorTrack,
@@ -1615,7 +1603,6 @@ mod zoom_focus_tests {
16151603
rotation: None,
16161604
under_trim: false,
16171605
hide_cursor: false,
1618-
click_impact: false,
16191606
}
16201607
}
16211608

@@ -2494,7 +2481,6 @@ mod tilt_tests {
24942481
rotation: rotation.map(Into::into),
24952482
under_trim: false,
24962483
hide_cursor: false,
2497-
click_impact: false,
24982484
};
24992485
// `follow-cursor` n'incline pas l'écran (caméra réelle, `camera.rs`) : chaînée à un angle
25002486
// fixe, la transition passe par l'écran droit, que ce balayage couvre aussi.
@@ -2902,31 +2888,6 @@ mod tilt_tests {
29022888
}
29032889
}
29042890

2905-
#[test]
2906-
fn the_click_impact_weight_follows_the_region_flag() {
2907-
let region = |click_impact: bool| SceneZoomRegion {
2908-
id: "z".into(),
2909-
clip_index: None,
2910-
start_sec: 2.0,
2911-
end_sec: 8.0,
2912-
scale: 2.0,
2913-
focus_x: 0.5,
2914-
focus_y: 0.5,
2915-
focus_mode: None,
2916-
rotation: Some("left".into()),
2917-
under_trim: false,
2918-
hide_cursor: false,
2919-
click_impact,
2920-
};
2921-
assert_eq!(zoom_state_at(&[region(true)], 5.0, None).click_impact, 1.0);
2922-
assert_eq!(zoom_state_at(&[region(false)], 5.0, None).click_impact, 0.0);
2923-
assert_eq!(
2924-
zoom_state_at(&[region(true)], 0.0, None).click_impact,
2925-
0.0,
2926-
"hors région"
2927-
);
2928-
}
2929-
29302891
/// `ZoomState::tilt` : la force pour une région à préset, 0 sans préset.
29312892
#[test]
29322893
fn the_tilt_gate_follows_the_region_strength_only_with_a_preset() {
@@ -2942,7 +2903,6 @@ mod tilt_tests {
29422903
rotation: rotation.map(Into::into),
29432904
under_trim: false,
29442905
hide_cursor: false,
2945-
click_impact: false,
29462906
};
29472907
assert_eq!(zoom_state_at(&[region(Some("left"))], 5.0, None).tilt, 1.0);
29482908
assert_eq!(zoom_state_at(&[region(None)], 5.0, None).tilt, 0.0);
@@ -3249,7 +3209,6 @@ mod follow_camera_tests {
32493209
rotation: Some(rotation.into()),
32503210
under_trim: false,
32513211
hide_cursor: false,
3252-
click_impact: true,
32533212
}
32543213
}
32553214

@@ -3278,17 +3237,16 @@ mod follow_camera_tests {
32783237

32793238
/// Sous `follow-cursor`, l'écran n'est pas incliné et ne glisse pas (focus au centre, quel que
32803239
/// soit le réglage de la région) : seule la caméra, de poids la force de la région, vise et
3281-
/// tourne avec le pointeur. L'impact du clic passe (l'œil recule). Hors région, l'état plat
3282-
/// exact.
3240+
/// tourne avec le pointeur. Hors région, l'état plat exact.
32833241
#[test]
32843242
fn follow_cursor_rides_the_zoom_envelope() {
32853243
let tr = track(|_| (0.85, 0.5));
32863244
let f = whole(&tr);
32873245
let r = [region("follow-cursor", 2.0, 8.0)];
32883246
let full = zoom_state_in(&r, 5.0, Some(&tr), &f, &ScreenClock::default());
32893247
assert_eq!(
3290-
(full.rotation, full.tilt, full.click_impact, full.camera),
3291-
([0.0; 3], 0.0, 1.0, 1.0)
3248+
(full.rotation, full.tilt, full.camera),
3249+
([0.0; 3], 0.0, 1.0)
32923250
);
32933251
assert_eq!((full.scale, full.focus), (2.0, [0.5, 0.5]));
32943252
assert!(
@@ -3326,7 +3284,7 @@ mod follow_camera_tests {
33263284
assert_eq!((blind.aim, blind.orbit), ([0.5, 0.5], [0.5, 0.5]));
33273285
// Un angle fixe garde exactement son état.
33283286
let left = zoom_state_in(&[region("left", 2.0, 8.0)], 5.0, Some(&tr), &f, &ScreenClock::default());
3329-
assert_eq!((left.rotation, left.camera, left.click_impact), ([-12.0, -18.0, -2.0], 0.0, 1.0));
3287+
assert_eq!((left.rotation, left.camera), ([-12.0, -18.0, -2.0], 0.0));
33303288
let unknown = zoom_state_in(&[region("orbit", 2.0, 8.0)], 5.0, Some(&tr), &f, &ScreenClock::default());
33313289
assert_eq!((unknown.rotation, unknown.tilt, unknown.camera), ([0.0; 3], 0.0, 0.0));
33323290
}

‎crates/compositor/src/scene.rs‎

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -490,11 +490,6 @@ pub struct SceneZoomRegion {
490490
/// Masque le curseur pendant cette région de zoom.
491491
#[serde(default)]
492492
pub hide_cursor: bool,
493-
/// Chaque clic enfonce le plan incliné (`regions::click_impact`). Sans effet hors préset
494-
/// 3D : c'est le préset qui installe le plan que le clic fait basculer.
495-
/// `#[serde(default)]` : l'app omet la clé quand elle est fausse.
496-
#[serde(default)]
497-
pub click_impact: bool,
498493
}
499494

500495
/// Une zone de vitesse portée par le temps source d'un clip.
@@ -540,6 +535,12 @@ pub struct SceneCursor {
540535
/// projets et des JSON écrits avant le réglage, qui gardent donc le curseur plat.
541536
#[serde(default)]
542537
pub model3d: bool,
538+
/// Chaque clic donne un impact à l'écran, quelle que soit la caméra : sous un angle fixe le
539+
/// plan bascule du côté cliqué (`regions::click_impact`), sous la caméra réelle l'œil recule,
540+
/// sur un écran droit l'écran recule (`frame_geometry::plan_frame`).
541+
/// `#[serde(default)]` : l'app omet la clé quand elle est fausse.
542+
#[serde(default)]
543+
pub click_impact: bool,
543544
pub clip_to_bounds: bool,
544545
/// id du thème (jeu de sprites) — informatif ici : le natif consomme `cursor_sprites`.
545546
pub theme: String,

‎crates/compositor/tests/click_impact_render.rs‎

Lines changed: 24 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
1-
//! Sous un préset 3D avec « Click impact », un clic fait basculer le plan du côté cliqué puis le
2-
//! laisse revenir (`regions::click_impact`). Rend de vraies frames par le compositeur D3D11.
1+
//! Avec « Click impact » (réglage du curseur), un clic fait basculer le plan d'un préset 3D du
2+
//! côté cliqué puis le laisse revenir (`regions::click_impact`) ; sans préset, il fait reculer
3+
//! l'écran droit. Rend de vraies frames par le compositeur D3D11.
34
//!
45
//! Même harnais que `tilt_parallax_render.rs`, mêmes variables d'environnement : une source
56
//! 1920×1080 de 6 s, de préférence quadrillée. La piste curseur (immobile, un clic) est écrite
@@ -40,20 +41,20 @@ fn write_sidecar(path: &std::path::Path) {
4041
std::fs::write(path, format!(r#"{{"samples":[{}]}}"#, samples.join(","))).expect("sidecar");
4142
}
4243

43-
fn scene_json(source: &str, click_impact: bool) -> String {
44+
fn scene_json(source: &str, rotation: &str, click_impact: bool) -> String {
4445
let s = source.replace('\\', "/");
45-
let flag = if click_impact { r#","clickImpact":true"# } else { "" };
46+
let flag = if click_impact { r#""clickImpact":true,"# } else { "" };
4647
format!(
4748
r##"{{
4849
"clips": [{{"screenPath":"{s}","webcamPath":"","sourceStartSec":0,"sourceEndSec":6,"webcamOffsetSec":0,"hasAudio":false}}],
4950
"layout": {{"preset":"no-webcam","webcamSize":1.0,"webcamShape":"rounded","webcamMirror":false,"webcamPosition":null,"webcamReactiveZoom":false,
5051
"screenRect":{{"x":0.15,"y":0.15,"width":0.7,"height":0.7}}}},
5152
"effects": {{"padding":0.1,"blur":false,"shadow":0.5,"roundnessFrac":0.02,"motionBlur":0.0}},
5253
"background": {{"kind":"color","color":"#2060d0"}},
53-
"zoomRegions": [{{"id":"z","startSec":0,"endSec":6,"scale":1.15,"focusX":0.5,"focusY":0.5,"focusMode":"manual","rotation":"iso"{flag}}}],
54+
"zoomRegions": [{{"id":"z","startSec":0,"endSec":6,"scale":1.15,"focusX":0.5,"focusY":0.5,"focusMode":"manual","rotation":"{rotation}"}}],
5455
"annotations": [],
5556
"speedRegions": [],
56-
"cursor": {{"show":true,"size":1,"smoothing":0,"motionBlur":0,"clickBounce":0,"clipToBounds":false,"theme":"default"}},
57+
"cursor": {{{flag}"show":true,"size":1,"smoothing":0,"motionBlur":0,"clickBounce":0,"clipToBounds":false,"theme":"default"}},
5758
"cropByClip": [null],
5859
"output": {{"width":{W},"height":{H},"fps":null}}
5960
}}"##
@@ -116,8 +117,8 @@ fn a_click_presses_the_clicked_side_of_the_tilted_plane() {
116117
let comp = Compositor::new_sized(&gpu, W, H).expect("compositor");
117118
comp.set_cursor(track.smoothed(0.0));
118119

119-
let render = |click_impact: bool, t: f64| -> Vec<u8> {
120-
let scene = Scene::from_json(&scene_json(&source, click_impact)).expect("scene valide");
120+
let render = |rotation: &str, click_impact: bool, t: f64| -> Vec<u8> {
121+
let scene = Scene::from_json(&scene_json(&source, rotation, click_impact)).expect("scene valide");
121122
comp.set_live_params(live_params_from_scene(&scene));
122123
comp.set_scene(Some(scene));
123124
unsafe {
@@ -141,15 +142,15 @@ fn a_click_presses_the_clicked_side_of_the_tilted_plane() {
141142
};
142143
let mut frames = Vec::new();
143144
for (name, t) in instants {
144-
let rgba = render(true, t);
145+
let rgba = render("iso", true, t);
145146
if let Some(dir) = &out_dir {
146147
write_ppm(&dir.join(format!("click-{name}.ppm")), &rgba);
147148
}
148149
let (l, r, hr, hl) = probe(&rgba);
149150
println!("{name:<12} t={t:<6} bords {l:>4}..{r:>4} hauteur droite {hr:>4} gauche {hl:>4}");
150151
frames.push((rgba, (l, r, hr, hl)));
151152
}
152-
let off = render(false, CLICK_S + 0.0495);
153+
let off = render("iso", false, CLICK_S + 0.0495);
153154
if let Some(dir) = &out_dir {
154155
write_ppm(&dir.join("click-off-at-contact.ppm"), &off);
155156
}
@@ -172,4 +173,17 @@ fn a_click_presses_the_clicked_side_of_the_tilted_plane() {
172173
assert!(contact.0 < rest.0, "bord gauche {} au repos {}", contact.0, rest.0);
173174
assert!(contact.2 < rest.2, "hauteur droite {} au repos {}", contact.2, rest.2);
174175
assert!(contact.3 > rest.3, "hauteur gauche {} au repos {}", contact.3, rest.3);
176+
177+
// Sans préset, rien ne bascule : l'écran droit recule au contact, les quatre bords rentrent.
178+
let flat_rest = render("none", true, CLICK_S - 0.5);
179+
let flat_contact = render("none", true, CLICK_S + 0.0495);
180+
let flat_off = render("none", false, CLICK_S + 0.0495);
181+
if let Some(dir) = &out_dir {
182+
write_ppm(&dir.join("click-flat-contact.ppm"), &flat_contact);
183+
}
184+
let (rest, contact) = (probe(&flat_rest), probe(&flat_contact));
185+
println!("écran droit : repos {rest:?}, contact {contact:?}");
186+
assert_eq!(silhouette_diff(&flat_off, &flat_rest), 0, "sans l'option, le clic ne doit rien changer");
187+
assert!(contact.0 > rest.0 && contact.1 < rest.1, "bords {contact:?} au repos {rest:?}");
188+
assert!(contact.2 < rest.2 && contact.3 < rest.3, "hauteurs {contact:?} au repos {rest:?}");
175189
}

‎crates/compositor/tests/follow_camera_render.rs‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -380,7 +380,7 @@ fn orbit_scene(source: &str, regions: &str, (w, h): (u32, u32)) -> Scene {
380380
"background":{{"kind":"gradient","angleDeg":135,"stops":["#3b4fd1","#c86fa6"]}},
381381
"zoomRegions":[{regions}],
382382
"annotations":[],
383-
"cursor":{{"show":true,"size":2.2,"smoothing":0,"motionBlur":0,"clickBounce":2.5,"model3d":true,"clipToBounds":false,"theme":"default",
383+
"cursor":{{"show":true,"size":2.2,"smoothing":0,"motionBlur":0,"clickBounce":2.5,"model3d":true,"clickImpact":true,"clipToBounds":false,"theme":"default",
384384
"cursorSprites":{{"arrow":{{"path":"{arrow}","hotspotX":0.1205,"hotspotY":0.0881}}}}}},
385385
"cropByClip":[null],
386386
"output":{{"width":{w},"height":{h},"fps":30}}}}"##
@@ -390,7 +390,7 @@ fn orbit_scene(source: &str, regions: &str, (w, h): (u32, u32)) -> Scene {
390390

391391
fn orbit_region(start: f32, end: f32, scale: f32) -> String {
392392
format!(
393-
r#"{{"clipIndex":0,"startSec":{start},"endSec":{end},"scale":{scale},"focusX":0.5,"focusY":0.5,"focusMode":"manual","rotation":"follow-cursor","clickImpact":true}}"#
393+
r#"{{"clipIndex":0,"startSec":{start},"endSec":{end},"scale":{scale},"focusX":0.5,"focusY":0.5,"focusMode":"manual","rotation":"follow-cursor"}}"#
394394
)
395395
}
396396

‎docs/3d-effects-v2.md‎

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,17 @@ deux effets qui partagent un unique budget d'angle dynamique (`DYNAMIC_TILT_BUDG
2525
`rotated_quad_corners_px(w, h, base, dyn)` projette les coins ; l'échelle de containment est
2626
calculée sur la **base seule** (« échelle gelée »), la part dynamique ne fait que reprojeter.
2727

28+
**L'impact du clic est un réglage du curseur** (`cursor.clickImpact`, « Click impact » dans le
29+
panneau du curseur, éteint par défaut), pas une option de zoom : il vaut sous **toutes** les
30+
caméras, dans une région de zoom ou hors d'elle. Chaque caméra le rend à sa façon : l'angle fixe
31+
bascule du côté cliqué (ci-dessus), la caméra mobile recule l'œil (A.3), et l'écran droit
32+
**recule** : sa boîte rapetisse autour de son centre de `camera::PRESS` au contact (−3,8 %), sur la
33+
même courbe `tap()`, puis revient (`frame_geometry::plan_frame`). Pas de bascule depuis l'écran
34+
droit : elle mettrait des arêtes sur les axes (règle des 2°). Le recul prend le complément de la
35+
porte des angles fixes (`regions::tilt_gate`) et du poids de la caméra mobile, donc les trois se
36+
relaient sans saut pendant les transitions. Un document qui portait `clickImpact` sur un zoom
37+
allume le réglage du curseur à son ouverture (`liftZoomClickImpact`).
38+
2839
### A.2 Le modèle : une seule liste « caméra 3D »
2940

3041
**Révisé trois fois après test produit.** La première version séparait l'attitude
@@ -121,8 +132,8 @@ d'azimut (`CAMERA_LIGHT_GAIN`, 0,5 donnait ±10 % avec l'œil en orbite).
121132
**Impact du clic** : l'écran reste immobile, c'est **l'œil qui recule** de 4 % de sa distance au
122133
contact, sur la courbe `tap()` de l'impact des angles fixes (mêmes clics, mêmes portes : fenêtre du
123134
clip, clic visible, masque, vitesse, opacité du curseur), puis revient avec un léger rebond. Sur la
124-
vidéo, l'écran perd 2 à 3 % de taille pendant deux images : un tapotement, pas un saut. Le panneau
125-
l'active sous cette caméra, avec sa propre description.
135+
vidéo, l'écran perd 2 à 3 % de taille pendant deux images : un tapotement, pas un saut. C'est le
136+
réglage « Click impact » du curseur, le même sous toutes les caméras (A.1).
126137

127138
Chaînée à un angle fixe, une région `follow-cursor` ne mélange jamais les deux modèles : la
128139
transition passe par l'écran droit à mi-course. Entre deux régions `follow-cursor`, le cadreur ne

‎electron/ai-edition/style-preset-service.test.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ const APPEARANCE: StylePresetAppearance = {
3333
clickBounce: 1,
3434
model3d: false,
3535
alwaysArrow: false,
36+
clickImpact: false,
3637
},
3738
cursorShow: true,
3839
cursorAutoHide: false,

‎electron/ipc/nativeBridge.presets.test.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,7 @@ const APPEARANCE: StylePresetAppearance = {
5353
clickBounce: 1,
5454
model3d: false,
5555
alwaysArrow: false,
56+
clickImpact: false,
5657
},
5758
cursorShow: true,
5859
cursorAutoHide: false,

‎src/components/ai-edition/CursorPane.levels.test.tsx‎

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
// @vitest-environment jsdom
2-
import { cleanup, render, screen } from "@testing-library/react";
2+
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
33
import { afterEach, beforeEach, describe, expect, it } from "vitest";
44
import { I18nProvider } from "@/contexts/I18nContext";
55
import { LOCALE_STORAGE_KEY } from "@/i18n/config";
@@ -62,3 +62,22 @@ describe("CursorPane size", () => {
6262
expect([slider.min, slider.max, slider.value]).toEqual(["1.5", "6", "4.5"]);
6363
});
6464
});
65+
66+
describe("CursorPane click impact", () => {
67+
const toggle = () => screen.queryByRole("button", { name: "Click impact" });
68+
69+
it("is a project-wide switch, off by default", () => {
70+
renderWithCursor({});
71+
expect(toggle()?.getAttribute("aria-pressed")).toBe("false");
72+
fireEvent.click(toggle() as HTMLElement);
73+
expect(useProjectStore.getState().document?.legacyEditor).toMatchObject({
74+
cursorClickImpact: true,
75+
});
76+
});
77+
78+
// The impact follows the pointer you see: with the cursor hidden it has nothing to show.
79+
it("is not offered while the cursor is hidden", () => {
80+
renderWithCursor({ cursorShow: false });
81+
expect(toggle()).toBeNull();
82+
});
83+
});

0 commit comments

Comments
 (0)