mirror of
https://github.com/TiddlyWiki/TiddlyWiki5.git
synced 2026-05-02 10:17:03 +00:00
Introducing "Dynannotate" plugin for overlaying annotations
This commit is contained in:
418
plugins/tiddlywiki/dynannotate/modules/dynannotate.js
Normal file
418
plugins/tiddlywiki/dynannotate/modules/dynannotate.js
Normal file
@@ -0,0 +1,418 @@
|
||||
/*\
|
||||
title: $:/plugins/tiddlywiki/dynannotate/dynannotate.js
|
||||
type: application/javascript
|
||||
module-type: widget
|
||||
|
||||
Dynannotate widget
|
||||
|
||||
\*/
|
||||
(function(){
|
||||
|
||||
/*jslint node: true, browser: true */
|
||||
/*global $tw: false */
|
||||
"use strict";
|
||||
|
||||
var TextMap = require("$:/plugins/tiddlywiki/dynannotate/textmap.js").TextMap;
|
||||
|
||||
var Widget = require("$:/core/modules/widgets/widget.js").widget;
|
||||
|
||||
var DynannotateWidget = function(parseTreeNode,options) {
|
||||
this.initialise(parseTreeNode,options);
|
||||
};
|
||||
|
||||
/*
|
||||
Inherit from the base widget class
|
||||
*/
|
||||
DynannotateWidget.prototype = new Widget();
|
||||
|
||||
/*
|
||||
Render this widget into the DOM
|
||||
*/
|
||||
DynannotateWidget.prototype.render = function(parent,nextSibling) {
|
||||
var self = this;
|
||||
this.parentDomNode = parent;
|
||||
this.computeAttributes();
|
||||
this.execute();
|
||||
// Create our DOM nodes
|
||||
var isSnippetMode = this.isSnippetMode();
|
||||
this.domContent = $tw.utils.domMaker("div",{
|
||||
"class": "tc-dynannotation-selection-container"
|
||||
});
|
||||
if(isSnippetMode) {
|
||||
this.domContent.setAttribute("hidden","hidden");
|
||||
}
|
||||
this.domAnnotations = $tw.utils.domMaker("div",{
|
||||
"class": "tc-dynannotation-annotation-wrapper"
|
||||
});
|
||||
this.domSnippets = $tw.utils.domMaker("div",{
|
||||
"class": "tc-dynannotation-snippet-wrapper"
|
||||
});
|
||||
this.domSearches = $tw.utils.domMaker("div",{
|
||||
"class": "tc-dynannotation-search-wrapper"
|
||||
});
|
||||
this.domWrapper = $tw.utils.domMaker("div",{
|
||||
"class": "tc-dynannotation-wrapper",
|
||||
children: [this.domContent,this.domAnnotations,this.domSnippets,this.domSearches]
|
||||
})
|
||||
parent.insertBefore(this.domWrapper,nextSibling);
|
||||
this.domNodes.push(this.domWrapper);
|
||||
// Apply the selection tracker data to the DOM
|
||||
if(!isSnippetMode) {
|
||||
this.applySelectionTrackerData();
|
||||
}
|
||||
// Render our child widgets
|
||||
this.renderChildren(this.domContent,null);
|
||||
if(isSnippetMode) {
|
||||
// Apply search snippets
|
||||
this.applySnippets();
|
||||
} else {
|
||||
// Get the list of annotation tiddlers
|
||||
this.getAnnotationTiddlers();
|
||||
// Apply annotations
|
||||
this.applyAnnotations();
|
||||
// Apply search overlays
|
||||
this.applySearch();
|
||||
}
|
||||
// Save the width of the wrapper so that we can tell when it changes
|
||||
this.wrapperWidth = this.domWrapper.offsetWidth;
|
||||
};
|
||||
|
||||
/*
|
||||
Compute the internal state of the widget
|
||||
*/
|
||||
DynannotateWidget.prototype.execute = function() {
|
||||
// Make the child widgets
|
||||
this.makeChildWidgets();
|
||||
};
|
||||
|
||||
DynannotateWidget.prototype.isSnippetMode = function() {
|
||||
return this.getAttribute("searchDisplay") === "snippet";
|
||||
}
|
||||
|
||||
/*
|
||||
Save the data attributes required by the selection tracker
|
||||
*/
|
||||
DynannotateWidget.prototype.applySelectionTrackerData = function() {
|
||||
if(this.hasAttribute("selection")) {
|
||||
this.domContent.setAttribute("data-annotation-selection-save",this.getAttribute("selection"));
|
||||
} else {
|
||||
this.domContent.removeAttribute("data-annotation-selection-save");
|
||||
}
|
||||
if(this.hasAttribute("selectionPopup")) {
|
||||
this.domContent.setAttribute("data-annotation-selection-popup",this.getAttribute("selectionPopup"));
|
||||
} else {
|
||||
this.domContent.removeAttribute("data-annotation-selection-popup");
|
||||
}
|
||||
if(this.hasAttribute("selectionPrefix")) {
|
||||
this.domContent.setAttribute("data-annotation-selection-prefix-save",this.getAttribute("selectionPrefix"));
|
||||
} else {
|
||||
this.domContent.removeAttribute("data-annotation-selection-prefix-save");
|
||||
}
|
||||
if(this.hasAttribute("selectionSuffix")) {
|
||||
this.domContent.setAttribute("data-annotation-selection-suffix-save",this.getAttribute("selectionSuffix"));
|
||||
} else {
|
||||
this.domContent.removeAttribute("data-annotation-selection-suffix-save");
|
||||
}
|
||||
};
|
||||
|
||||
/*
|
||||
Create overlay dom elements to cover a specified range
|
||||
|
||||
options include:
|
||||
startNode: Start node of range
|
||||
startOffset: Start offset of range
|
||||
endNode: End node of range
|
||||
endOffset: End offset of range
|
||||
className: Optional classname for the overlay
|
||||
wrapper: Wrapper dom node for the overlays
|
||||
colour: Optional CSS colour for the overlay
|
||||
blendMode: Optional CSS mix blend mode for the overlay
|
||||
onclick: Optional click event handler for the overlay
|
||||
*/
|
||||
DynannotateWidget.prototype.createOverlay = function(options) {
|
||||
var self = this;
|
||||
// Create a range covering the text
|
||||
var range = this.document.createRange();
|
||||
range.setStart(options.startNode,options.startOffset);
|
||||
range.setEnd(options.endNode,options.endOffset);
|
||||
// Get the position of the range
|
||||
var rects = range.getClientRects();
|
||||
if(rects) {
|
||||
// Paint each rectangle
|
||||
var parentRect = this.domContent.getBoundingClientRect();
|
||||
$tw.utils.each(rects,function(rect) {
|
||||
var domOverlay = self.document.createElement("div");
|
||||
domOverlay.className = (options.className || "") + " tc-dynaview-request-refresh-on-resize";
|
||||
domOverlay.style.top = (rect.top - parentRect.top) + "px";
|
||||
domOverlay.style.left = (rect.left - parentRect.left) + "px";
|
||||
domOverlay.style.width = rect.width + "px";
|
||||
domOverlay.style.height = rect.height + "px";
|
||||
domOverlay.style.backgroundColor = options.colour;
|
||||
domOverlay.style.mixBlendMode = options.blendMode;
|
||||
if(options.onclick) {
|
||||
domOverlay.addEventListener("click",function(event) {
|
||||
var modifierKey = event.ctrlKey && !event.shiftKey ? "ctrl" : event.shiftKey && !event.ctrlKey ? "shift" : event.ctrlKey && event.shiftKey ? "ctrl-shift" : "normal";
|
||||
options.onclick(event,domOverlay,modifierKey);
|
||||
},false);
|
||||
}
|
||||
options.wrapper.appendChild(domOverlay);
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
DynannotateWidget.prototype.getAnnotationTiddlers = function() {
|
||||
this.annotationTiddlers = this.wiki.filterTiddlers(this.getAttribute("filter",""),this);
|
||||
};
|
||||
|
||||
DynannotateWidget.prototype.removeAnnotations = function() {
|
||||
while(this.domAnnotations.hasChildNodes()) {
|
||||
this.domAnnotations.removeChild(this.domAnnotations.firstChild);
|
||||
}
|
||||
};
|
||||
|
||||
DynannotateWidget.prototype.applyAnnotations = function() {
|
||||
var self = this;
|
||||
// Remove any previous annotation overlays
|
||||
this.removeAnnotations();
|
||||
// Don't do anything if there are no annotations to apply
|
||||
if(this.annotationTiddlers.length === 0 && !this.hasAttribute("target")) {
|
||||
return;
|
||||
}
|
||||
// Build the map of the text content
|
||||
var textMap = new TextMap(this.domContent);
|
||||
// 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});
|
||||
if(self.hasAttribute("popup")) {
|
||||
$tw.popup.triggerPopup({
|
||||
domNode: domOverlay,
|
||||
title: self.getAttribute("popup"),
|
||||
wiki: self.wiki
|
||||
});
|
||||
}
|
||||
};
|
||||
};
|
||||
// Draw the overlay for the "target" attribute
|
||||
if(this.hasAttribute("target")) {
|
||||
var result = textMap.findText(this.getAttribute("target"),this.getAttribute("targetPrefix"),this.getAttribute("targetSuffix"));
|
||||
if(result) {
|
||||
this.createOverlay({
|
||||
startNode: result.startNode,
|
||||
startOffset: result.startOffset,
|
||||
endNode: result.endNode,
|
||||
endOffset: result.endOffset,
|
||||
wrapper: self.domAnnotations,
|
||||
className: "tc-dynannotation-annotation-overlay",
|
||||
onclick: clickHandlerFn(null)
|
||||
});
|
||||
}
|
||||
}
|
||||
// Draw the overlays for each annotation tiddler
|
||||
$tw.utils.each(this.annotationTiddlers,function(title) {
|
||||
var tiddler = self.wiki.getTiddler(title),
|
||||
annotateText = tiddler.fields["annotate-text"],
|
||||
annotatePrefix = tiddler.fields["annotate-prefix"],
|
||||
annotateSuffix = tiddler.fields["annotate-suffix"];
|
||||
if(tiddler && annotateText) {
|
||||
var result = textMap.findText(annotateText,annotatePrefix,annotateSuffix);
|
||||
if(result) {
|
||||
self.createOverlay({
|
||||
startNode: result.startNode,
|
||||
startOffset: result.startOffset,
|
||||
endNode: result.endNode,
|
||||
endOffset: result.endOffset,
|
||||
wrapper: self.domAnnotations,
|
||||
className: "tc-dynannotation-annotation-overlay",
|
||||
colour: tiddler.fields["annotate-colour"],
|
||||
blendMode: tiddler.fields["annotate-blend-mode"],
|
||||
onclick: clickHandlerFn(title)
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
DynannotateWidget.prototype.removeSearch = function() {
|
||||
while(this.domSearches.hasChildNodes()) {
|
||||
this.domSearches.removeChild(this.domSearches.firstChild);
|
||||
}
|
||||
};
|
||||
|
||||
DynannotateWidget.prototype.applySearch = function() {
|
||||
var self = this;
|
||||
// Remove any previous search overlays
|
||||
this.removeSearch();
|
||||
// Gather parameters
|
||||
var searchString = this.getAttribute("search",""),
|
||||
searchMode = this.getAttribute("searchMode"),
|
||||
searchCaseSensitive = this.getAttribute("searchCaseSensitive","yes") === "yes",
|
||||
searchMinLength = parseInt(this.getAttribute("searchMinLength","1"),10) || 1;
|
||||
// Bail if search string too short
|
||||
if(searchString.length < searchMinLength) {
|
||||
return;
|
||||
}
|
||||
// Build the map of the text content
|
||||
var textMap = new TextMap(this.domContent);
|
||||
// Search for the string
|
||||
var matches = textMap.search(this.getAttribute("search",""),{
|
||||
mode: this.getAttribute("searchMode"),
|
||||
caseSensitive: this.getAttribute("searchCaseSensitive","yes") === "yes"
|
||||
});
|
||||
// Create overlays for each match
|
||||
$tw.utils.each(matches,function(match) {
|
||||
self.createOverlay({
|
||||
startNode: match.startNode,
|
||||
startOffset: match.startOffset,
|
||||
endNode: match.endNode,
|
||||
endOffset: match.endOffset,
|
||||
wrapper: self.domSearches,
|
||||
className: "tc-dynannotation-search-overlay " + self.getAttribute("searchClass","")
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
DynannotateWidget.prototype.removeSnippets = function() {
|
||||
while(this.domSnippets.hasChildNodes()) {
|
||||
this.domSnippets.removeChild(this.domSnippets.firstChild);
|
||||
}
|
||||
};
|
||||
|
||||
DynannotateWidget.prototype.applySnippets = function() {
|
||||
var self = this,
|
||||
contextLength = parseInt(this.getAttribute("snippetContextLength","33"),10) || 0;
|
||||
// Build the map of the text content
|
||||
var textMap = new TextMap(this.domContent);
|
||||
// Remove any previous snippets
|
||||
this.removeSnippets();
|
||||
// Gather parameters
|
||||
var searchString = this.getAttribute("search",""),
|
||||
searchMode = this.getAttribute("searchMode"),
|
||||
searchCaseSensitive = this.getAttribute("searchCaseSensitive","yes") === "yes",
|
||||
searchMinLength = parseInt(this.getAttribute("searchMinLength","1"),10) || 1;
|
||||
// Build the map of the text content
|
||||
var textMap = new TextMap(this.domContent);
|
||||
// Search for the string
|
||||
var matches = textMap.search(this.getAttribute("search",""),{
|
||||
mode: this.getAttribute("searchMode"),
|
||||
caseSensitive: this.getAttribute("searchCaseSensitive","no") === "yes"
|
||||
});
|
||||
// Output a snippet for each match
|
||||
if(matches && matches.length > 0) {
|
||||
var merged = false, // Keep track of whether the context of the previous match merges into this one
|
||||
ellipsis = String.fromCharCode(8230),
|
||||
container = null; // Track the container so that we can reuse the same container for merged matches
|
||||
$tw.utils.each(matches,function(match,index) {
|
||||
// Create a container if we're not reusing it
|
||||
if(!container) {
|
||||
container = $tw.utils.domMaker("div",{
|
||||
"class": "tc-dynannotate-snippet"
|
||||
});
|
||||
self.domSnippets.appendChild(container);
|
||||
}
|
||||
// Output the preceding context if it wasn't merged into the previous match
|
||||
if(!merged) {
|
||||
container.appendChild($tw.utils.domMaker("span",{
|
||||
text: (match.startPos < contextLength ? "" : ellipsis) +
|
||||
textMap.string.slice(Math.max(match.startPos - contextLength,0),match.startPos),
|
||||
"class": "tc-dynannotate-snippet-context"
|
||||
}));
|
||||
}
|
||||
// Output the match
|
||||
container.appendChild($tw.utils.domMaker("span",{
|
||||
text: textMap.string.slice(match.startPos,match.endPos),
|
||||
"class": "tc-dynannotate-snippet-highlight " + self.getAttribute("searchClass")
|
||||
}));
|
||||
// Does the context of this match merge into the next?
|
||||
merged = index < matches.length - 1 && matches[index + 1].startPos - match.endPos <= 2 * contextLength;
|
||||
if(merged) {
|
||||
// If they're merged, use the context up until the next match
|
||||
container.appendChild($tw.utils.domMaker("span",{
|
||||
text: textMap.string.slice(match.endPos,matches[index + 1].startPos),
|
||||
"class": "tc-dynannotate-snippet-context"
|
||||
}));
|
||||
} else {
|
||||
// If they're not merged, use the context up to the end
|
||||
container.appendChild($tw.utils.domMaker("span",{
|
||||
text: textMap.string.slice(match.endPos,match.endPos + contextLength) +
|
||||
((match.endPos + contextLength) >= textMap.string.length ? "" : ellipsis),
|
||||
"class": "tc-dynannotate-snippet-context"
|
||||
}));
|
||||
}
|
||||
// Reuse the next container if we're merged
|
||||
if(!merged) {
|
||||
container = null;
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
/*
|
||||
Selectively refreshes the widget if needed. Returns true if the widget or any of its children needed re-rendering
|
||||
*/
|
||||
DynannotateWidget.prototype.refresh = function(changedTiddlers) {
|
||||
// Get the changed attributes
|
||||
var changedAttributes = this.computeAttributes();
|
||||
// Refresh completely if the "searchDisplay" attribute has changed
|
||||
if(changedAttributes.searchDisplay) {
|
||||
this.refreshSelf();
|
||||
return true;
|
||||
}
|
||||
// Check whether we're in snippet mode
|
||||
var isSnippetMode = this.isSnippetMode();
|
||||
// Refresh the child widgets
|
||||
var childrenDidRefresh = this.refreshChildren(changedTiddlers);
|
||||
// Reapply the selection tracker data to the DOM
|
||||
if(changedAttributes.selection || changedAttributes.selectionPrefix || changedAttributes.selectionSuffix || changedAttributes.selectionPopup) {
|
||||
this.applySelectionTrackerData();
|
||||
}
|
||||
// Reapply the annotations if the children refreshed or the main wrapper resized
|
||||
var wrapperWidth = this.domWrapper.offsetWidth,
|
||||
hasResized = wrapperWidth !== this.wrapperWidth || changedTiddlers["$:/state/DynaView/ViewportDimensions/ResizeCount"],
|
||||
oldAnnotationTiddlers = this.annotationTiddlers;
|
||||
this.getAnnotationTiddlers();
|
||||
if(!isSnippetMode && (
|
||||
childrenDidRefresh ||
|
||||
hasResized ||
|
||||
changedAttributes.target ||
|
||||
changedAttributes.targetPrefix ||
|
||||
changedAttributes.targetSuffix ||
|
||||
changedAttributes.filter ||
|
||||
changedAttributes.actions ||
|
||||
changedAttributes.popup ||
|
||||
!$tw.utils.isArrayEqual(oldAnnotationTiddlers,this.annotationTiddlers) ||
|
||||
this.annotationTiddlers.find(function(title) {
|
||||
return changedTiddlers[title];
|
||||
}) !== undefined
|
||||
)) {
|
||||
this.applyAnnotations();
|
||||
}
|
||||
if(!isSnippetMode && (
|
||||
childrenDidRefresh ||
|
||||
hasResized ||
|
||||
changedAttributes.search ||
|
||||
changedAttributes.searchMinLength ||
|
||||
changedAttributes.searchClass ||
|
||||
changedAttributes.searchMode ||
|
||||
changedAttributes.searchCaseSensitive
|
||||
)) {
|
||||
this.applySearch();
|
||||
}
|
||||
if(isSnippetMode && (
|
||||
childrenDidRefresh ||
|
||||
hasResized ||
|
||||
changedAttributes.search ||
|
||||
changedAttributes.searchMinLength ||
|
||||
changedAttributes.searchClass ||
|
||||
changedAttributes.searchMode ||
|
||||
changedAttributes.searchCaseSensitive
|
||||
)) {
|
||||
this.applySnippets();
|
||||
}
|
||||
this.wrapperWidth = wrapperWidth;
|
||||
return childrenDidRefresh;
|
||||
};
|
||||
|
||||
exports.dynannotate = DynannotateWidget;
|
||||
|
||||
})();
|
||||
116
plugins/tiddlywiki/dynannotate/modules/selection-tracker.js
Normal file
116
plugins/tiddlywiki/dynannotate/modules/selection-tracker.js
Normal file
@@ -0,0 +1,116 @@
|
||||
/*\
|
||||
title: $:/plugins/tiddlywiki/dynannotate/selection-tracker.js
|
||||
type: application/javascript
|
||||
module-type: startup
|
||||
|
||||
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 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;
|
||||
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;
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
SelectionTracker.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;
|
||||
};
|
||||
|
||||
})();
|
||||
177
plugins/tiddlywiki/dynannotate/modules/textmap.js
Normal file
177
plugins/tiddlywiki/dynannotate/modules/textmap.js
Normal file
@@ -0,0 +1,177 @@
|
||||
/*\
|
||||
title: $:/plugins/tiddlywiki/dynannotate/textmap.js
|
||||
type: application/javascript
|
||||
module-type: library
|
||||
|
||||
Structure for modelling mapping between a string and its representation in the DOM
|
||||
|
||||
\*/
|
||||
(function(){
|
||||
|
||||
/*jslint node: true, browser: true */
|
||||
/*global $tw: false */
|
||||
"use strict";
|
||||
|
||||
var PREFIX_SUFFIX_LENGTH = 50;
|
||||
|
||||
/*
|
||||
Build a map of the text content of a dom node and its descendents:
|
||||
|
||||
string: concatenation of the text content of child nodes
|
||||
metadata: array of {start,end,domNode} where start and end identify position in the string
|
||||
*/
|
||||
exports.TextMap = function(domNode) {
|
||||
var self = this,
|
||||
stringChunks = [],
|
||||
p = 0;
|
||||
this.metadata = [];
|
||||
var processNode = function(domNode) {
|
||||
// Check for text nodes
|
||||
if(domNode.nodeType === 3) {
|
||||
var text = domNode.textContent;
|
||||
stringChunks.push(text);
|
||||
self.metadata.push({
|
||||
start: p,
|
||||
end: p + text.length,
|
||||
domNode: domNode
|
||||
});
|
||||
p += text.length;
|
||||
} else {
|
||||
// Otherwise look within the child nodes
|
||||
if(domNode.childNodes) {
|
||||
for(var t=0; t<domNode.childNodes.length; t++ ) {
|
||||
processNode(domNode.childNodes[t]);
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
// Process our text nodes
|
||||
processNode(domNode);
|
||||
this.string = stringChunks.join("");
|
||||
};
|
||||
|
||||
/*
|
||||
Locate the metadata record corresponding to a given position in the string
|
||||
*/
|
||||
exports.TextMap.prototype.locateMetadata = function(position) {
|
||||
return this.metadata.find(function(metadata) {
|
||||
return position >= metadata.start && position < metadata.end;
|
||||
});
|
||||
};
|
||||
|
||||
/*
|
||||
Search for the first occurance of a target string within the textmap of a dom node
|
||||
|
||||
Returns an object with the following properties:
|
||||
startNode: node containing the start of the text
|
||||
startOffset: offset of the start of the text within the node
|
||||
endNode: node containing the end of the text
|
||||
endOffset: offset of the end of the text within the node
|
||||
*/
|
||||
exports.TextMap.prototype.findText = function(targetString,targetPrefix,targetSuffix) {
|
||||
if(!targetString) {
|
||||
return null;
|
||||
}
|
||||
targetPrefix = targetPrefix || "";
|
||||
targetSuffix = targetSuffix || "";
|
||||
var startPos = this.string.indexOf(targetPrefix + targetString + targetSuffix);
|
||||
if(startPos !== -1) {
|
||||
startPos += targetPrefix.length;
|
||||
var startMetadata = this.locateMetadata(startPos),
|
||||
endMetadata = this.locateMetadata(startPos + targetString.length);
|
||||
if(startMetadata && endMetadata) {
|
||||
return {
|
||||
startNode: startMetadata.domNode,
|
||||
startOffset: startPos - startMetadata.start,
|
||||
endNode: endMetadata.domNode,
|
||||
endOffset: (startPos + targetString.length) - endMetadata.start
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
/*
|
||||
Search for all occurances of a string within the textmap of a dom node
|
||||
|
||||
Options include:
|
||||
mode: "normal", "regexp" or "whitespace"
|
||||
caseSensitive: true if the search should be case sensitive
|
||||
|
||||
Returns an array of objects with the following properties:
|
||||
startPos: start position of the match within the string contained by this TextMap
|
||||
startNode: node containing the start of the text
|
||||
startOffset: offset of the start of the text within the node
|
||||
endPos: end position of the match within the string contained by this TextMap
|
||||
endNode: node containing the end of the text
|
||||
endOffset: offset of the end of the text within the node
|
||||
*/
|
||||
exports.TextMap.prototype.search = function(searchString,options) {
|
||||
if(!searchString) {
|
||||
return [];
|
||||
}
|
||||
options = options || {};
|
||||
// Compose the regexp
|
||||
var regExpString,
|
||||
flags = options.caseSensitive ? "g" : "gi";
|
||||
if(options.mode === "regexp") {
|
||||
regExpString = "(" + searchString + ")";
|
||||
} else if(options.mode === "whitespace") {
|
||||
// Normalise whitespace
|
||||
regExpString = "(" + searchString.split(/\s+/g).filter(function(word) {
|
||||
return !!word
|
||||
}).map($tw.utils.escapeRegExp).join("\\s+") + ")";
|
||||
} else {
|
||||
// Normal search
|
||||
regExpString = "(" + $tw.utils.escapeRegExp(searchString) + ")";
|
||||
}
|
||||
// Compile the regular expression
|
||||
var regExp;
|
||||
try {
|
||||
regExp = RegExp(regExpString,flags);
|
||||
} catch(e) {
|
||||
}
|
||||
if(!regExp) {
|
||||
return [];
|
||||
}
|
||||
// Find each match
|
||||
var results = [],
|
||||
match;
|
||||
do {
|
||||
match = regExp.exec(this.string);
|
||||
if(match) {
|
||||
var metadataStart = this.locateMetadata(match.index),
|
||||
metadataEnd = this.locateMetadata(match.index + match[0].length);
|
||||
if(metadataStart && metadataEnd) {
|
||||
results.push({
|
||||
startPos: match.index,
|
||||
startNode: metadataStart.domNode,
|
||||
startOffset: match.index - metadataStart.start,
|
||||
endPos: match.index + match[0].length,
|
||||
endNode: metadataEnd.domNode,
|
||||
endOffset: match.index + match[0].length - metadataEnd.start
|
||||
});
|
||||
}
|
||||
}
|
||||
} while(match);
|
||||
return results;
|
||||
};
|
||||
|
||||
/*
|
||||
Given a start container and offset and a search string, return a prefix and suffix to disambiguate the text
|
||||
*/
|
||||
exports.TextMap.prototype.extractContext = function(startContainer,startOffset,text) {
|
||||
var startMetadata = this.metadata.find(function(metadata) {
|
||||
return metadata.domNode === startContainer
|
||||
});
|
||||
if(!startMetadata) {
|
||||
return null;
|
||||
}
|
||||
var startPos = startMetadata.start + startOffset;
|
||||
return {
|
||||
prefix: this.string.slice(Math.max(startPos - PREFIX_SUFFIX_LENGTH, 0), startPos),
|
||||
suffix: this.string.slice(startPos + text.length, Math.min(startPos + text.length + PREFIX_SUFFIX_LENGTH, this.string.length))
|
||||
};
|
||||
};
|
||||
|
||||
})();
|
||||
Reference in New Issue
Block a user