From f8ed4216e45fa56b25864eb5c25fd4105b7c9299 Mon Sep 17 00:00:00 2001 From: Ian Harrigan Date: Tue, 5 Mar 2024 21:58:49 +0100 Subject: [PATCH] navigation manager (wip) --- haxe/ui/events/NavigationEvent.hx | 5 + haxe/ui/macros/NavigationMacros.hx | 79 +++++ haxe/ui/navigation/INavigatableView.hx | 6 + haxe/ui/navigation/NavigationManager.hx | 445 ++++++++++++++++++++++++ haxe/ui/navigation/RouteDetails.hx | 33 ++ 5 files changed, 568 insertions(+) create mode 100644 haxe/ui/events/NavigationEvent.hx create mode 100644 haxe/ui/macros/NavigationMacros.hx create mode 100644 haxe/ui/navigation/INavigatableView.hx create mode 100644 haxe/ui/navigation/NavigationManager.hx create mode 100644 haxe/ui/navigation/RouteDetails.hx diff --git a/haxe/ui/events/NavigationEvent.hx b/haxe/ui/events/NavigationEvent.hx new file mode 100644 index 000000000..354350a76 --- /dev/null +++ b/haxe/ui/events/NavigationEvent.hx @@ -0,0 +1,5 @@ +package haxe.ui.events; + +class NavigationEvent extends UIEvent { + public static final NAVIGATION_CHANGED:EventType = EventType.name("navigationchanged"); +} \ No newline at end of file diff --git a/haxe/ui/macros/NavigationMacros.hx b/haxe/ui/macros/NavigationMacros.hx new file mode 100644 index 000000000..6e94d1418 --- /dev/null +++ b/haxe/ui/macros/NavigationMacros.hx @@ -0,0 +1,79 @@ +package haxe.ui.macros; + +import haxe.macro.ExprTools; +#if macro +import haxe.macro.TypeTools; +import haxe.macro.ComplexTypeTools; +import haxe.macro.Context; +import haxe.macro.Expr.Field; + +using StringTools; +#end + +class NavigationMacros { + #if macro + public static macro function buildNavigatableView():Array { + var localClass = Context.getLocalClass(); + var localType = Context.getLocalType(); + var localComplexType = TypeTools.toComplexType(localType); + var localMeta = localClass.get().meta; + + var routeDetailsMeta = null; + if (localMeta.has(":route")) { + routeDetailsMeta = localMeta.extract(":route")[0]; + } + if (localMeta.has("route")) { + routeDetailsMeta = localMeta.extract("route")[0]; + } + + var navigationSubDomain = Context.getDefines().get("haxeui_navigation_sub_domain"); + if (navigationSubDomain != null && navigationSubDomain.trim().length > 0) { + BackendMacros.additionalExprs.push(macro haxe.ui.navigation.NavigationManager.instance.subDomain = $v{navigationSubDomain}); + } + + if (routeDetailsMeta != null) { + var routePathExpr = routeDetailsMeta.params[0]; + var initialRoute = localMeta.has(":initialRoute") || localMeta.has("initialRoute"); + var errorRoute = localMeta.has(":errorRoute") || localMeta.has("errorRoute"); + var preserveView = localMeta.has(":preserveView") || localMeta.has("preserveView"); + + if (routePathExpr != null) { + var parts = localClass.toString().split("."); + parts.push("new"); + BackendMacros.additionalExprs.push(macro haxe.ui.navigation.NavigationManager.instance.registerRoute($routePathExpr, { + viewCtor: $p{parts}, + initial: $v{initialRoute}, + error: $v{errorRoute}, + preserveView: $v{preserveView} + })); + } + } + + var applyParamsField = null; + var fields = Context.getBuildFields(); + for (f in fields) { + if (f.name == "applyParams") { + applyParamsField = f; + break; + } + } + + if (applyParamsField == null) { + fields.push({ + name: "applyParams", + access: [APublic], + kind: FFun({ + args: [{name: "params", type: macro: Map}], + expr: macro { + } + }), + pos: Context.currentPos() + }); + } + + + return fields; + } + + #end +} \ No newline at end of file diff --git a/haxe/ui/navigation/INavigatableView.hx b/haxe/ui/navigation/INavigatableView.hx new file mode 100644 index 000000000..723b1cef2 --- /dev/null +++ b/haxe/ui/navigation/INavigatableView.hx @@ -0,0 +1,6 @@ +package haxe.ui.navigation; + +@:autoBuild(haxe.ui.macros.NavigationMacros.buildNavigatableView()) +interface INavigatableView { + public function applyParams(params:Map):Void; +} \ No newline at end of file diff --git a/haxe/ui/navigation/NavigationManager.hx b/haxe/ui/navigation/NavigationManager.hx new file mode 100644 index 000000000..ccd0e607a --- /dev/null +++ b/haxe/ui/navigation/NavigationManager.hx @@ -0,0 +1,445 @@ +package haxe.ui.navigation; + +import haxe.ui.util.EventDispatcher; +import haxe.ui.core.Screen; +import haxe.ui.core.IComponentContainer; +import haxe.ui.core.Component; +import haxe.ui.events.NavigationEvent; + +using StringTools; + +class NavigationManager extends EventDispatcher { + private static var _instance:NavigationManager; + public static var instance(get, null):NavigationManager; + private static function get_instance():NavigationManager { + if (_instance == null) { + _instance = new NavigationManager(); + } + return _instance; + } + + + //**************************************************************************************************** + // Instance + //**************************************************************************************************** + public var defaultContainer:Component; + public var subDomain:String; + + private var registeredRoutes:Array = []; + + private function new() { + super(); + + #if js + + if (js.Browser.window.location.protocol != "file:") { + js.Browser.window.onpopstate = (event) -> { + var state:String = event.state; + if (state == null) { + state = "/"; + } + if (state.trim().length == 0) { + state = "/"; + } + if (!state.startsWith("/")) { + state = "/" + state; + } + navigateTo(state, null, true); + }; + } + + #end + } + + public function applyInitialRoute() { + var path:String = null; + + #if js + if (js.Browser.window.location.protocol != "file:") { + path = js.Browser.window.location.pathname; + path = normalizePath(path); + if (js.Browser.window.location.search != null && js.Browser.window.location.search.trim().length > 0) { + path += js.Browser.window.location.search; + } + } + #end + + if (path != null && subDomain != null && path.startsWith(subDomain)) { + path = path.substring(subDomain.length); + path = normalizePath(path); + } + + #if haxeui_navigation_persist_route + #if js + if (js.Browser.window.location.protocol == "file:") { + // get the path, we can look this up on refreshes (assuming flag allows it) + var localStorage = js.Browser.window.localStorage; + var lastPath = localStorage.getItem("lastPath"); + if (lastPath != null) { + path = lastPath; + } else { + path = null; + } + } + #end + #end + + if (path == null) { + var initialRoute = findInitialRoute(); + if (initialRoute != null) { + path = initialRoute.path; + } + } + + if (path != null) { + if (!path.startsWith("/")) { + path = "/" + path; + } + navigateTo(path); + } + } + + public function registerRoute(path:String, routeDetails:RouteDetails) { + if (subDomain != null && subDomain.length != 0 && !path.startsWith(subDomain) && !path.startsWith("/" + subDomain)) { + path = subDomain + path; + } + + var copy = routeDetails.clone(); + path = normalizePath(path); + copy.path = path; + registeredRoutes.push(copy); + } + + private var currentFullPath:String; + private var lastPath:String; + + public var currentPath(get, set):String; + private function get_currentPath():String { + var path = currentFullPath; + return path; + } + private function set_currentPath(value:String):String { + navigateTo(value); + return value; + } + + private function applyPathParams(path:String, params:Map = null) { + if (params == null) { + return path; + } + + var newPath = path; + for (key in params.keys()) { + var token = "{" + key + "}"; + var value = params.get(key); + if (newPath.indexOf(token) != -1) { + newPath = newPath.replace(token, value); + params.remove(key); + } + } + + for (key in params.keys()) { + var value = params.get(key); + if (value != null) { + var use = switch (Type.typeof(value)) { + case TInt | TFloat | TBool: true; + case _: (value is String); + } + if (use) { + if (newPath.indexOf("?") == -1) { + newPath += "?"; + } + + newPath += key + "=" + value + "&"; + params.remove(key); + } + } + } + + if (newPath.endsWith("&")) { + newPath = newPath.substring(0, newPath.length - 1); + } + + return newPath; + } + + private var views:Map = new Map(); + public function navigateTo(path:String, params:Map = null, replaceState:Bool = false) { + if (registeredRoutes.length == 0) { + trace("WARNING: no routes registered"); + } + + path = applyPathParams(path, params); + if (currentFullPath == path) { + //return; + } + currentFullPath = path; + + var fullPath = path; + var pathParams:Map = []; + if (path.indexOf("?") != -1) { + var paramsString = path.substring(path.indexOf("?") + 1); + path = path.substring(0, path.indexOf("?")); + var parts = paramsString.split("&"); + for (p in parts) { + var n = p.indexOf("="); + var param = p.substring(0, n); + var value = p.substring(n + 1); + pathParams.set(param, value); + } + } + + var originalPath = path; + if (subDomain != null && subDomain.length != 0 && !path.startsWith(subDomain) && !path.startsWith("/" + subDomain)) { + path = subDomain + path; + } + + path = normalizePath(path); + + var routeDetails = findRouteByPath(path); + if (routeDetails == null) { + trace("path not found", path); + var errorRouteDetails = findErrorRoute(); + if (errorRouteDetails != null) { + routeDetails = errorRouteDetails.clone(); + } else { + return; + } + } + + var routeParams = routeDetails.params; + if (routeParams == null) { + routeParams = []; + } + if (pathParams != null) { + for (k in pathParams.keys()) { + routeParams.set(k, pathParams.get(k)); + } + } + if (params != null) { + for (k in params.keys()) { + routeParams.set(k, params.get(k)); + } + } + + var container = getContainer(routeDetails); + var view:INavigatableView = null; + if (routeDetails.preserveView) { + view = views.get(routeDetails.path); + if (view == null) { + view = routeDetails.viewCtor(); + } + views.set(routeDetails.path, view); + } else { + view = routeDetails.viewCtor(); + } + var component:Component = cast view; + updateRouteContainer(routeDetails, container); + updateRouteComponent(routeDetails, component); + var containerRoutes = findRoutesForContainer(container); + for (containerRoute in containerRoutes) { + if (containerRoute.component != null && containerRoute.container.containsComponent(containerRoute.component)) { + containerRoute.container.removeComponent(containerRoute.component, !containerRoute.preserveView); + } + } + + view.applyParams(routeParams); + container.addComponent(component); + + #if js + + var statePath = fullPath; + if (subDomain != null && subDomain.length != 0 && !statePath.startsWith(subDomain) && !statePath.startsWith("/" + subDomain)) { + statePath = subDomain + "/" + statePath; + statePath = "/" + normalizePath(statePath); + } + + var documentOrigin = js.Browser.window.origin; + var useState = true; + if (documentOrigin == null || js.Browser.window.location.protocol == "file:") { + useState = false; + } + if (useState && lastPath != statePath) { + if (replaceState) { + js.Browser.window.history.replaceState(statePath, null, statePath); + } else { + js.Browser.window.history.pushState(statePath, null, statePath); + } + // is this a hack?! + lastPath = statePath; + } + + #end + + #if haxeui_navigation_persist_route + #if js + if (js.Browser.window.location.protocol == "file:" && !routeDetails.error) { + // store the path, we can look this up on refreshes (assuming flag allows it) + var localStorage = js.Browser.window.localStorage; + localStorage.setItem("lastPath", fullPath); + } + #end + #end + + var event = new NavigationEvent(NavigationEvent.NAVIGATION_CHANGED); + dispatch(event); + } + + // since we work with copies, if we want to see container on the original one we'll have to find it + private function updateRouteContainer(routeDetails:RouteDetails, container:IComponentContainer) { + for (temp in registeredRoutes) { + if (temp.path == routeDetails.path) { + temp.container = container; + break; + } + } + } + + // since we work with copies, if we want to see container on the original one we'll have to find it + private function updateRouteComponent(routeDetails:RouteDetails, component:Component) { + for (temp in registeredRoutes) { + if (temp.path == routeDetails.path) { + temp.component = component; + break; + } + } + } + + private function findRoutesForContainer(container:IComponentContainer):Array { + var list = []; + for (routeDetails in registeredRoutes) { + if (routeDetails.container != null && routeDetails.container == container) { + list.push(routeDetails); + } + } + return list; + } + + private function getContainer(routeDetails:RouteDetails):IComponentContainer { + if (routeDetails.container != null) { + return routeDetails.container; + } + if (defaultContainer == null) { + return Screen.instance; + } + return defaultContainer; + } + + private function findRouteByPath(path:String):RouteDetails { + if (path == null) { + return null; + } + var route = null; + for (r in registeredRoutes) { + if (isRouteMatch(path, r)) { + route = r; + break; + } + } + + if (route == null) { + return null; + } + + route = route.clone(); + + var pathPartsParamNames = route.path.split("/"); + var pathPartParamValues = path.split("/"); + var params:Map = []; + for (i in 0...pathPartsParamNames.length) { + var pathPartName = pathPartsParamNames[i]; + var pathPartValue = pathPartParamValues[i]; + if (pathPartName.startsWith("{") && pathPartName.endsWith("}")) { + params.set(pathPartName.substring(1, pathPartName.length - 1), pathPartValue); + } + } + + if (route.params != null) { + route.params = []; + for (k in params.keys()) { + route.params.set(k, params.get(k)); + } + } + + return route; + } + + private function isRouteMatch(path:String, candidate:RouteDetails):Bool { + if (path == candidate.path) { + return true; + } + + var candidatePath = candidate.path; + var pathParts = path.split("/"); + var candidatePathParts = candidatePath.split("/"); + if (pathParts.length != candidatePathParts.length) { + return false; + } + + for (i in 0...pathParts.length) { + var pathPart = pathParts[i]; + var candidatePathPart = candidatePathParts[i]; + if (candidatePathPart.startsWith("{") && candidatePathPart.endsWith("}")) { + continue; + } + if (pathPart != candidatePathPart) { + return false; + } + } + + return true; + } + + private function findInitialRoute():RouteDetails { + if (registeredRoutes.length == 0) { + trace("WARNING: no routes registered"); + } + + for (details in registeredRoutes) { + if (details.initial) { + return details; + } + } + + for (details in registeredRoutes) { + if (details.path.length == 0) { + return details; + } + } + + return null; + } + + + private function findErrorRoute():RouteDetails { + if (registeredRoutes.length == 0) { + trace("WARNING: no routes registered"); + } + + for (details in registeredRoutes) { + if (details.error) { + return details; + } + } + return null; + } + + private static function normalizePath(path:String) { + if (path == null) { + return null; + } + if (path.startsWith("/")) { + path = path.substring(1); + } + if (path.endsWith("/")) { + path = path.substring(0, path.length - 1); + } + path = path.replace("//", "/"); + /* + if (!path.startsWith("/")) { + path = "/" + path; + } + */ + return path; + } +} \ No newline at end of file diff --git a/haxe/ui/navigation/RouteDetails.hx b/haxe/ui/navigation/RouteDetails.hx new file mode 100644 index 000000000..6bb2cf7ba --- /dev/null +++ b/haxe/ui/navigation/RouteDetails.hx @@ -0,0 +1,33 @@ +package haxe.ui.navigation; + +import haxe.ui.core.IComponentContainer; +import haxe.ui.core.Component; + +@:structInit +class RouteDetails { + public var viewCtor:Void->INavigatableView = null; + public var path:String = null; + public var initial:Bool = false; + public var error:Bool = false; + public var preserveView:Bool = false; + + public var containerId:String = null; + public var container:IComponentContainer = null; + public var component:Component = null; + + public var params:Map = []; + + public function clone():RouteDetails { + return { + viewCtor: this.viewCtor, + path: this.path, + initial: this.initial, + error: this.error, + preserveView: this.preserveView, + containerId: this.containerId, + container: this.container, + component: this.component, + params: this.params.copy() + } + } +} \ No newline at end of file