1
0
mirror of https://github.com/Jermolene/TiddlyWiki5 synced 2024-11-23 10:07:19 +00:00

Dynannotate: Improve selection tracker

These improvements rely on the new JSON operators to be useful. Those improvements were originally in #6522 but now there's an updated version in #6666. Managing things is simpler if I merge these changes now
This commit is contained in:
jeremy@jermolene.com 2022-05-25 15:23:11 +01:00
parent a226975b3e
commit df7416d16b
5 changed files with 372 additions and 100 deletions

View File

@ -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
```
<div data-selection-action-title="{tiddler title}">
<div id="{title}">
Content text
</div>
...
</div>
```
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": {
<variables listed above>
}
}
```
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:

View File

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

View File

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

View File

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

View File

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