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.)
This commit is contained in:
yumoqing 2026-05-27 15:19:17 +08:00
parent 6312ae8e22
commit 3c8757bd2c
3 changed files with 236 additions and 1 deletions

View File

@ -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 {

View File

@ -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 \

229
bricks/router.js Normal file
View File

@ -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();
}
};
})();