1
0
mirror of https://github.com/Jermolene/TiddlyWiki5 synced 2024-11-27 03:57:21 +00:00

Introducing "Dynannotate" plugin for overlaying annotations

This commit is contained in:
Jeremy Ruston 2020-03-11 16:55:19 +00:00
parent 592922d399
commit 9b48a1c829
17 changed files with 1221 additions and 0 deletions

View File

@ -12,6 +12,7 @@
"tiddlywiki/savetrail",
"tiddlywiki/external-attachments",
"tiddlywiki/dynaview",
"tiddlywiki/dynannotate",
"tiddlywiki/codemirror",
"tiddlywiki/comments",
"tiddlywiki/menubar"

View File

@ -0,0 +1,33 @@
title: $:/plugins/tiddlywiki/dynannotate/history
!! v0.0.5
* Added support for displaying search snippets
* Fixed animated popups in the combined demo
* Added minimum length for dynannotate search string
* Added custom classes for search overlays
* Fix crash for malformed regexps
!! v0.0.4
* Fix crash with Chrome search-in-page
* Improve docs
!! v0.0.3
* Add support for showing the selection popup even for a zero length selection (ie clicking within the text without dragging)
* Add support for searching
* Refresh when browser or wrapper resizes
** Note that Dynannotate now requires the core TiddlyWiki plugin Dynaview
* Fixes problem with selections within HTML textareas or inputs
* Improved presentation of examples
!! v0.0.2
* Adds support for Mobile Safari
* Split demo into multiple chunks
* Only show the selection popup when the selection is entirely within a selection container
!! v0.0.1
Initial release

View File

