diff --git a/plugins/tiddlywiki/dynannotate/docs/readme.tid b/plugins/tiddlywiki/dynannotate/docs/readme.tid
index 9142e16c2..dcdef9799 100644
--- a/plugins/tiddlywiki/dynannotate/docs/readme.tid
+++ b/plugins/tiddlywiki/dynannotate/docs/readme.tid
@@ -1,12 +1,10 @@
title: $:/plugins/tiddlywiki/dynannotate/readme
-The ''Dynannotate'' plugin allows annotations on textual content to be created and displayed. It has three components:
+The ''Dynannotate'' plugin allows annotations on textual content to be created and displayed. It has several components:
-* The dynannotate widget overlays clickable textual annotations, search highlights and search snippets on the content that it contains
-* The selection tracker displays a popup that tracks the selection, and keeps track of the selected text. It also tracks a prefix and suffix that can be used to disambiguate the selected text within the container
-* The `<$action-popup>` widget is used for some specialised popup switching in the demo
-
-''Note that the TiddlyWiki core plugin __Dynaview__ is required for correct operation of __Dynannotate__''
+* The dynannotate widget draws clickable textual annotations, search highlights and search snippets as overlays over the top of the content that it contains
+* The selection tracker keeps track of changes to the selected text in the main browser window. It triggers an action string when the selection changes, passing it the details of the selection. It can be used to display a popup menu
+** The original legacy selection tracker is also provided for backwards compatibility. It is much more limited, and not recommended for new projects
!! Dynannotate Widget
@@ -32,6 +30,10 @@ The `<$dynannotate>` widget uses the selection tracker to support a popup that d
|searchCaseSensitive |"no" (default) for a case insensitive search, or "yes" for a case sensitive search |
|searchClass |Optional CSS class to be added to search overlays |
|snippetContextLength |Optional length of search result contextual prefix/suffix |
+
+The following attributes are only used with the legacy selection tracker:
+
+|!Attribute |!Description |
|selection |Tiddler to which the currently selected text should be dynamically saved |
|selectionPrefix |Tiddler to which up to 50 characters preceding the currently selected text should be dynamically saved |
|selectionSuffix |Tiddler to which up to 50 characters succeeding the currently selected text should be dynamically saved |
@@ -91,9 +93,71 @@ An annotation tiddler is a tiddler describing an annotation to be overlaid over
Note that using the `annotate-tiddler` field to associate an annotation with the annotated tiddler is a lightweight convention employed by the examples; it isn't actually required by any of the JavaScript code. Thus authors can experiment with other techniques for recording the association.
-!! Selection Tracker
+!! Selection Trackers
-The selection tracker is incorporated within the `<$dynannotate>` widget, but it can be used independently for specialised applications.
+The following configuration tiddlers can be used to control whether the selection trackers are enabled when the following configuration tiddlers are set to ''yes'' (the default).
+
+* $:/config/Dynannotate/SelectionTracker/Enable for the main selection tracker
+* $:/config/Dynannotate/LegacySelectionTracker/Enable for the legacy selection tracker
+
+Both selection trackers are enabled by default.
+
+!!! Main Selection Tracker
+
+The selection tracker triggers an action string whenever the browser text selection changes. The actions are delayed until the selection has not changed for 500ms (this means that the selection tracker is only triggered when the user pauses after completing a selection, and is not continuously invoked as the user drags the selection).
+
+The selection tracker works within DOM subtrees that have the following structure:
+
+* The outer wrapper element has the attribute `data-selection-action-title` containing the title of the tiddler containing the action string to be invoked when the selection changes
+* Each child element of the outer element must have a unique `id` attribute to identify it
+
+```
+
+
+ Content text
+
+ ...
+
+```
+
+The action string is invoked with the following variables:
+
+|!Variable |!Description |
+|`selection` |A JSON object representing the selection (see below) |
+|`dom-*` |All DOM attributes of the outer wrapper node are made available as variables, with the prefix `dom-` |
+|`tv-selection-posx` |X position of the selection in pixels |
+|`tv-selection-posy` |Y position of the selection in pixels |
+|`tv-selection-width` |Width of the selection in pixels |
+|`tv-selection-height` |Height of the selection in pixels |
+|`tv-selection-coords` |A co-ordinate string that can be used with the ActionPopupWidget to trigger a popup at the selection |
+
+The JSON representation of the selection is as follows:
+
+```
+{
+ "chunks": [
+ {
+ "id": "id-of-first-chunk-of-selection",
+ "text": "text-of-first-chunk-of-selection",
+ "prefix": "optional-prefix-before-first-chunk-of-selection",
+ "suffix": "optional-suffix-after-last-chunk-of-selection"
+ }
+ ...
+ ],
+ "variables": {
+
+ }
+}
+```
+
+Notes:
+
+* Only the first chunk of the selection may have a "prefix" field which will contain any text at the start of the chunk preceding the selection
+* Only the last chunk of the selection may have a "suffix" field which will contain any text at the end of the chunk following the selection
+
+!!! Legacy Selection Tracker
+
+The selection tracker is incorporated within the `<$dynannotate>` widget via the ''selection'', ''selectionPrefix'' ''selectionSuffix'' and ''selectionPopup'' attributes. It can be used independently for specialised applications.
Each selection container is marked with the class `tc-dynannotate-selection-container`, and should contain the following attributes:
diff --git a/plugins/tiddlywiki/dynannotate/modules/dynannotate.js b/plugins/tiddlywiki/dynannotate/modules/dynannotate.js
index f96000c48..9a913384d 100644
--- a/plugins/tiddlywiki/dynannotate/modules/dynannotate.js
+++ b/plugins/tiddlywiki/dynannotate/modules/dynannotate.js
@@ -183,7 +183,16 @@ DynannotateWidget.prototype.applyAnnotations = function() {
// We'll dynamically build the click event handler so that we can reuse it
var clickHandlerFn = function(title) {
return function(event,domOverlay,modifierKey) {
- self.invokeActionString(self.getAttribute("actions"),self,event,{annotationTiddler: title, modifier: modifierKey});
+ var bounds = domOverlay.getBoundingClientRect();
+ self.invokeActionString(self.getAttribute("actions"),self,event,{
+ annotationTiddler: title,
+ modifier: modifierKey,
+ "tv-selection-posx": (bounds.left).toString(),
+ "tv-selection-posy": (bounds.top).toString(),
+ "tv-selection-width": (bounds.width).toString(),
+ "tv-selection-height": (bounds.height).toString(),
+ "tv-selection-coords": "(" + bounds.left + "," + bounds.top + "," + bounds.width + "," + bounds.height + ")"
+ });
if(self.hasAttribute("popup")) {
$tw.popup.triggerPopup({
domNode: domOverlay,
diff --git a/plugins/tiddlywiki/dynannotate/modules/legacy-selection-tracker.js b/plugins/tiddlywiki/dynannotate/modules/legacy-selection-tracker.js
new file mode 100644
index 000000000..f282601f8
--- /dev/null
+++ b/plugins/tiddlywiki/dynannotate/modules/legacy-selection-tracker.js
@@ -0,0 +1,104 @@
+/*\
+title: $:/plugins/tiddlywiki/dynannotate/legacy-selection-tracker.js
+type: application/javascript
+module-type: library
+
+Legacy version of the dyannotate background daemon to track the selection
+
+\*/
+(function(){
+
+/*jslint node: true, browser: true */
+/*global $tw: false */
+"use strict";
+
+var TextMap = require("$:/plugins/tiddlywiki/dynannotate/textmap.js").TextMap;
+
+function LegacySelectionTracker(wiki,options) {
+ options = options || {};
+ var self = this;
+ this.wiki = wiki;
+ this.allowBlankSelectionPopup = options.allowBlankSelectionPopup;
+ this.selectionPopupTitle = null;
+ document.addEventListener("selectionchange",function(event) {
+ var selection = document.getSelection();
+ if(selection && (selection.type === "Range" || (self.allowBlankSelectionPopup && !self.selectionPopupTitle))) {
+ // Look for the selection containers for each of the two ends of the selection
+ var anchorContainer = self.findSelectionContainer(selection.anchorNode),
+ focusContainer = self.findSelectionContainer(selection.focusNode);
+ // If either end of the selection then we ignore it
+ if(!!anchorContainer || !!focusContainer) {
+ var selectionRange = selection.getRangeAt(0);
+ // Check for the selection spilling outside the starting container
+ if((anchorContainer !== focusContainer) || (selectionRange.startContainer.nodeType !== Node.TEXT_NODE && selectionRange.endContainer.nodeType !== Node.TEXT_NODE)) {
+ if(self.selectionPopupTitle) {
+ self.wiki.deleteTiddler(self.selectionPopupTitle);
+ self.selectionPopupTitle = null;
+ }
+ } else {
+ self.selectionSaveTitle = anchorContainer.getAttribute("data-annotation-selection-save");
+ self.selectionPrefixSaveTitle = anchorContainer.getAttribute("data-annotation-selection-prefix-save");
+ self.selectionSuffixSaveTitle = anchorContainer.getAttribute("data-annotation-selection-suffix-save");
+ self.selectionPopupTitle = anchorContainer.getAttribute("data-annotation-selection-popup");
+ // The selection is a range so we trigger the popup
+ if(self.selectionPopupTitle) {
+ var selectionRectangle = selectionRange.getBoundingClientRect(),
+ trackingRectangle = anchorContainer.getBoundingClientRect();
+ $tw.popup.triggerPopup({
+ domNode: null,
+ domNodeRect: {
+ left: selectionRectangle.left - trackingRectangle.left,
+ top: selectionRectangle.top - trackingRectangle.top,
+ width: selectionRectangle.width,
+ height: selectionRectangle.height
+ },
+ force: true,
+ floating: true,
+ title: self.selectionPopupTitle,
+ wiki: self.wiki
+ });
+ }
+ // Write the selection text to the specified tiddler
+ if(self.selectionSaveTitle) {
+ // Note that selection.toString() normalizes whitespace but selection.getRangeAt(0).toString() does not
+ var text = selectionRange.toString();
+ self.wiki.addTiddler(new $tw.Tiddler({title: self.selectionSaveTitle, text: text}));
+ // Build a textmap of the container so that we can find the prefix and suffix
+ var textMap = new TextMap(anchorContainer);
+ // Find the selection start in the text map and hence extract the prefix and suffix
+ var context = textMap.extractContext(selectionRange.startContainer,selectionRange.startOffset,text);
+ // Save the prefix and suffix
+ if(context) {
+ if(self.selectionPrefixSaveTitle) {
+ self.wiki.addTiddler(new $tw.Tiddler({title: self.selectionPrefixSaveTitle, text: context.prefix}));
+ }
+ if(self.selectionSuffixSaveTitle) {
+ self.wiki.addTiddler(new $tw.Tiddler({title: self.selectionSuffixSaveTitle, text: context.suffix}));
+ }
+ }
+ }
+ }
+ }
+ } else {
+ // If the selection is a caret we clear any active popup
+ if(self.selectionPopupTitle) {
+ self.wiki.deleteTiddler(self.selectionPopupTitle);
+ self.selectionPopupTitle = null;
+ }
+ }
+ });
+}
+
+LegacySelectionTracker.prototype.findSelectionContainer = function findSelectionContainer(domNode) {
+ if(domNode && domNode.nodeType === Node.ELEMENT_NODE && domNode.classList.contains("tc-dynannotation-selection-container")) {
+ return domNode;
+ }
+ if(domNode && domNode.parentNode) {
+ return findSelectionContainer(domNode.parentNode);
+ }
+ return null;
+};
+
+exports.LegacySelectionTracker = LegacySelectionTracker;
+
+})();
diff --git a/plugins/tiddlywiki/dynannotate/modules/selection-tracker.js b/plugins/tiddlywiki/dynannotate/modules/selection-tracker.js
index 20113b89d..616bedd29 100644
--- a/plugins/tiddlywiki/dynannotate/modules/selection-tracker.js
+++ b/plugins/tiddlywiki/dynannotate/modules/selection-tracker.js
@@ -1,9 +1,9 @@
/*\
title: $:/plugins/tiddlywiki/dynannotate/selection-tracker.js
type: application/javascript
-module-type: startup
+module-type: library
-Dyannotate background daemon to track the selection
+Background daemon to track the selection
\*/
(function(){
@@ -12,105 +12,160 @@ Dyannotate background daemon to track the selection
/*global $tw: false */
"use strict";
-// Export name and synchronous status
-exports.name = "dyannotate-startup";
-exports.platforms = ["browser"];
-exports.after = ["render"];
-exports.synchronous = true;
-
-var TextMap = require("$:/plugins/tiddlywiki/dynannotate/textmap.js").TextMap;
-
-exports.startup = function() {
- $tw.dynannotate = {
- selectionTracker: new SelectionTracker($tw.wiki,{
- allowBlankSelectionPopup: true
- })
- };
-};
-
function SelectionTracker(wiki,options) {
options = options || {};
var self = this;
this.wiki = wiki;
- this.allowBlankSelectionPopup = options.allowBlankSelectionPopup;
- this.selectionPopupTitle = null;
+ var timerId = null;
document.addEventListener("selectionchange",function(event) {
- var selection = document.getSelection();
- if(selection && (selection.type === "Range" || (self.allowBlankSelectionPopup && !self.selectionPopupTitle))) {
- // Look for the selection containers for each of the two ends of the selection
- var anchorContainer = self.findSelectionContainer(selection.anchorNode),
- focusContainer = self.findSelectionContainer(selection.focusNode);
- // If either end of the selection then we ignore it
- if(!!anchorContainer || !!focusContainer) {
- var selectionRange = selection.getRangeAt(0);
- // Check for the selection spilling outside the starting container
- if((anchorContainer !== focusContainer) || (selectionRange.startContainer.nodeType !== Node.TEXT_NODE && selectionRange.endContainer.nodeType !== Node.TEXT_NODE)) {
- if(self.selectionPopupTitle) {
- self.wiki.deleteTiddler(self.selectionPopupTitle);
- self.selectionPopupTitle = null;
- }
- } else {
- self.selectionSaveTitle = anchorContainer.getAttribute("data-annotation-selection-save");
- self.selectionPrefixSaveTitle = anchorContainer.getAttribute("data-annotation-selection-prefix-save");
- self.selectionSuffixSaveTitle = anchorContainer.getAttribute("data-annotation-selection-suffix-save");
- self.selectionPopupTitle = anchorContainer.getAttribute("data-annotation-selection-popup");
- // The selection is a range so we trigger the popup
- if(self.selectionPopupTitle) {
- var selectionRectangle = selectionRange.getBoundingClientRect(),
- trackingRectangle = anchorContainer.getBoundingClientRect();
- $tw.popup.triggerPopup({
- domNode: null,
- domNodeRect: {
- left: selectionRectangle.left - trackingRectangle.left,
- top: selectionRectangle.top - trackingRectangle.top,
- width: selectionRectangle.width,
- height: selectionRectangle.height
- },
- force: true,
- floating: true,
- title: self.selectionPopupTitle,
- wiki: self.wiki
- });
- }
- // Write the selection text to the specified tiddler
- if(self.selectionSaveTitle) {
- // Note that selection.toString() normalizes whitespace but selection.getRangeAt(0).toString() does not
- var text = selectionRange.toString();
- self.wiki.addTiddler(new $tw.Tiddler({title: self.selectionSaveTitle, text: text}));
- // Build a textmap of the container so that we can find the prefix and suffix
- var textMap = new TextMap(anchorContainer);
- // Find the selection start in the text map and hence extract the prefix and suffix
- var context = textMap.extractContext(selectionRange.startContainer,selectionRange.startOffset,text);
- // Save the prefix and suffix
- if(context) {
- if(self.selectionPrefixSaveTitle) {
- self.wiki.addTiddler(new $tw.Tiddler({title: self.selectionPrefixSaveTitle, text: context.prefix}));
- }
- if(self.selectionSuffixSaveTitle) {
- self.wiki.addTiddler(new $tw.Tiddler({title: self.selectionSuffixSaveTitle, text: context.suffix}));
- }
- }
- }
- }
- }
- } else {
- // If the selection is a caret we clear any active popup
- if(self.selectionPopupTitle) {
- self.wiki.deleteTiddler(self.selectionPopupTitle);
- self.selectionPopupTitle = null;
- }
+ if(timerId) {
+ clearTimeout(timerId);
}
+ timerId = setTimeout(function() {
+ timerId = null;
+ self.handleSelectionChange();
+ },500);
});
}
-SelectionTracker.prototype.findSelectionContainer = function findSelectionContainer(domNode) {
- if(domNode && domNode.nodeType === Node.ELEMENT_NODE && domNode.classList.contains("tc-dynannotation-selection-container")) {
- return domNode;
+SelectionTracker.prototype.handleSelectionChange = function() {
+ var selection = document.getSelection();
+ if(selection && selection.type === "Range") {
+ // Helper to get the tiddler title corresponding to a chunk container
+ var getIdOfContainer = function(domNode) {
+ return domNode.id;
+ }
+ // Get information about the selection anchor and focus
+ var getSelectionInfo = function(targetDomNode,targetOffset) {
+ // Find the chunk container node
+ var domNode = targetDomNode;
+ if(domNode.nodeType === Node.TEXT_NODE) {
+ domNode = domNode.parentNode;
+ }
+ var container = domNode.closest(".dynannotate-chunk");
+ if(!container) {
+ return null;
+ }
+ // Find the index of the container within the child nodes of its parent
+ var childNodeIndex = Array.prototype.indexOf.call(container.parentNode.childNodes,container);
+ // Walk through the chunk collecting the text before and after the specified domNode and offset
+ var beforeText = null, afterText = [];
+ var splitTextResult = function() {
+ beforeText = afterText;
+ afterText = [];
+ },
+ processNode = function(domNode) {
+ // Check for a text node
+ if(domNode.nodeType === Node.TEXT_NODE) {
+ // If this is the target node then perform the split
+ if(domNode === targetDomNode) {
+ afterText.push(domNode.textContent.substring(0,targetOffset));
+ splitTextResult();
+ afterText.push(domNode.textContent.substring(targetOffset));
+ } else {
+ afterText.push(domNode.textContent);
+ }
+ } else {
+ // Process the child nodes
+ $tw.utils.each(domNode.childNodes,function(childNode,childNodeIndex) {
+ // Check whether we need to split on this child node
+ if(domNode === targetDomNode && childNodeIndex === targetOffset) {
+ splitTextResult();
+ }
+ processNode(childNode);
+ });
+ }
+ };
+ processNode(container);
+ if(beforeText === null) {
+ splitTextResult();
+ }
+ // Return results
+ return {
+ container: container,
+ childNodeIndex: childNodeIndex,
+ beforeText: beforeText.join(""),
+ afterText: afterText.join("")
+ }
+
+ }
+ var anchor = getSelectionInfo(selection.anchorNode,selection.anchorOffset),
+ focus = getSelectionInfo(selection.focusNode,selection.focusOffset);
+ // Check that the containers share a parent
+ if(anchor && focus && anchor.container.parentNode === focus.container.parentNode) {
+ // Make sure that the anchor is before the focus
+ if((anchor.childNodeIndex > focus.childNodeIndex) || (anchor.container === focus.container && anchor.beforeText.length > focus.beforeText.length)) {
+ var temp = anchor;
+ anchor = focus;
+ focus = temp;
+ }
+ var chunks = [];
+ // Check for the selection being all in one chunk
+ if(anchor.container === focus.container) {
+ chunks.push({
+ id: getIdOfContainer(anchor.container),
+ prefix: anchor.beforeText,
+ text: anchor.afterText.substring(0,anchor.afterText.length - focus.afterText.length),
+ suffix: focus.afterText
+ });
+ } else {
+ // We span two or more chunks
+ chunks.push({
+ id: getIdOfContainer(anchor.container),
+ prefix: anchor.beforeText,
+ text: anchor.afterText
+ });
+ // Get the titles and text of the intervening tiddlers
+ var domNode;
+ if(anchor.container !== focus.container) {
+ domNode = anchor.container.nextElementSibling;
+ while(domNode && domNode !== focus.container) {
+ chunks.push({
+ id: getIdOfContainer(domNode),
+ text: domNode.textContent
+ });
+ domNode = domNode.nextElementSibling;
+ }
+ }
+ chunks.push({
+ id: getIdOfContainer(focus.container),
+ text: focus.beforeText,
+ suffix: focus.afterText
+ });
+ }
+ // Get the title of the tiddler containing the actions to be executed
+ var actionsTiddler = anchor.container.parentNode.getAttribute("data-selection-actions-title");
+ // Assemble the variables to be passed to the action
+ var variables = {};
+ // Get the bounds of the container and the selection
+ var selectionRectangle = selection.getRangeAt(0).getBoundingClientRect(),
+ trackingRectangle = anchor.container.parentNode.getBoundingClientRect();
+ variables["tv-selection-posx"] = (selectionRectangle.left).toString();
+ variables["tv-selection-posy"] = (selectionRectangle.top).toString();
+ variables["tv-selection-width"] = (selectionRectangle.width).toString();
+ variables["tv-selection-height"] = (selectionRectangle.height).toString();
+ variables["tv-selection-coords"] = "(" + variables["tv-selection-posx"] + "," + variables["tv-selection-posy"] + "," + variables["tv-selection-width"] + "," + variables["tv-selection-height"] + ")";
+ // Collect the attributes from the container
+ $tw.utils.each(anchor.container.parentNode.attributes,function(attribute) {
+ variables["dom-" + attribute.name] = attribute.value.toString();
+ });
+ // Action the selection
+ this.performSelectionActions(chunks,variables,actionsTiddler);
+ }
}
- if(domNode && domNode.parentNode) {
- return findSelectionContainer(domNode.parentNode);
- }
- return null;
};
+SelectionTracker.prototype.performSelectionActions = function(chunks,variables,actionsTiddler) {
+ // Invoke the actions, passing the extract tiddler title as a variable
+ if(actionsTiddler) {
+ var actions = $tw.wiki.getTiddlerText(actionsTiddler)
+ if(actions) {
+ var selection = JSON.stringify({chunks: chunks,variables: variables});
+ $tw.rootWidget.invokeActionString(actions,undefined,undefined,$tw.utils.extend({},variables,{selection: selection}));
+ }
+ }
+};
+
+exports.SelectionTracker = SelectionTracker;
+
})();
diff --git a/plugins/tiddlywiki/dynannotate/modules/startup.js b/plugins/tiddlywiki/dynannotate/modules/startup.js
new file mode 100644
index 000000000..a2fe6a7f6
--- /dev/null
+++ b/plugins/tiddlywiki/dynannotate/modules/startup.js
@@ -0,0 +1,40 @@
+/*\
+title: $:/plugins/tiddlywiki/dynannotate/startup.js
+type: application/javascript
+module-type: startup
+
+Startup the dyannotate background daemon to track the selection
+
+\*/
+(function(){
+
+/*jslint node: true, browser: true */
+/*global $tw: false */
+"use strict";
+
+// Export name and synchronous status
+exports.name = "dyannotate-startup";
+exports.platforms = ["browser"];
+exports.after = ["render"];
+exports.synchronous = true;
+
+var CONFIG_SELECTION_TRACKER_TITLE = "$:/config/Dynannotate/SelectionTracker/Enable",
+ CONFIG_LEGACY_SELECTION_TRACKER_TITLE = "$:/config/Dynannotate/LegacySelectionTracker/Enable";
+
+var SelectionTracker = require("$:/plugins/tiddlywiki/dynannotate/selection-tracker.js").SelectionTracker,
+ LegacySelectionTracker = require("$:/plugins/tiddlywiki/dynannotate/legacy-selection-tracker.js").LegacySelectionTracker;
+
+exports.startup = function() {
+ $tw.dynannotate = {};
+ if($tw.wiki.getTiddlerText(CONFIG_SELECTION_TRACKER_TITLE,"yes") === "yes") {
+ $tw.dynannotate.selectionTracker = new SelectionTracker($tw.wiki);
+ }
+ if($tw.wiki.getTiddlerText(CONFIG_LEGACY_SELECTION_TRACKER_TITLE,"yes") === "yes") {
+ $tw.dynannotate.legacySelectionTracker = new LegacySelectionTracker($tw.wiki,{
+ allowBlankSelectionPopup: true
+ });
+ }
+};
+
+})();
+
\ No newline at end of file