You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

145 lines
5.1 KiB

3 years ago
  1. package outwatch.router
  2. import org.scalajs.dom.window
  3. import colibri.Observable
  4. import outwatch.util.Store
  5. import outwatch.util.Reducer
  6. import cats.effect.Sync
  7. import outwatch._
  8. import outwatch.dsl._
  9. import outwatch.router.dsl.Path
  10. import outwatch.router.dsl.HashPath
  11. import outwatch.router.dsl.Root
  12. sealed trait Action
  13. final case class Replace(path: Path) extends Action
  14. final case class HistoryEvent(path: Path) extends Action
  15. final case class RouterState[P](page: P)
  16. /** An AppRouter handles parsing of URLs and mapping to pages of the given type.
  17. * @param siteRoot - The prefix part of a pathname, or the subpath at which your site is applied.
  18. * Usually this is just Root, but your site might need a prefix as in /my_site/[parsed pathname]
  19. * @param parent - The parent path at which this router is mounted. You can have routers contained in subroots of your site.
  20. * @param f - a mapping function from a Path to a page P.
  21. * @tparam F - the effect type
  22. * @tparam P - Your page type, such as a sealed trait root type.
  23. */
  24. class AppRouter[F[_]: Sync, P](siteRoot: Path, parent: Path, f: Path => P) {
  25. // Sync from the required page to the window.location
  26. def routerReducer(state: RouterState[P], action: Action): RouterState[P] =
  27. action match {
  28. case Replace(path) =>
  29. println(s"Path = $path")
  30. window.history.pushState(
  31. "",
  32. "",
  33. HashPath(siteRoot, HashPath(parent, path)).toUrlString
  34. )
  35. state.copy(page = f(path))
  36. case HistoryEvent(path) =>
  37. println(s"Path = $path")
  38. state.copy(page = f(path))
  39. case _ => state
  40. }
  41. def store: F[RouterStore[P]] = {
  42. val startingPath = HashPath(window.location.hash.substring(1))
  43. Store.create[F, Action, RouterState[P]](
  44. Replace(startingPath),
  45. RouterState(f(startingPath)),
  46. Reducer(routerReducer _)
  47. )
  48. }
  49. def link(
  50. linkHref: String
  51. )(attrs: VDomModifier*)(implicit store: RouterStore[P]): BasicVNode =
  52. a(href := linkHref)(
  53. onClick.preventDefault.useLazy(
  54. Replace(HashPath(linkHref))
  55. ) --> store.sink,
  56. attrs
  57. )
  58. def render(resolver: RouterResolve[P])(implicit
  59. store: RouterStore[P]
  60. ): Observable[VDomModifier] =
  61. store.map { case (_, RouterState(p)) => resolver(p) }
  62. def watch()(implicit store: RouterStore[P]) =
  63. emitter(outwatch.dsl.events.window.onPopState).useLazy(
  64. HistoryEvent(HashPath(org.scalajs.dom.window.location.hash.substring(1)))
  65. ) --> store.sink
  66. // def link(
  67. // p: P
  68. // )(attrs: VDomModifier*)(implicit store: RouterStore[P]): BasicVNode =
  69. // a(href := p.toString())(
  70. // onClick.preventDefault.useLazy(
  71. // Replace(Path(p.toString()))
  72. // ) --> store.sink,
  73. // attrs
  74. // )
  75. }
  76. object AppRouter {
  77. def render[P](resolver: RouterResolve[P])(implicit
  78. store: RouterStore[P]
  79. ): Observable[VDomModifier] =
  80. store.map { case (_, RouterState(p)) => resolver(p) }
  81. def watch[P]()(implicit store: RouterStore[P]) =
  82. emitter(
  83. outwatch.dsl.events.window.onPopState
  84. // .doOnNext(e =>
  85. // println(
  86. // s"changed ${org.scalajs.dom.window.location.toString()}"
  87. // )
  88. // )
  89. ).useLazy(
  90. HistoryEvent(Path(org.scalajs.dom.window.location.pathname))
  91. ) --> store.sink
  92. def create[F[_]: Sync, P](notFound: P)(
  93. f: PartialFunction[Path, P]
  94. ): AppRouter[F, P] =
  95. create[F, P](Root, notFound)(f)
  96. def create[F[_]: Sync, P](parent: Path, notFound: P)(
  97. f: PartialFunction[Path, P]
  98. ): AppRouter[F, P] =
  99. new AppRouter[F, P](Root, parent, f.lift.andThen(_.getOrElse(notFound)))
  100. def createParseSiteRoot[F[_]: Sync, P](notFound: P)(
  101. f: PartialFunction[Path, P]
  102. ): AppRouter[F, P] =
  103. createParseSiteRoot[F, P](Root, notFound)(f)
  104. /** Automatically determine what siteroot we're using, based on the current URL and expected parent.
  105. * For example, your site could be deployed at /example/directory/, your router root path could be /names,
  106. * and the current url could be /example/directory/names/alice. So given the call:
  107. * createParseSubRoot[Page](Path("/names"), NotFound)(f), the router will work out that the window location
  108. * prefix must be /example/directory/names, and will handle actions such as Replace(Path("/names/bob"))
  109. * @param parent the parent for this router, another path perhaps managed by another router
  110. * @param notFound - the default case page assignment
  111. * @param f - a router function from Path to instances of your page type
  112. * @tparam P - your page type
  113. */
  114. def createParseSiteRoot[F[_]: Sync, P](parent: Path, notFound: P)(
  115. f: PartialFunction[Path, P]
  116. ): AppRouter[F, P] = {
  117. val initUrl = window.location.pathname
  118. // url is of form /sra/srb/src/pa/pb/pc...
  119. // so just drop the parent part from the right of the url if it exists.
  120. val siteRoot = initUrl.lastIndexOf(parent.toString) match {
  121. case x if x < 1 => Root
  122. case x => Path(initUrl.substring(0, x))
  123. }
  124. val routerFun: Path => P = f.lift.andThen(_.getOrElse(notFound))
  125. new AppRouter[F, P](siteRoot, parent, routerFun)
  126. }
  127. }