001package arez.dom; 002 003import arez.ComputableValue; 004import arez.annotations.Action; 005import arez.annotations.ArezComponent; 006import arez.annotations.ComputableValueRef; 007import arez.annotations.DepType; 008import arez.annotations.Feature; 009import arez.annotations.Memoize; 010import arez.annotations.Observable; 011import arez.annotations.OnActivate; 012import arez.annotations.OnDeactivate; 013import java.util.Objects; 014import javax.annotation.Nonnull; 015import jsinterop.annotations.JsFunction; 016import jsinterop.annotations.JsMethod; 017import jsinterop.annotations.JsPackage; 018import jsinterop.annotations.JsProperty; 019import jsinterop.annotations.JsType; 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 @JsFunction 039 private interface HashChangeEventListener 040 { 041 void handleEvent( HashChangeEvent event ); 042 } 043 044 @JsType( isNative = true, name = "HashChangeEvent", namespace = JsPackage.GLOBAL ) 045 private static class HashChangeEvent 046 { 047 @JsMethod 048 native void preventDefault(); 049 } 050 051 @JsType( isNative = true, name = "Location", namespace = JsPackage.GLOBAL ) 052 private static class Location 053 { 054 @JsProperty( name = "hash" ) 055 native String hash(); 056 057 @JsProperty 058 native void setHash( String hash ); 059 060 @JsProperty( name = "pathname" ) 061 native String pathname(); 062 063 @JsProperty( name = "search" ) 064 native String search(); 065 } 066 067 @JsType( isNative = true, name = "History", namespace = JsPackage.GLOBAL ) 068 private static class History 069 { 070 @JsMethod 071 native void pushState( Object data, String unused, String url ); 072 } 073 074 @JsType( isNative = true, name = "Document", namespace = JsPackage.GLOBAL ) 075 private static class Document 076 { 077 @JsProperty( name = "title" ) 078 native String title(); 079 } 080 081 @Nonnull 082 private final HashChangeEventListener _listener = this::onHashChangeEvent; 083 /** 084 * The location according to the application. 085 */ 086 @Nonnull 087 private String _location; 088 /** 089 * The location that the application is attempting to update the browser to. 090 */ 091 @Nonnull 092 private String _targetLocation; 093 /** 094 * Should we prevent the default action associated with hash change. 095 */ 096 private boolean _preventDefault = true; 097 098 /** 099 * Create the model object. 100 * 101 * @return the BrowserLocation instance. 102 */ 103 @Nonnull 104 public static BrowserLocation create() 105 { 106 return new Arez_BrowserLocation(); 107 } 108 109 BrowserLocation() 110 { 111 _targetLocation = _location = getHash(); 112 } 113 114 /** 115 * Return true if component will prevent default actions when hash. 116 * 117 * @return true if component will prevent default actions when hash. 118 */ 119 public boolean shouldPreventDefault() 120 { 121 return _preventDefault; 122 } 123 124 /** 125 * Set a flag to determine whether events default action will be prevented. 126 * 127 * @param preventDefault true to prevent default action. 128 */ 129 public void setPreventDefault( final boolean preventDefault ) 130 { 131 _preventDefault = preventDefault; 132 } 133 134 /** 135 * Change the target location to the specified parameter. 136 * This will ultimately result in a side-effect that updates the browsers location. 137 * This location parameter should not include "#" as the first character. 138 * 139 * @param targetLocation the location to change to. 140 */ 141 @Action( verifyRequired = false ) 142 public void changeLocation( @Nonnull final String targetLocation ) 143 { 144 _targetLocation = targetLocation; 145 if ( targetLocation.equals( getBrowserLocation() ) ) 146 { 147 setLocation( targetLocation ); 148 } 149 setHash( targetLocation ); 150 /* 151 * setHash does not trigger a "hashchange" event so explicitly call the hook here 152 */ 153 updateBrowserLocation(); 154 } 155 156 /** 157 * Revert the browsers location to the application location. 158 */ 159 @Action 160 public void resetBrowserLocation() 161 { 162 changeLocation( getLocation() ); 163 } 164 165 /** 166 * Return the location as the application sees it. 167 * This return value does not include a "#" as the first character. 168 * 169 * @return the location. 170 */ 171 @Observable 172 @Nonnull 173 public String getLocation() 174 { 175 return _location; 176 } 177 178 @Observable 179 void setLocation( @Nonnull final String location ) 180 { 181 _location = Objects.requireNonNull( location ); 182 } 183 184 @Memoize( depType = DepType.AREZ_OR_EXTERNAL ) 185 @Nonnull 186 public String getBrowserLocation() 187 { 188 return getHash(); 189 } 190 191 @OnActivate 192 void onBrowserLocationActivate() 193 { 194 addEventListener( "hashchange", _listener, false ); 195 } 196 197 @OnDeactivate 198 void onBrowserLocationDeactivate() 199 { 200 removeEventListener( "hashchange", _listener, false ); 201 } 202 203 @ComputableValueRef 204 abstract ComputableValue<?> getBrowserLocationComputableValue(); 205 206 @Action 207 void updateBrowserLocation() 208 { 209 getBrowserLocationComputableValue().reportPossiblyChanged(); 210 final String location = getBrowserLocation(); 211 if ( _targetLocation.equals( location ) ) 212 { 213 setLocation( location ); 214 } 215 } 216 217 private void onHashChangeEvent( @Nonnull final HashChangeEvent e ) 218 { 219 if ( _preventDefault ) 220 { 221 e.preventDefault(); 222 } 223 updateBrowserLocation(); 224 } 225 226 @Nonnull 227 private String getHash() 228 { 229 return location().hash().substring( 1 ); 230 } 231 232 private void setHash( @Nonnull final String hash ) 233 { 234 final Location location = location(); 235 if ( hash.isEmpty() ) 236 { 237 /* 238 * This code is needed to remove the stray #. 239 * See https://stackoverflow.com/questions/1397329/how-to-remove-the-hash-from-window-location-url-with-javascript-without-page-r/5298684#5298684 240 */ 241 history().pushState( "", document().title(), location.pathname() + location.search() ); 242 } 243 else 244 { 245 location.setHash( hash ); 246 } 247 } 248 249 @JsMethod( name = "addEventListener", namespace = JsPackage.GLOBAL ) 250 private static native void addEventListener( String type, HashChangeEventListener listener, boolean capture ); 251 252 @JsMethod( name = "removeEventListener", namespace = JsPackage.GLOBAL ) 253 private static native void removeEventListener( String type, HashChangeEventListener listener, boolean capture ); 254 255 @JsProperty( name = "location", namespace = JsPackage.GLOBAL ) 256 private static native Location location(); 257 258 @JsProperty( name = "history", namespace = JsPackage.GLOBAL ) 259 private static native History history(); 260 261 @JsProperty( name = "document", namespace = JsPackage.GLOBAL ) 262 private static native Document document(); 263}