@ -0,0 +1,108 @@
title: $:/plugins/tiddlywiki/dynannotate/readme
The ''Dynannotate'' plugin allows annotations on textual content to be created and displayed. It has three 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__''
!! Dynannotate Widget
The attributes of the `<$dynannotate>` widget describe annotations to be overlaid over the text contained within its child widgets. A single annotation can be directly applied using the attributes or multiple annotations can be applied by providing a filter identifying the "annotation tiddlers" that specify each annotation.
The content of the `<$dynannotate>` widget should not contain HTML `<input>` or `<textarea>` text editing elements (and therefore should not contain TiddlyWiki's `<$edit-text>` widget)
The `<$dynannotate>` widget uses the selection tracker to support a popup that dynamically tracks selected text within it.
!!! Attributes
|!Attribute |!Description |
|target |Optional text to be annotated |
|targetPrefix |Optional prefix text to disambiguate the target |
|targetSuffix |Optional suffix text to disambiguate the target |
|filter |Filter identifying the annotation tiddlers applying to this content (see below) |
|actions |Action string to be executed when an annotation is clicked. The variable `annotationTiddler` contains the title of the tiddler corresponding to the annotation that was clicked, and the variable `modifierKey` contains "ctrl", "shift", "ctrl-shift", "normal" according to which modifier keys were pressed |
|popup |Popup state tiddler to be used to trigger a popup when an annotation is clicked |
|search |Search text to be highlighted within the widget |
|searchDisplay |"overlay" or "snippet" (see below) |
|searchMode |"normal" (default), "regexp" or "whitespace" (see below) |
|searchMinLength |Optional minimum length of search string |
|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 |
|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 |
|selectionPopup |Popup state tiddler to be used to trigger a popup when text is selected |
The values supported by the `searchDisplay` attribute are:
* `overlay` - display search results as overlays over the contained text
* `snippet` - display search results as a sequence of highlighted snippets, and the original text is hidden. Selecting this option therefore disables the annotation functionality
The search modes supported by the `searchMode` attribute are:
* `normal` - a literal string of plain text to match
* `regexp` - a JavaScript-style regular expression (without the quoting backslashes and flags)
* `whitespace` - a literal string to match while normalising runs of whitespace. This allows `a. b` to match `a. b`
When the selection popup is triggered, the currently selected text can be found in the tiddler named in the `selection` attribute, with the disambiguating prefix and suffix in the tiddlers named in the `selectionPrefix` and `selectionPopup` tiddlers. Note that the selection text will be an empty string if the selection popup was triggered in response to a click (ie zero width selection).
Here's a simple example that highlights the first occurrence of the word "ut" within the text contained within it:
```
<$dynannotate target="ut">
Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum
</$dynannotate>
```
A prefix and/or suffix can be specified to disambiguate the annotation. For example, here we target the second occurrence of the word "ut":
```
<$dynannotate target="ut" targetPrefix="ullamco laboris nisi " targetSuffix=" aliquip ex ea commodo consequat">
Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum
</$dynannotate>
```
The widget works by scanning the rendered text of its content, so it works even if the text is built dynamically:
```
<$dynannotate target="HelloThere">
<<list-links "[tag[Work]]">>
</$dynannotate>
```
!!! Annotation Tiddlers
An annotation tiddler is a tiddler describing an annotation to be overlaid over another tiddler. Their fields are used as follows:
|!Field |!Description |
|title |By convention the prefix `$:/annotations/<username>/` is used, but any title can be used |
|text |The text of the annotation |
|created, creator, modified, modifier |As per TiddlyWiki normal behaviour |
|annotate-tiddler |The title of the target tiddler being annotated (optional, see below) |
|annotate-text |The text being annotated in the target tiddler |
|annotate-prefix |Optional prefix to disambiguate the target annotation |
|annotate-suffix |Optional suffix to disambiguate the target annotation |
|annotate-colour |CSS colour for the annotation (defaults to `rgba(255,255,0,0.3)`) |
|annotate-blend-mode |CSS [[mix blend mode|https://developer.mozilla.org/en-US/docs/Web/CSS/mix-blend-mode]] for the annotation (defaults to `multiply`) |
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
The selection tracker is incorporated within the `<$dynannotate>` widget, but 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:
* `data-annotation-selection-save`: title of tiddler to which the selected text should be saved
* `data-annotation-selection-prefix-save`: title of tiddler to which up to 50 characters preceding the currently selected text should be dynamically saved
* `data-annotation-selection-suffix-save`: title of tiddler to which up to 50 characters succeeding the currently selected text should be dynamically saved
* `data-annotation-selection-popup`: title of state tiddler used to trigger the selection popup
Notes:
* The selection popup will disappear if the selection is cancelled; this will happen if the user clicks on any other element apart than a button. Thus it is not possible to have any interactive controls within the popup apart from buttons

View File

@ -0,0 +1,111 @@
title: $:/plugins/tiddlywiki/dynannotate/examples/combined
tags: $:/tags/dynannotateExamples
caption: Combined
\define click-annotation-actions()
<$action-setfield $tiddler="$:/temp/dynannotate/demo/annotation-title" $value=<<annotationTiddler>>/>
\end
\define create-annotation-actions()
<$action-createtiddler
$basetitle="$:/plugins/tiddlywiki/dynannotate/demo-annotation"
$savetitle={{{ [<chunk>addprefix[$:/state/dynannotate/temp-save-title/]] }}}
annotate-tiddler=<<chunk>>
annotate-text=<<text>>
annotate-prefix=<<prefix>>
annotate-suffix=<<suffix>>
annotate-colour=<<colour>>
/>
<$set name="popup-coords" value={{{ [<chunk>addprefix[$:/state/dynannotate/popup-selection/]get[text]] }}}>
<$action-deletetiddler $tiddler={{{ [<chunk>addprefix[$:/state/dynannotate/popup-selection/]] }}}/>
<$action-setfield $tiddler="$:/temp/dynannotate/demo/annotation-title" $value={{{ [<chunk>addprefix[$:/state/dynannotate/temp-save-title/]get[text]] }}}/>
<$action-popup $state={{{ [<chunk>addprefix[$:/state/dynannotate/popup-annotation/]] }}} $coords=<<popup-coords>>/>
</$set>
\end
<div class="tc-dynannotation-example-info">
This example combines many of the features of the dynannotate plugin:
* using annotation tiddlers to store the details of each annotation
* triggering actions when the annotations are clicked
* attaching a popup to the annotations
* tracking the selection with another popup
See the [[source|$:/plugins/tiddlywiki/dynannotate/examples/combined]] for details
</div>
Search: <$edit-text tiddler="$:/temp/search" tag="input"/>
<$list filter="[all[tiddlers+shadows]tag[DynannotateDemo]sort[title]]" variable="chunk">
<div style="position:relative;"><!-- Needed for the popups to work -->
<$dynannotate
filter="[all[shadows+tiddlers]!has[draft.of]annotate-tiddler<chunk>]"
actions=<<click-annotation-actions>>
popup={{{ [<chunk>addprefix[$:/state/dynannotate/popup-annotation/]] }}}
selection={{{ [<chunk>addprefix[$:/state/dynannotate/selection/]] }}}
selectionPrefix={{{ [<chunk>addprefix[$:/state/dynannotate/selection-prefix/]] }}}
selectionSuffix={{{ [<chunk>addprefix[$:/state/dynannotate/selection-suffix/]] }}}
selectionPopup={{{ [<chunk>addprefix[$:/state/dynannotate/popup-selection/]] }}}
search={{$:/temp/search}}
searchClass="tc-dynannotation-search-overlay-blurred"
searchMinLength={{$:/config/Search/MinLength}}
>
<$transclude tiddler=<<chunk>> mode="block"/>
</$dynannotate>
<$reveal type="popup" state={{{ [<chunk>addprefix[$:/state/dynannotate/popup-annotation/]] }}} position="belowright" animate="yes" retain="yes" style="overflow-y:hidden;">
<div class="tc-drop-down-wrapper">
<div class="tc-drop-down tc-popup-keep" style="max-width:550px;white-space: normal;overflow-y:hidden;">
<$tiddler tiddler={{$:/temp/dynannotate/demo/annotation-title}}>
<p>
<h2>
This is an annotation
</h2>
</p>
<p>
The annotation is stored in the tiddler:
</p>
<p>
<$link><$view field="title"/></$link>
</p>
<p>
The annotated text is ''<$view field="annotate-text"/>''.
</p>
<p>
Annotation Colour:
<$macrocall $name='colour-picker' actions="""
<$action-setfield $field="annotate-colour" $value=<<colour-picker-value>>/>
"""/>
</p>
</$tiddler>
</div>
</div>
</$reveal>
<$reveal type="popup" state={{{ [<chunk>addprefix[$:/state/dynannotate/popup-selection/]] }}} position="belowright" animate="yes" retain="yes" style="overflow-y:hidden;">
<div class="tc-drop-down-wrapper">
<div class="tc-drop-down tc-popup-keep" style="max-width:550px;white-space:normal;">
<$vars
text={{{ [<chunk>addprefix[$:/state/dynannotate/selection/]get[text]] }}}
prefix={{{ [<chunk>addprefix[$:/state/dynannotate/selection-prefix/]get[text]] }}}
suffix={{{ [<chunk>addprefix[$:/state/dynannotate/selection-suffix/]get[text]] }}}
colour={{{ [<chunk>addprefix[$:/state/dynannotate/annotation-colour/]get[text]] }}}
>
<$button actions=<<create-annotation-actions>>>
Create annotation
</$button>
<p>
Text: <$text text=<<text>>/>
</p>
<p>
Prefix: <$text text=<<prefix>>/>
</p>
<p>
Suffix: <$text text=<<suffix>>/>
</p>
</$vars>
</div>
</div>
</$reveal>
</div>
</$list>

View File

@ -0,0 +1,5 @@
title: $:/plugins/tiddlywiki/dynannotate/example-annotation-1
annotate-tiddler: $:/plugins/tiddlywiki/dynannotate/example-text-1
annotate-text: memory is transitory. Yet the speed of action
annotate-colour: SkyBlue
annotate-blend-mode: multiply

View File

@ -0,0 +1,5 @@
title: $:/plugins/tiddlywiki/dynannotate/example-annotation-2
annotate-tiddler: $:/plugins/tiddlywiki/dynannotate/example-text-3
annotate-text: It needs a name, and to coin one at random, "memex" will do
annotate-colour: rgba(255,0,255,0.45)
annotate-blend-mode: multiply

View File

@ -0,0 +1,5 @@
title: $:/plugins/tiddlywiki/dynannotate/example-annotation-3
annotate-tiddler: $:/plugins/tiddlywiki/dynannotate/example-text-5
annotate-text: it would take him hundreds of years to fill the repository
annotate-colour: #fff
annotate-blend-mode: difference

View File

@ -0,0 +1,10 @@
{
"title": "$:/plugins/tiddlywiki/dynannotate/example-annotation-4",
"annotate-tiddler": "$:/plugins/tiddlywiki/dynannotate/example-text-1",
"annotate-text": "that",
"annotate-prefix": "It has other characteristics, of course; trails ",
"annotate-suffix": " are not frequently followed are prone to fade",
"annotate-colour": "rgba(255,0,255,0.45)",
"annotate-blend-mode": "difference",
"text": "(This tiddler is in .json format so that we can have field values that start with a whitespace"
}

View File

@ -0,0 +1,19 @@
title: $:/plugins/tiddlywiki/dynannotate/example-text-
source: https://www.w3.org/History/1945/vbush/vbush.txt
tags: DynannotateDemo
1: The human mind does not work that way. It operates by association. With one item in its grasp, it snaps instantly to the next that is suggested by the association of thoughts, in accordance with some intricate web of trails carried by the cells of the brain. It has other characteristics, of course; trails that are not frequently followed are prone to fade, items are not fully permanent, memory is ''transitory''. Yet the speed of action, the intricacy of trails, the detail of mental pictures, is awe-inspiring beyond all else in nature.
2: Man cannot hope fully to duplicate this mental process artificially, but he certainly ought to be able to learn from it. In minor ways he may even improve, for his records have relative permanency. The first idea, however, to be drawn from the analogy concerns selection. Selection by association, rather than by indexing, may yet be mechanized. One cannot hope thus to equal the speed and flexibility with which the mind follows an associative trail, but it should be possible to beat the mind decisively in regard to the permanence and clarity of the items resurrected from storage.
3: Consider a future device for individual use, which is a sort of mechanized private file and library. It needs a name, and to coin one at random, "memex" will do. A memex is a device in which an individual stores all his books, records, and communications, and which is mechanized so that it may be consulted with exceeding speed and flexibility. It is an enlarged intimate supplement to his memory.
4: It consists of a desk, and while it can presumably be operated from a distance, it is primarily the piece of furniture at which he works. On the top are slanting translucent screens, on which material can be projected for convenient reading. There is a keyboard, and sets of buttons and levers. Otherwise it looks like an ordinary desk.
5: In one end is the stored material. The matter of bulk is well taken care of by improved microfilm. Only a small part of the interior of the memex is devoted to storage, the rest to mechanism. Yet if the user inserted 5000 pages of material a day it would take him hundreds of years to fill the repository, so he can be profligate and enter material freely.
6: Most of the memex contents are purchased on microfilm ready for insertion. Books of all sorts, pictures, current periodicals, newspapers, are thus obtained and dropped into place. Business correspondence takes the same path. And there is provision for direct entry. On the top of the memex is a transparent platen. On this are placed longhand notes, photographs, memoranda, all sort of things. When one is in place, the depression of a lever causes it to be photographed onto the next blank space in a section of the memex film, dry photography being employed.
7: There is, of course, provision for consultation of the record by the usual scheme of indexing. If the user wishes to consult a certain book, he taps its code on the keyboard, and the title page of the book promptly appears before him, projected onto one of his viewing positions. Frequently-used codes are mnemonic, so that he seldom consults his code book; but when he does, a single tap of a key projects it for his use. Moreover, he has supplemental levers. On deflecting one of these levers to the right he runs through the book before him, each page in turn being projected at a speed which just allows a recognizing glance at each. If he deflects it further to the right, he steps through the book 10 pages at a time; still further at 100 pages at a time. Deflection to the left gives him the same control backwards.
8: A special button transfers him immediately to the first page of the index. Any given book of his library can thus be called up and consulted with far greater facility than if it were taken from a shelf. As he has several projection positions, he can leave one item in position while he calls up another. He can add marginal notes and comments, taking advantage of one possible type of dry photography, and it could even be arranged so that he can do this by a stylus scheme, such as is now employed in the telautograph seen in railroad waiting rooms, just as though he had the physical page before him.

View File

@ -0,0 +1,4 @@
title: $:/plugins/tiddlywiki/dynannotate/examples
<<tabs "[all[tiddlers+shadows]tag[$:/tags/dynannotateExamples]!has[draft.of]]" "$:/plugins/tiddlywiki/dynannotate/examples/snippets">>

View File

@ -0,0 +1,100 @@
title: $:/plugins/tiddlywiki/dynannotate/examples/simple
tags: $:/tags/dynannotateExamples
caption: Simple
\define show-example(example)
<$codeblock code=<<__example__>>/>
//''Displays as:''//
$example$
\end
<div class="tc-dynannotation-example-info">
!! Simple annotation
We use the `target*` attributes to specify a target string for the annotation and optionally a prefix and suffix for disambiguating multiple occurances.
</div>
<<show-example """
<$dynannotate
target="the"
targetPrefix="Yet "
targetSuffix=" speed"
>
<$transclude tiddler="$:/plugins/tiddlywiki/dynannotate/example-text-1" mode="block"/>
</$dynannotate>
""">>
<div class="tc-dynannotation-example-info">
!! Plain text searching
We use the `search` attribute to specify a search string for highlighting:
</div>
<<show-example """
<$dynannotate
search="the"
>
<$transclude tiddler="$:/plugins/tiddlywiki/dynannotate/example-text-1" mode="block"/>
</$dynannotate>
""">>
<div class="tc-dynannotation-example-info">
!! Regular expression searching
We use the `mode` attribute set to `regexp` to highlight matches of a regular expression:
</div>
<<show-example """
<$dynannotate
search="the|an"
searchMode="regexp"
searchClass="tc-dynannotation-search-overlay-blurred"
>
<$transclude tiddler="$:/plugins/tiddlywiki/dynannotate/example-text-1" mode="block"/>
</$dynannotate>
""">>
<div class="tc-dynannotation-example-info">
!! Normalised whitespace searching
We use the `mode` attribute set to `whitespace` to search for a string with whitespace normalised (ie runs of whitespace are collapsed to a single space for matching purposes):
</div>
<<show-example """
<$dynannotate
search="does not work that way. It operates"
searchMode="whitespace"
searchClass="tc-dynannotation-search-overlay-animated"
>
<$transclude tiddler="$:/plugins/tiddlywiki/dynannotate/example-text-1" mode="block"/>
</$dynannotate>
""">>
<div class="tc-dynannotation-example-info">
!! Using annotation tiddlers
Annotation tiddlers can be used to describe annotations. This example references the following annotation tiddlers:
</div>
<<list-links "[all[shadows+tiddlers]annotate-tiddler[$:/plugins/tiddlywiki/dynannotate/example-text-1]]">>
<<show-example """
<$dynannotate
filter="[all[shadows+tiddlers]annotate-tiddler[$:/plugins/tiddlywiki/dynannotate/example-text-1]]"
>
<$transclude tiddler="$:/plugins/tiddlywiki/dynannotate/example-text-1" mode="block"/>
</$dynannotate>
""">>

View File

@ -0,0 +1,56 @@
title: $:/plugins/tiddlywiki/dynannotate/examples/snippets
tags: $:/tags/dynannotateExamples
caption: Snippets
\define show-example(example)
<$codeblock code=<<__example__>>/>
//''Displays as:''//
$example$
\end
<div class="tc-dynannotation-example-info">
!! Search result snippets
The `searchDisplay` attribute can be set to `snippet` (instead of the default `overlay`) in order to display contextual snippets around search results.
</div>
<<show-example """
<$dynannotate
search="the"
searchDisplay="snippet"
><$transclude tiddler="$:/plugins/tiddlywiki/dynannotate/example-text-1" mode="block"/>
</$dynannotate>
""">>
<div class="tc-dynannotation-example-info">
!! Multiple search result snippets
This example searches across multiple tiddlers and shows snippets for those tiddlers that match.
</div>
<$macrocall $name="show-example" example="""
Search: <$edit-text tiddler="$:/temp/search" tag="input"/>
<$list filter="[all[tiddlers+shadows]tag[DynannotateDemo]search:text{$:/temp/search}sort[title]]">
<dl>
<dt>
<$link>
<$text text=<<currentTiddler>>/>
</$link>
</dt>
<dd>
<$dynannotate
search={{$:/temp/search}}
searchMode="whitespace"
searchDisplay="snippet"
><$transclude tiddler=<<currentTiddler>> mode="block"/>
</$dynannotate>
</dd>
</dl>
</$list>
"""/>

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

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

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

View File

@ -0,0 +1,9 @@
{
"title": "$:/plugins/tiddlywiki/dynannotate",
"description": "Dynamic content annotation",
"author": "JeremyRuston",
"core-version": ">=5.0.0",
"version": "0.0.6-prerelease",
"list": "readme examples history",
"dependents": ["$:/plugins/tiddlywiki/dynaview"]
}

View File

@ -0,0 +1,44 @@
title: $:/plugins/tiddlywiki/dynannotate/styles
tags: [[$:/tags/Stylesheet]]
\rules only filteredtranscludeinline transcludeinline macrodef macrocallinline
.tc-dynannotation-wrapper {
position: relative;
}
.tc-dynannotation-annotation-overlay {
position: absolute;
background: rgba(255,255,0,0.3);
mix-blend-mode: multiply;
}
.tc-dynannotation-search-overlay {
position: absolute;
pointer-events: none;
background: rgba(255,0,0,0.3);
}
.tc-dynannotation-search-overlay-blurred {
background: rgba(255,0,0,0.3);
mix-blend-mode: multiply;
border-radius: 4px;
filter: blur(2px);
}
@keyframes ta-dynannotation-search-overlay-animated { to { background-position: 100% 100% } }
.tc-dynannotation-search-overlay-animated {
mix-blend-mode: multiply;
background: repeating-linear-gradient(-45deg, #ff8 0, #dd8 25%, transparent 0, transparent 50%) 0 / .6em .6em;
animation: ta-dynannotation-search-overlay-animated 12s linear infinite;
}
.tc-dynannotate-snippet-highlight {
background: #efef53;
}
.tc-dynannotation-example-info {
background: #ffa;
padding: 1em;
}