mirror of
https://github.com/Jermolene/TiddlyWiki5
synced 2024-12-27 10:30:28 +00:00
430 lines
15 KiB
JavaScript
430 lines
15 KiB
JavaScript
/*\
|
|
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 Popup = require("$:/core/modules/utils/dom/popup.js");
|
|
|
|
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) {
|
|
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": Popup.buildCoordinates(Popup.coordinatePrefix.csOffsetParent,bounds)
|
|
});
|
|
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;
|
|
|
|
})();
|