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}