001package arez.dom; 002 003import akasha.Global; 004import akasha.HashChangeEvent; 005import akasha.HashChangeEventListener; 006import akasha.Location; 007import akasha.WindowGlobal; 008import arez.ComputableValue; 009import arez.annotations.Action; 010import arez.annotations.ArezComponent; 011import arez.annotations.ComputableValueRef; 012import arez.annotations.DepType; 013import arez.annotations.Feature; 014import arez.annotations.Memoize; 015import arez.annotations.Observable; 016import arez.annotations.OnActivate; 017import arez.annotations.OnDeactivate; 018import java.util.Objects; 019import javax.annotation.Nonnull; 020 021/** 022 * This is a simple abstraction over browser location as a hash. 023 * The model exposes the observable values for the location as the application sees it via 024 * {@link #getLocation()}, the way the browser sees it via {@link #getBrowserLocation()}. 025 * The application code should define an observer that monitors the location as the browser 026 * sees it and update the location as the application sees it via {@link #changeLocation(String)} 027 * if the browser location is valid. Otherwise the browser location should be reset to the application 028 * location. 029 * 030 * <p>It should be noted that this class is not a router but a primitive that can be used to 031 * implement a router. Observing the application location will allow the application to update 032 * the view. Observing the browser location will allow the application to decide whether the 033 * route should be updated.</p> 034 */ 035@ArezComponent( requireId = Feature.DISABLE ) 036public abstract class BrowserLocation 037{ 038 @Nonnull 039 private final HashChangeEventListener _listener = this::onHashChangeEvent; 040 /** 041 * The location according to the application. 042 */ 043 @Nonnull 044 private String _location; 045 /** 046 * The location that the application is attempting to update the browser to. 047 */ 048 @Nonnull 049 private String _targetLocation; 050 /** 051 * Should we prevent the default action associated with hash change? 052 */ 053 private boolean _preventDefault = true; 054 055 /** 056 * Create the model object. 057 * 058 * @return the BrowserLocation instance. 059 */ 060 @Nonnull 061 public static BrowserLocation create() 062 { 063 return new Arez_BrowserLocation(); 064 } 065 066 BrowserLocation() 067 { 068 _targetLocation = _location = getHash(); 069 } 070 071 /** 072 * Return true if component will prevent default actions when hash. 073 * 074 * @return true if component will prevent default actions when hash. 075 */ 076 public boolean shouldPreventDefault() 077 { 078 return _preventDefault; 079 } 080 081 /** 082 * Set a flag to determine whether events default action will be prevented. 083 * 084 * @param preventDefault true to prevent default action. 085 */ 086 public void setPreventDefault( final boolean preventDefault ) 087 { 088 _preventDefault = preventDefault; 089 } 090 091 /** 092 * Change the target location to the specified parameter. 093 * This will ultimately result in a side-effect that updates the browsers location. 094 * This location parameter should not include "#" as the first character. 095 * 096 * @param targetLocation the location to change to. 097 */ 098 @Action( verifyRequired = false ) 099 public void changeLocation( @Nonnull final String targetLocation ) 100 { 101 _targetLocation = targetLocation; 102 if ( targetLocation.equals( getBrowserLocation() ) ) 103 { 104 setLocation( targetLocation ); 105 } 106 setHash( targetLocation ); 107 /* 108 * setHash does not trigger a "hashchange" event so explicitly call the hook here 109 */ 110 updateBrowserLocation(); 111 } 112 113 /** 114 * Revert the browsers location to the application location. 115 */ 116 @Action 117 public void resetBrowserLocation() 118 { 119 changeLocation( getLocation() ); 120 } 121 122 /** 123 * Return the location as the application sees it. 124 * This return value does not include a "#" as the first character. 125 * 126 * @return the location. 127 */ 128 @Observable 129 @Nonnull 130 public String getLocation() 131 { 132 return _location; 133 } 134 135 @Observable 136 void setLocation( @Nonnull final String location ) 137 { 138 _location = Objects.requireNonNull( location ); 139 } 140 141 @Memoize( depType = DepType.AREZ_OR_EXTERNAL ) 142 @Nonnull 143 public String getBrowserLocation() 144 { 145 return getHash(); 146 } 147 148 @OnActivate 149 void onBrowserLocationActivate() 150 { 151 WindowGlobal.addHashchangeListener( _listener, false ); 152 } 153 154 @OnDeactivate 155 void onBrowserLocationDeactivate() 156 { 157 WindowGlobal.removeHashchangeListener( _listener, false ); 158 } 159 160 @ComputableValueRef 161 abstract ComputableValue<?> getBrowserLocationComputableValue(); 162 163 @Action 164 void updateBrowserLocation() 165 { 166 getBrowserLocationComputableValue().reportPossiblyChanged(); 167 final String location = getBrowserLocation(); 168 if ( _targetLocation.equals( location ) ) 169 { 170 setLocation( location ); 171 } 172 } 173 174 private void onHashChangeEvent( @Nonnull final HashChangeEvent e ) 175 { 176 if ( _preventDefault ) 177 { 178 e.preventDefault(); 179 } 180 updateBrowserLocation(); 181 } 182 183 @Nonnull 184 private String getHash() 185 { 186 return WindowGlobal.location().hash.substring( 1 ); 187 } 188 189 private void setHash( @Nonnull final String hash ) 190 { 191 final Location location = WindowGlobal.location(); 192 if ( 0 == hash.length() ) 193 { 194 /* 195 * This code is needed to remove the stray #. 196 * See https://stackoverflow.com/questions/1397329/how-to-remove-the-hash-from-window-location-url-with-javascript-without-page-r/5298684#5298684 197 */ 198 WindowGlobal.history().pushState( "", WindowGlobal.document().title, location.pathname + location.search ); 199 } 200 else 201 { 202 location.hash = hash; 203 } 204 } 205}