From 5cf3fcd8433413b3960dcac265624f3b9792718f Mon Sep 17 00:00:00 2001 From: Jeremy Ruston Date: Fri, 6 Feb 2026 16:30:46 +0000 Subject: [PATCH] Background actions and media query tracking (#9641) * Initial commit cherry picked from #8702 * Initial docs from #8702 ...which need to also be turned into a changenote * Add changenote --- core/modules/background-actions.js | 116 ++++++++++++++++++ core/modules/filter-tracker.js | 106 ++++++++++++++++ core/modules/info/mediaquerytracker.js | 67 ++++++++++ core/modules/info/platform.js | 7 -- core/modules/startup/load-modules.js | 26 ++++ core/modules/startup/startup.js | 15 --- .../MediaQueryTrackers/DarkLightPreferred.tid | 5 + .../mechanisms/Background Actions.tid | 22 ++++ .../tiddlers/mechanisms/InfoMechanism.tid | 6 +- .../mechanisms/MediaQueryTrackerMechanism.tid | 13 ++ .../tiddlers/releasenotes/5.4.0/#9641.tid | 14 +++ 11 files changed, 373 insertions(+), 24 deletions(-) create mode 100644 core/modules/background-actions.js create mode 100644 core/modules/filter-tracker.js create mode 100644 core/modules/info/mediaquerytracker.js create mode 100644 core/wiki/config/MediaQueryTrackers/DarkLightPreferred.tid create mode 100644 editions/tw5.com/tiddlers/mechanisms/Background Actions.tid create mode 100644 editions/tw5.com/tiddlers/mechanisms/MediaQueryTrackerMechanism.tid create mode 100644 editions/tw5.com/tiddlers/releasenotes/5.4.0/#9641.tid diff --git a/core/modules/background-actions.js b/core/modules/background-actions.js new file mode 100644 index 0000000000..86eec9757a --- /dev/null +++ b/core/modules/background-actions.js @@ -0,0 +1,116 @@ +/*\ +title: $:/core/modules/background-actions.js +type: application/javascript +module-type: global + +Class to dispatch actions when filters change + +\*/ + +"use strict"; + +class BackgroundActionDispatcher { + constructor(filterTracker, wiki) { + this.filterTracker = filterTracker; + this.wiki = wiki; + this.nextTrackedFilterId = 1; + this.trackedFilters = new Map(); // Use Map for better key management + // Track the filter for the background actions + this.filterTracker.track({ + filterString: "[all[tiddlers+shadows]tag[$:/tags/BackgroundAction]!is[draft]]", + fnEnter: title => this.trackFilter(title), + fnLeave: (title, enterValue) => this.untrackFilter(enterValue), + fnChange: (title, enterValue) => { + this.untrackFilter(enterValue); + return this.trackFilter(title); + }, + fnProcess: changes => this.process(changes) + }); + } + + trackFilter(title) { + const tiddler = this.wiki.getTiddler(title); + const id = this.nextTrackedFilterId++; + const tracker = new BackgroundActionTracker({ + wiki: this.wiki, + title, + trackFilter: tiddler.fields["track-filter"], + actions: tiddler.fields.text + }); + this.trackedFilters.set(id, tracker); + return id; + } + + untrackFilter(enterValue) { + const tracker = this.trackedFilters.get(enterValue); + if(tracker) { + tracker.destroy(); + } + this.trackedFilters.delete(enterValue); + } + + process(changes) { + for(const tracker of this.trackedFilters.values()) { + tracker.process(changes); + } + } +} + +/* +Represents an individual tracked filter. Options include: +wiki: wiki to use +title: title of the tiddler being tracked +trackFilter: filter string to track changes +actions: actions to be executed when the filter changes +*/ +class BackgroundActionTracker { + constructor({wiki, title, trackFilter, actions}) { + this.wiki = wiki; + this.title = title; + this.trackFilter = trackFilter; + this.actions = actions; + this.filterTracker = new $tw.FilterTracker(this.wiki); + this.hasChanged = false; + this.trackerID = this.filterTracker.track({ + filterString: this.trackFilter, + fnEnter: () => { this.hasChanged = true; }, + fnLeave: () => { this.hasChanged = true; }, + fnProcess: changes => { + if(this.hasChanged) { + this.hasChanged = false; + console.log("Processing background action", this.title); + const tiddler = this.wiki.getTiddler(this.title); + let doActions = true; + if(tiddler && tiddler.fields.platforms) { + doActions = false; + const platforms = $tw.utils.parseStringArray(tiddler.fields.platforms); + if(($tw.browser && platforms.includes("browser")) || ($tw.node && platforms.includes("node"))) { + doActions = true; + } + } + if(doActions) { + this.wiki.invokeActionString( + this.actions, + null, + { + currentTiddler: this.title + },{ + parentWidget: $tw.rootWidget + } + ); + } + } + } + }); + } + + process(changes) { + this.filterTracker.handleChangeEvent(changes); + } + + destroy() { + this.filterTracker.untrack(this.trackerID); + } +} + +exports.BackgroundActionDispatcher = BackgroundActionDispatcher; diff --git a/core/modules/filter-tracker.js b/core/modules/filter-tracker.js new file mode 100644 index 0000000000..761fc94642 --- /dev/null +++ b/core/modules/filter-tracker.js @@ -0,0 +1,106 @@ +/*\ +title: $:/core/modules/filter-tracker.js +type: application/javascript +module-type: global + +Class to track the results of a filter string + +\*/ + +"use strict"; + +class FilterTracker { + constructor(wiki) { + this.wiki = wiki; + this.trackers = new Map(); + this.nextTrackerId = 1; + } + + handleChangeEvent(changes) { + this.processTrackers(); + this.processChanges(changes); + } + + /* + Add a tracker to the filter tracker. Returns null if any of the parameters are invalid, or a tracker id if the tracker was added successfully. Options include: + filterString: the filter string to track + fnEnter: function to call when a title enters the filter results. Called even if the tiddler does not actually exist. Called as (title), and should return a truthy value that is stored in the tracker as the "enterValue" + fnLeave: function to call when a title leaves the filter results. Called as (title,enterValue) + fnChange: function to call when a tiddler changes in the filter results. Only called for filter results that identify a tiddler or shadow tiddler. Called as (title,enterValue), and may optionally return a replacement enterValue + fnProcess: function to call each time the tracker is processed, after any enter, leave or change functions are called. Called as (changes) + */ + track(options = {}) { + const { + filterString, + fnEnter, + fnLeave, + fnChange, + fnProcess + } = options; + const id = this.nextTrackerId++; + const tracker = { + id, + filterString, + fnEnter, + fnLeave, + fnChange, + fnProcess, + previousResults: [], + resultValues: {} + }; + this.trackers.set(id, tracker); + // Process the tracker + this.processTracker(id); + return id; + } + + untrack(id) { + this.trackers.delete(id); + } + + processTrackers() { + for(const id of this.trackers.keys()) { + this.processTracker(id); + } + } + + processTracker(id) { + const tracker = this.trackers.get(id); + if(!tracker) return; + const results = []; + // Evaluate the filter and remove duplicate results + $tw.utils.each(this.wiki.filterTiddlers(tracker.filterString), title => { + $tw.utils.pushTop(results, title); + }); + // Process the newly entered results + results.forEach(title => { + if(!tracker.previousResults.includes(title) && !tracker.resultValues[title] && tracker.fnEnter) { + tracker.resultValues[title] = tracker.fnEnter(title) || true; + } + }); + // Process the results that have just left + tracker.previousResults.forEach(title => { + if(!results.includes(title) && tracker.resultValues[title] && tracker.fnLeave) { + tracker.fnLeave(title, tracker.resultValues[title]); + delete tracker.resultValues[title]; + } + }); + // Update the previous results + tracker.previousResults = results; + } + + processChanges(changes) { + for(const tracker of this.trackers.values()) { + Object.keys(changes).forEach(title => { + if(title && tracker.previousResults.includes(title) && tracker.fnChange) { + tracker.resultValues[title] = tracker.fnChange(title, tracker.resultValues[title]) || tracker.resultValues[title]; + } + }); + if(tracker.fnProcess) { + tracker.fnProcess(changes); + } + } + } +} + +exports.FilterTracker = FilterTracker; diff --git a/core/modules/info/mediaquerytracker.js b/core/modules/info/mediaquerytracker.js new file mode 100644 index 0000000000..62c51e54c6 --- /dev/null +++ b/core/modules/info/mediaquerytracker.js @@ -0,0 +1,67 @@ +/*\ +title: $:/core/modules/info/mediaquerytracker.js +type: application/javascript +module-type: info + +Initialise $:/info/ tiddlers derived from media queries via + +\*/ + +"use strict"; + +exports.getInfoTiddlerFields = function(updateInfoTiddlersCallback) { + if($tw.browser) { + // Functions to start and stop tracking a particular media query tracker tiddler + function track(title) { + var result = {}, + tiddler = $tw.wiki.getTiddler(title); + if(tiddler) { + var mediaQuery = tiddler.fields["media-query"], + infoTiddler = tiddler.fields["info-tiddler"], + infoTiddlerAlt = tiddler.fields["info-tiddler-alt"]; + if(mediaQuery && infoTiddler) { + // Evaluate and track the media query + result.mqList = window.matchMedia(mediaQuery); + function getResultTiddlers() { + var value = result.mqList.matches ? "yes" : "no", + tiddlers = []; + tiddlers.push({title: infoTiddler, text: value}); + if(infoTiddlerAlt) { + tiddlers.push({title: infoTiddlerAlt, text: value}); + } + return tiddlers; + }; + updateInfoTiddlersCallback(getResultTiddlers()); + result.handler = function(event) { + updateInfoTiddlersCallback(getResultTiddlers()); + }; + result.mqList.addEventListener("change",result.handler); + } + } + return result; + } + function untrack(enterValue) { + if(enterValue.mqList && enterValue.handler) { + enterValue.mqList.removeEventListener("change",enterValue.handler); + } + } + // Track media query tracker tiddlers + function fnEnter(title) { + return track(title); + } + function fnLeave(title,enterValue) { + untrack(enterValue); + } + function fnChange(title,enterValue) { + untrack(enterValue); + return track(title); + } + $tw.filterTracker.track({ + filterString: "[all[tiddlers+shadows]tag[$:/tags/MediaQueryTracker]!is[draft]]", + fnEnter: fnEnter, + fnLeave: fnLeave, + fnChange: fnChange + }); + } + return []; +}; diff --git a/core/modules/info/platform.js b/core/modules/info/platform.js index 9775b2d6b4..5db5bfc0a2 100644 --- a/core/modules/info/platform.js +++ b/core/modules/info/platform.js @@ -33,13 +33,6 @@ exports.getInfoTiddlerFields = function(updateInfoTiddlersCallback) { // Screen size infoTiddlerFields.push({title: "$:/info/browser/screen/width", text: window.screen.width.toString()}); infoTiddlerFields.push({title: "$:/info/browser/screen/height", text: window.screen.height.toString()}); - // Dark mode through event listener on MediaQueryList - var mqList = window.matchMedia("(prefers-color-scheme: dark)"), - getDarkModeTiddler = function() {return {title: "$:/info/darkmode", text: mqList.matches ? "yes" : "no"};}; - infoTiddlerFields.push(getDarkModeTiddler()); - mqList.addListener(function(event) { - updateInfoTiddlersCallback([getDarkModeTiddler()]); - }); // Language infoTiddlerFields.push({title: "$:/info/browser/language", text: navigator.language || ""}); } diff --git a/core/modules/startup/load-modules.js b/core/modules/startup/load-modules.js index c94335fb7e..22b4dfd453 100644 --- a/core/modules/startup/load-modules.js +++ b/core/modules/startup/load-modules.js @@ -13,6 +13,11 @@ Load core modules exports.name = "load-modules"; exports.synchronous = true; +// Set to `true` to enable performance instrumentation +var PERFORMANCE_INSTRUMENTATION_CONFIG_TITLE = "$:/config/Performance/Instrumentation"; + +var widget = require("$:/core/modules/widgets/widget.js"); + exports.startup = function() { // Load modules $tw.modules.applyMethods("utils",$tw.utils); @@ -31,6 +36,27 @@ exports.startup = function() { $tw.modules.applyMethods("tiddlerdeserializer",$tw.Wiki.tiddlerDeserializerModules); $tw.macros = $tw.modules.getModulesByTypeAsHashmap("macro"); $tw.wiki.initParsers(); + // -------------------------- + // The rest of the startup process here is not strictly to do with loading modules, but are needed before other startup + // modules are executed. It is easier to put them here than to introduce a new startup module + // -------------------------- + // Create a root widget for attaching event handlers. By using it as the parentWidget for another widget tree, one can reuse the event handlers + $tw.rootWidget = new widget.widget({ + type: "widget", + children: [] + },{ + wiki: $tw.wiki, + document: $tw.browser ? document : $tw.fakeDocument + }); + // Set up the performance framework + $tw.perf = new $tw.Performance($tw.wiki.getTiddlerText(PERFORMANCE_INSTRUMENTATION_CONFIG_TITLE,"no") === "yes"); + // Kick off the filter tracker + $tw.filterTracker = new $tw.FilterTracker($tw.wiki); + $tw.wiki.addEventListener("change",function(changes) { + $tw.filterTracker.handleChangeEvent(changes); + }); + // Kick off the background action dispatcher + $tw.backgroundActionDispatcher = new $tw.BackgroundActionDispatcher($tw.filterTracker,$tw.wiki); if($tw.node) { $tw.Commander.initCommands(); } diff --git a/core/modules/startup/startup.js b/core/modules/startup/startup.js index 6f78c71b50..c52da03f4e 100755 --- a/core/modules/startup/startup.js +++ b/core/modules/startup/startup.js @@ -14,11 +14,6 @@ exports.name = "startup"; exports.after = ["load-modules"]; exports.synchronous = true; -// Set to `true` to enable performance instrumentation -var PERFORMANCE_INSTRUMENTATION_CONFIG_TITLE = "$:/config/Performance/Instrumentation"; - -var widget = require("$:/core/modules/widgets/widget.js"); - exports.startup = function() { // Minimal browser detection if($tw.browser) { @@ -54,16 +49,6 @@ exports.startup = function() { } // Initialise version $tw.version = $tw.utils.extractVersionInfo(); - // Set up the performance framework - $tw.perf = new $tw.Performance($tw.wiki.getTiddlerText(PERFORMANCE_INSTRUMENTATION_CONFIG_TITLE,"no") === "yes"); - // Create a root widget for attaching event handlers. By using it as the parentWidget for another widget tree, one can reuse the event handlers - $tw.rootWidget = new widget.widget({ - type: "widget", - children: [] - },{ - wiki: $tw.wiki, - document: $tw.browser ? document : $tw.fakeDocument - }); // Kick off the language manager and switcher $tw.language = new $tw.Language(); $tw.languageSwitcher = new $tw.PluginSwitcher({ diff --git a/core/wiki/config/MediaQueryTrackers/DarkLightPreferred.tid b/core/wiki/config/MediaQueryTrackers/DarkLightPreferred.tid new file mode 100644 index 0000000000..6b5e9f26cb --- /dev/null +++ b/core/wiki/config/MediaQueryTrackers/DarkLightPreferred.tid @@ -0,0 +1,5 @@ +title: $:/core/wiki/config/MediaQueryTrackers/DarkLightPreferred +tags: $:/tags/MediaQueryTracker +media-query: (prefers-color-scheme: dark) +info-tiddler: $:/info/browser/darkmode +info-tiddler-alt: $:/info/darkmode diff --git a/editions/tw5.com/tiddlers/mechanisms/Background Actions.tid b/editions/tw5.com/tiddlers/mechanisms/Background Actions.tid new file mode 100644 index 0000000000..07b8e32c45 --- /dev/null +++ b/editions/tw5.com/tiddlers/mechanisms/Background Actions.tid @@ -0,0 +1,22 @@ +title: Background Actions +created: 20250212154426403 +modified: 20250212154426403 +tags: Mechanisms + +<<.demo-tiddler """ +title: SampleBackgroundAction: Story Change +tags: $:/tags/BackgroundAction +track-filter: [list[$:/StoryList]] + +<$action-sendmessage $message="tm-notify" $param="SampleBackgroundAction: Story Change" list={{$:/StoryList!!list}}/> + +Story List: + +
    +<$list filter="[enlist]"> +
  1. +<$text text=<>/> +
  2. + +
+""">> \ No newline at end of file diff --git a/editions/tw5.com/tiddlers/mechanisms/InfoMechanism.tid b/editions/tw5.com/tiddlers/mechanisms/InfoMechanism.tid index e22ff238a1..3e46efe481 100644 --- a/editions/tw5.com/tiddlers/mechanisms/InfoMechanism.tid +++ b/editions/tw5.com/tiddlers/mechanisms/InfoMechanism.tid @@ -4,8 +4,8 @@ tags: Mechanisms title: InfoMechanism type: text/vnd.tiddlywiki -\define example(name) -<$transclude tiddler="""$:/info/url/$name$""" mode="inline"/> +\procedure example(name) +<$text text={{{ [[$:/info/url/]addsuffixget[text]] }}} /> \end System tiddlers in the namespace `$:/info/` are used to expose information about the system (including the current browser) so that WikiText applications can adapt themselves to available features. @@ -19,6 +19,8 @@ System tiddlers in the namespace `$:/info/` are used to expose information about |[[$:/info/browser/language]] |<<.from-version "5.1.20">> Language as reported by browser (note that some browsers report two character codes such as `en` while others report full codes such as `en-GB`) | |[[$:/info/browser/screen/width]] |Screen width in pixels | |[[$:/info/browser/screen/height]] |Screen height in pixels | +|[[$:/info/browser/darkmode]] |<<.from-version "5.4.0">> Is dark mode preferred? ("yes" or "no") | +|[[$:/info/darkmode]] |<<.deprecated-since "5.4.0">> Alias for $:/info/browser/darkmode | |`$:/info/browser/window/*` |<<.from-version "5.4.0">> Tiddlers reporting window dimensions, updated when the windows are resized | |[[$:/info/node]] |Running under [[Node.js]]? ("yes" or "no") | |[[$:/info/url/full]] |<<.from-version "5.1.14">> Full URL of wiki (eg, ''<>'') | diff --git a/editions/tw5.com/tiddlers/mechanisms/MediaQueryTrackerMechanism.tid b/editions/tw5.com/tiddlers/mechanisms/MediaQueryTrackerMechanism.tid new file mode 100644 index 0000000000..f95219b0d8 --- /dev/null +++ b/editions/tw5.com/tiddlers/mechanisms/MediaQueryTrackerMechanism.tid @@ -0,0 +1,13 @@ +title: Media Query Tracker Mechanism +tags: Mechanisms +created: 20250212154426403 +modified: 20250212154426403 + + +<<.from-version "5.4.0">> The media query tracker mechanism allows you to define [[custom CSS media queries|https://developer.mozilla.org/en-US/docs/Web/CSS/CSS_media_queries/Using_media_queries]] to be bound to a specified [[info|InfoMechanism]] tiddler. The info tiddler will be dynamically update to reflect the current state of the media query. + +Adding or modifying a tiddler tagged $:/tags/MediaQueryTracker takes effect immediately. + +The media queries are always applied against the main window. This is relevant for viewport related media queries such as `min-width` which will always respect the main window and ignore the sizes of any external windows. + +The core includes a media query tracker that is used for tracking the operating system dark/light setting. See $:/core/wiki/config/MediaQueryTrackers/DarkLightPreferred for details. diff --git a/editions/tw5.com/tiddlers/releasenotes/5.4.0/#9641.tid b/editions/tw5.com/tiddlers/releasenotes/5.4.0/#9641.tid new file mode 100644 index 0000000000..bdf0eed7b1 --- /dev/null +++ b/editions/tw5.com/tiddlers/releasenotes/5.4.0/#9641.tid @@ -0,0 +1,14 @@ +title: $:/changenotes/5.4.0/#9641 +description: Background actions and media query tracking +tags: $:/tags/ChangeNote +release: 5.4.0 +change-type: enhancement +change-category: internal +github-links: https://github.com/TiddlyWiki/TiddlyWiki5/pull/9641 +github-contributors: Jermolene + +Introduces[[Background Actions]] that are triggered whenever there is a change to the results of a specified filter. + +Building on that, it also introduces a new [[Media Query Tracker Mechanism]] that can track the results of any CSS media query (not just dark mode), storing the results in a shadow `$:/info/...` tiddler + +These improvements were cherrypicked from [[#8702 - Colour Handling Improvements|https://github.com/TiddlyWiki/TiddlyWiki5/pull/8702]] when it was deferred until v5.5.0.