From 3c8757bd2c24620f8e5fd22d6cb76e6b4707b2d9 Mon Sep 17 00:00:00 2001 From: yumoqing Date: Wed, 27 May 2026 15:19:17 +0800 Subject: [PATCH] feat: add bricks.Router for SPA URL state management - New router.js: configurable target tracking, History API integration - Hook in _buildWidget: tracks urlwidget replace into registered targets - Init via bricks.Router.init({targets: [{id, param}]}) - Supports: refresh restore, deep linking, back/forward navigation - Excludes: Popup/PopupWindow/NewWindow (modal, transient) - Non-tracked targets unaffected (login state, header, etc.) --- bricks/bricks.js | 6 ++ bricks/build.sh | 2 +- bricks/router.js | 229 +++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 236 insertions(+), 1 deletion(-) create mode 100644 bricks/router.js diff --git a/bricks/bricks.js b/bricks/bricks.js index 7a14a5f..9d04c7f 100644 --- a/bricks/bricks.js +++ b/bricks/bricks.js @@ -348,6 +348,12 @@ var _buildWidget = async function(from_widget, target, mode, options, desc){ if (mode == 'replace'){ target.clear_widgets(); target.add_widget(w); + // Router hook: track urlwidget navigations to registered targets + if (bricks.Router && bricks.Router._enabled + && options && options.widgettype === 'urlwidget' + && options.options && options.options.url){ + bricks.Router._onReplace(target, options.options.url); + } } else if (mode == 'insert'){ target.add_widget(w, 0); } else { diff --git a/bricks/build.sh b/bricks/build.sh index a59afea..43b67c0 100755 --- a/bricks/build.sh +++ b/bricks/build.sh @@ -1,5 +1,5 @@ SOURCES=" page_data_loader.js factory.js uitypesdef.js utils.js uitype.js \ - i18n.js widget.js layout.js bricks.js image.js html.js splitter.js \ + i18n.js widget.js layout.js bricks.js router.js image.js html.js splitter.js \ jsoncall.js myoperator.js scroll.js menu.js popup.js recorder.js \ modal.js running.js llmout.js glbviewer.js \ markdown_viewer.js audio.js toolbar.js tab.js \ diff --git a/bricks/router.js b/bricks/router.js new file mode 100644 index 0000000..3588910 --- /dev/null +++ b/bricks/router.js @@ -0,0 +1,229 @@ +/** + * bricks.Router — SPA routing for bricks single-page applications + * + * Provides URL state management so that: + * - Browser refresh lands on the current page + * - Direct URL access (deep linking) works + * - Browser back/forward buttons navigate between pages + * + * Usage — call after bricks app is created: + * + * bricks.Router.init({ + * targets: [ + * { id: 'main_content', param: 'page' }, + * { id: 'sidebar', param: 'side' } // optional extra targets + * ] + * }); + * + * URL format: /?page=/module/index.ui + * Multiple: /?page=/module/index.ui&side=/module/sidebar.ui + * + * What IS tracked: + * - urlwidget loads with mode:'replace' into registered target IDs + * + * What is NOT tracked (by design): + * - Popup / PopupWindow / NewWindow (modal overlays, transient) + * - urlwidgets into non-registered targets (header, login state, etc.) + * These re-render on refresh via server-side session state. + * - insert/append mode urlwidgets + */ +bricks.Router = (function(){ + + var _enabled = false; + var _targets = {}; // { targetId: { param: string, current: string|null } } + var _isPopState = false; // suppress pushState during popstate restore + var _restoring = false; // suppress _onReplace during _loadInto + + // ── URL helpers ── + + function _getURLParam(key){ + return new URLSearchParams(window.location.search).get(key); + } + + function _pushState(){ + var url = new URL(window.location); + for (var id in _targets){ + var cfg = _targets[id]; + if (cfg.current){ + url.searchParams.set(cfg.param, cfg.current); + } else { + url.searchParams.delete(cfg.param); + } + } + history.pushState(_getState(), '', url.toString()); + } + + function _replaceState(){ + var url = new URL(window.location); + for (var id in _targets){ + var cfg = _targets[id]; + if (cfg.current){ + url.searchParams.set(cfg.param, cfg.current); + } else { + url.searchParams.delete(cfg.param); + } + } + history.replaceState(_getState(), '', url.toString()); + } + + function _getState(){ + var s = {}; + for (var id in _targets){ s[id] = _targets[id].current; } + return s; + } + + // ── Load content into a target ── + + async function _loadInto(targetId, url){ + if (!bricks.app){ return; } + var target = bricks.getWidgetById(targetId, bricks.app); + if (!target){ + console.log('[Router] target not found:', targetId); + return; + } + var desc = { widgettype: 'urlwidget', options: { url: url } }; + try { + _restoring = true; + var w = await bricks.widgetBuild(desc, bricks.app); + if (w && !(w instanceof bricks.Popup) && !(w instanceof bricks.NewWindow)){ + target.clear_widgets(); + target.add_widget(w); + } + } catch(e){ + console.error('[Router] load failed:', url, e); + } finally { + _restoring = false; + } + } + + // ── popstate handler (back/forward) ── + + async function _onPopState(event){ + if (!event.state){ return; } + _isPopState = true; + for (var id in _targets){ + var url = event.state[id]; + if (url !== undefined && url !== _targets[id].current){ + _targets[id].current = url || null; + if (url){ + await _loadInto(id, url); + } + } + } + _isPopState = false; + } + + // ── Restore from URL on page load ── + + async function _restore(){ + var anyRoute = false; + for (var id in _targets){ + var cfg = _targets[id]; + var url = _getURLParam(cfg.param); + if (url){ + cfg.current = url; + anyRoute = true; + } + } + if (!anyRoute){ + // No route in URL — set initial state + _replaceState(); + return; + } + // Wait for app to fully initialize + var attempts = 0; + while (!bricks.app && attempts < 50){ + await new Promise(function(r){ setTimeout(r, 100); }); + attempts++; + } + if (!bricks.app){ + console.error('[Router] bricks.app not ready'); + return; + } + // Extra delay for shell layout to finish + await new Promise(function(r){ setTimeout(r, 300); }); + + console.log('[Router] restoring routes:', _getState()); + for (var id in _targets){ + var cfg = _targets[id]; + if (cfg.current){ + await _loadInto(id, cfg.current); + } + } + // Set initial history entry + _replaceState(); + } + + // ── Public API ── + + return { + _enabled: false, + + /** + * Called by _buildWidget after a urlwidget replaces content in a target. + * Internal — do not call directly. + */ + _onReplace: function(target, url){ + if (!_enabled || _restoring || _isPopState){ return; } + var cfg = _targets[target.id]; + if (!cfg){ return; } + if (cfg.current === url){ return; } + cfg.current = url; + _pushState(); + }, + + /** + * Initialize the router. + * @param {Object} config + * @param {Array} config.targets - [{id: 'widget_id', param: 'url_param_name'}] + */ + init: function(config){ + if (!config || !config.targets || !config.targets.length){ + console.error('[Router] init requires config.targets array'); + return; + } + config.targets.forEach(function(t){ + _targets[t.id] = { + param: t.param || t.id, + current: null + }; + }); + _enabled = true; + this._enabled = true; + + window.addEventListener('popstate', _onPopState); + _restore(); + + console.log('[Router] init OK, targets:', Object.keys(_targets)); + }, + + /** + * Programmatically navigate a target to a URL. + * @param {string} targetId - registered target widget ID + * @param {string} url - .ui URL to load + */ + navigate: function(targetId, url){ + var cfg = _targets[targetId]; + if (!cfg){ + console.error('[Router] unknown target:', targetId); + return; + } + _loadInto(targetId, url).then(function(){ + cfg.current = url; + _pushState(); + }); + }, + + /** Get current route for a target */ + current: function(targetId){ + var cfg = _targets[targetId]; + return cfg ? cfg.current : null; + }, + + /** Get all current routes */ + state: function(){ + return _getState(); + } + }; + +})();