Skip to content
Projects
Groups
Snippets
Help
Loading...
Help
Submit feedback
Contribute to GitLab
Sign in
Toggle navigation
C
coopwire-hypothesis
Project
Project
Details
Activity
Releases
Cycle Analytics
Repository
Repository
Files
Commits
Branches
Tags
Contributors
Graph
Compare
Charts
Issues
0
Issues
0
List
Board
Labels
Milestones
Merge Requests
0
Merge Requests
0
CI / CD
CI / CD
Pipelines
Jobs
Schedules
Charts
Wiki
Wiki
Snippets
Snippets
Members
Members
Collapse sidebar
Close sidebar
Activity
Graph
Charts
Create a new issue
Jobs
Commits
Issue Boards
Open sidebar
孙灵跃 Leon Sun
coopwire-hypothesis
Commits
1f7174f6
Commit
1f7174f6
authored
Sep 11, 2020
by
Robert Knight
Browse files
Options
Browse Files
Download
Email Patches
Plain Diff
Convert Guest class to JS
parent
70693ddf
Changes
5
Hide whitespace changes
Inline
Side-by-side
Showing
5 changed files
with
667 additions
and
528 deletions
+667
-528
guest.coffee
src/annotator/guest.coffee
+0
-519
guest.js
src/annotator/guest.js
+660
-0
host.js
src/annotator/host.js
+1
-3
index.js
src/annotator/index.js
+5
-5
guest-test.coffee
src/annotator/test/guest-test.coffee
+1
-1
No files found.
src/annotator/guest.coffee
deleted
100644 → 0
View file @
70693ddf
scrollIntoView
=
require
(
'scroll-into-view'
)
CustomEvent
=
require
(
'custom-event'
)
{
default
:
Delegator
}
=
require
(
'./delegator'
)
$
=
require
(
'jquery'
)
adder
=
require
(
'./adder'
)
htmlAnchoring
=
require
(
'./anchoring/html'
)
highlighter
=
require
(
'./highlighter'
)
rangeUtil
=
require
(
'./range-util'
)
{
default
:
selections
}
=
require
(
'./selections'
)
xpathRange
=
require
(
'./anchoring/range'
)
{
closest
}
=
require
(
'../shared/dom-element'
)
{
normalizeURI
}
=
require
(
'./util/url'
)
animationPromise
=
(
fn
)
->
return
new
Promise
(
resolve
,
reject
)
->
requestAnimationFrame
->
try
resolve
(
fn
())
catch
error
reject
(
error
)
annotationsForSelection
=
()
->
selection
=
window
.
getSelection
()
range
=
selection
.
getRangeAt
(
0
)
return
rangeUtil
.
itemsForRange
(
range
,
(
node
)
->
$
(
node
).
data
(
'annotation'
))
# Return the annotations associated with any highlights that contain a given
# DOM node.
annotationsAt
=
(
node
)
->
if
node
.
nodeType
!=
Node
.
ELEMENT_NODE
node
=
node
.
parentElement
highlights
=
[]
while
node
if
node
.
classList
.
contains
(
'hypothesis-highlight'
)
highlights
.
push
(
node
)
node
=
node
.
parentElement
return
highlights
.
map
((
h
)
=>
$
(
h
).
data
(
'annotation'
))
# A selector which matches elements added to the DOM by Hypothesis (eg. for
# highlights and annotation UI).
#
# We can simplify this once all classes are converted from an "annotator-"
# prefix to a "hypothesis-" prefix.
IGNORE_SELECTOR
=
'[class^="annotator-"],[class^="hypothesis-"]'
module
.
exports
=
class
Guest
extends
Delegator
SHOW_HIGHLIGHTS_CLASS
=
'hypothesis-highlights-always-on'
options
:
Document
:
{}
TextSelection
:
{}
# Anchoring module
anchoring
:
null
# Internal state
plugins
:
null
anchors
:
null
visibleHighlights
:
false
frameIdentifier
:
null
html
:
adder
:
'<hypothesis-adder></hypothesis-adder>'
constructor
:
(
element
,
config
,
anchoring
=
htmlAnchoring
)
->
super
this
.
adder
=
$
(
this
.
html
.
adder
).
appendTo
(
@
element
).
hide
()
self
=
this
this
.
adderCtrl
=
new
adder
.
Adder
(
@
adder
[
0
],
{
onAnnotate
:
->
self
.
createAnnotation
()
document
.
getSelection
().
removeAllRanges
()
onHighlight
:
->
self
.
setVisibleHighlights
(
true
)
self
.
createHighlight
()
document
.
getSelection
().
removeAllRanges
()
onShowAnnotations
:
(
anns
)
->
self
.
selectAnnotations
(
anns
)
})
this
.
selections
=
selections
(
document
).
subscribe
next
:
(
range
)
->
if
range
self
.
_onSelection
(
range
)
else
self
.
_onClearSelection
()
this
.
plugins
=
{}
this
.
anchors
=
[]
# Set the frame identifier if it's available.
# The "top" guest instance will have this as null since it's in a top frame not a sub frame
this
.
frameIdentifier
=
config
.
subFrameIdentifier
||
null
this
.
anchoring
=
anchoring
cfOptions
=
config
:
config
on
:
(
event
,
handler
)
=>
this
.
subscribe
(
event
,
handler
)
emit
:
(
event
,
args
...)
=>
this
.
publish
(
event
,
args
)
this
.
addPlugin
(
'CrossFrame'
,
cfOptions
)
@
crossframe
=
this
.
plugins
.
CrossFrame
@
crossframe
.
onConnect
(
=>
this
.
_setupInitialState
(
config
))
# Whether clicks on non-highlighted text should close the sidebar
this
.
closeSidebarOnDocumentClick
=
true
this
.
_connectAnnotationSync
(
@
crossframe
)
this
.
_connectAnnotationUISync
(
@
crossframe
)
# Load plugins
for
own
name
,
opts
of
@
options
if
not
@
plugins
[
name
]
and
@
options
.
pluginClasses
[
name
]
this
.
addPlugin
(
name
,
opts
)
# Setup event handlers on the root element
this
.
_elementEventListeners
=
[]
this
.
_setupElementEvents
()
# Add DOM event listeners for clicks, taps etc. on the document and
# highlights.
_setupElementEvents
:
->
addListener
=
(
event
,
callback
)
=>
@
element
[
0
].
addEventListener
(
event
,
callback
)
this
.
_elementEventListeners
.
push
({
event
,
callback
})
# Hide the sidebar in response to a document click or tap, so it doesn't obscure
# the document content.
maybeHideSidebar
=
(
event
)
=>
if
!
this
.
closeSidebarOnDocumentClick
||
this
.
isEventInAnnotator
(
event
)
||
@
selectedTargets
?
.
length
# Don't hide the sidebar if event occurred inside Hypothesis UI, or
# the user is making a selection, or the behavior was disabled because
# the sidebar doesn't overlap the content.
return
@
crossframe
?
.
call
(
'hideSidebar'
)
addListener
'click'
,
(
event
)
=>
annotations
=
annotationsAt
(
event
.
target
)
if
annotations
.
length
and
@
visibleHighlights
toggle
=
event
.
metaKey
or
event
.
ctrlKey
this
.
selectAnnotations
(
annotations
,
toggle
)
else
maybeHideSidebar
(
event
)
# Allow taps on the document to hide the sidebar as well as clicks, because
# on touch-input devices, not all elements will generate a "click" event.
addListener
'touchstart'
,
(
event
)
=>
if
!
annotationsAt
(
event
.
target
).
length
maybeHideSidebar
(
event
)
addListener
'mouseover'
,
(
event
)
=>
annotations
=
annotationsAt
(
event
.
target
)
if
annotations
.
length
and
@
visibleHighlights
this
.
focusAnnotations
(
annotations
)
addListener
'mouseout'
,
(
event
)
=>
if
@
visibleHighlights
this
.
focusAnnotations
[]
_removeElementEvents
:
->
this
.
_elementEventListeners
.
forEach
({
event
,
callback
})
=>
@
element
[
0
].
removeEventListener
(
event
,
callback
)
addPlugin
:
(
name
,
options
)
->
if
@
plugins
[
name
]
console
.
error
(
"You cannot have more than one instance of any plugin."
)
else
klass
=
@
options
.
pluginClasses
[
name
]
if
typeof
klass
is
'function'
@
plugins
[
name
]
=
new
klass
(
@
element
[
0
],
options
)
@
plugins
[
name
].
annotator
=
this
@
plugins
[
name
].
pluginInit
?
()
else
console
.
error
(
"Could not load "
+
name
+
" plugin. Have you included the appropriate <script> tag?"
)
this
# allow chaining
# Get the document info
getDocumentInfo
:
->
if
@
plugins
.
PDF
?
metadataPromise
=
Promise
.
resolve
(
@
plugins
.
PDF
.
getMetadata
())
uriPromise
=
Promise
.
resolve
(
@
plugins
.
PDF
.
uri
())
else
if
@
plugins
.
Document
?
uriPromise
=
Promise
.
resolve
(
@
plugins
.
Document
.
uri
())
metadataPromise
=
Promise
.
resolve
(
@
plugins
.
Document
.
metadata
)
else
uriPromise
=
Promise
.
reject
()
metadataPromise
=
Promise
.
reject
()
uriPromise
=
uriPromise
.
catch
(
->
decodeURIComponent
(
window
.
location
.
href
))
metadataPromise
=
metadataPromise
.
catch
(
->
{
title
:
document
.
title
link
:
[{
href
:
decodeURIComponent
(
window
.
location
.
href
)}]
})
return
Promise
.
all
([
metadataPromise
,
uriPromise
]).
then
([
metadata
,
href
])
=>
return
{
uri
:
normalizeURI
(
href
),
metadata
,
frameIdentifier
:
this
.
frameIdentifier
}
_setupInitialState
:
(
config
)
->
this
.
publish
(
'panelReady'
)
this
.
setVisibleHighlights
(
config
.
showHighlights
==
'always'
)
_connectAnnotationSync
:
(
crossframe
)
->
this
.
subscribe
'annotationDeleted'
,
(
annotation
)
=>
this
.
detach
(
annotation
)
this
.
subscribe
'annotationsLoaded'
,
(
annotations
)
=>
for
annotation
in
annotations
this
.
anchor
(
annotation
)
_connectAnnotationUISync
:
(
crossframe
)
->
crossframe
.
on
'focusAnnotations'
,
(
tags
=
[])
=>
for
anchor
in
@
anchors
when
anchor
.
highlights
?
toggle
=
anchor
.
annotation
.
$tag
in
tags
$
(
anchor
.
highlights
).
toggleClass
(
'hypothesis-highlight-focused'
,
toggle
)
crossframe
.
on
'scrollToAnnotation'
,
(
tag
)
=>
for
anchor
in
@
anchors
when
anchor
.
highlights
?
if
anchor
.
annotation
.
$tag
is
tag
event
=
new
CustomEvent
(
'scrolltorange'
,
{
bubbles
:
true
cancelable
:
true
detail
:
anchor
.
range
})
defaultNotPrevented
=
@
element
[
0
].
dispatchEvent
(
event
)
if
defaultNotPrevented
scrollIntoView
(
anchor
.
highlights
[
0
])
crossframe
.
on
'getDocumentInfo'
,
(
cb
)
=>
this
.
getDocumentInfo
()
.
then
((
info
)
->
cb
(
null
,
info
))
.
catch
((
reason
)
->
cb
(
reason
))
crossframe
.
on
'setVisibleHighlights'
,
(
state
)
=>
this
.
setVisibleHighlights
(
state
)
destroy
:
->
this
.
_removeElementEvents
()
this
.
selections
.
unsubscribe
()
@
adder
.
remove
()
@
element
.
find
(
'.hypothesis-highlight'
).
each
->
$
(
this
).
contents
().
insertBefore
(
this
)
$
(
this
).
remove
()
@
element
.
data
(
'annotator'
,
null
)
for
name
,
plugin
of
@
plugins
@
plugins
[
name
].
destroy
()
super
anchor
:
(
annotation
)
->
self
=
this
root
=
@
element
[
0
]
# Anchors for all annotations are in the `anchors` instance property. These
# are anchors for this annotation only. After all the targets have been
# processed these will be appended to the list of anchors known to the
# instance. Anchors hold an annotation, a target of that annotation, a
# document range for that target and an Array of highlights.
anchors
=
[]
# The targets that are already anchored. This function consults this to
# determine which targets can be left alone.
anchoredTargets
=
[]
# These are the highlights for existing anchors of this annotation with
# targets that have since been removed from the annotation. These will
# be removed by this function.
deadHighlights
=
[]
# Initialize the target array.
annotation
.
target
?=
[]
locate
=
(
target
)
->
# Check that the anchor has a TextQuoteSelector -- without a
# TextQuoteSelector we have no basis on which to verify that we have
# reanchored correctly and so we shouldn't even try.
#
# Returning an anchor without a range will result in this annotation being
# treated as an orphan (assuming no other targets anchor).
if
not
(
target
.
selector
?
[]).
some
((
s
)
=>
s
.
type
==
'TextQuoteSelector'
)
return
Promise
.
resolve
({
annotation
,
target
})
# Find a target using the anchoring module.
options
=
{
cache
:
self
.
anchoringCache
ignoreSelector
:
IGNORE_SELECTOR
}
return
self
.
anchoring
.
anchor
(
root
,
target
.
selector
,
options
)
.
then
((
range
)
->
{
annotation
,
target
,
range
})
.
catch
(
->
{
annotation
,
target
})
highlight
=
(
anchor
)
->
# Highlight the range for an anchor.
return
anchor
unless
anchor
.
range
?
return
animationPromise
->
range
=
xpathRange
.
sniff
(
anchor
.
range
)
normedRange
=
range
.
normalize
(
root
)
highlights
=
highlighter
.
highlightRange
(
normedRange
)
$
(
highlights
).
data
(
'annotation'
,
anchor
.
annotation
)
anchor
.
highlights
=
highlights
return
anchor
sync
=
(
anchors
)
->
# Store the results of anchoring.
# An annotation is considered to be an orphan if it has at least one
# target with selectors, and all targets with selectors failed to anchor
# (i.e. we didn't find it in the page and thus it has no range).
hasAnchorableTargets
=
false
hasAnchoredTargets
=
false
for
anchor
in
anchors
if
anchor
.
target
.
selector
?
hasAnchorableTargets
=
true
if
anchor
.
range
?
hasAnchoredTargets
=
true
break
annotation
.
$orphan
=
hasAnchorableTargets
and
not
hasAnchoredTargets
# Add the anchors for this annotation to instance storage.
self
.
anchors
=
self
.
anchors
.
concat
(
anchors
)
# Let plugins know about the new information.
self
.
plugins
.
BucketBar
?
.
update
()
self
.
plugins
.
CrossFrame
?
.
sync
([
annotation
])
return
anchors
# Remove all the anchors for this annotation from the instance storage.
for
anchor
in
self
.
anchors
.
splice
(
0
,
self
.
anchors
.
length
)
if
anchor
.
annotation
is
annotation
# Anchors are valid as long as they still have a range and their target
# is still in the list of targets for this annotation.
if
anchor
.
range
?
and
anchor
.
target
in
annotation
.
target
anchors
.
push
(
anchor
)
anchoredTargets
.
push
(
anchor
.
target
)
else
if
anchor
.
highlights
?
# These highlights are no longer valid and should be removed.
deadHighlights
=
deadHighlights
.
concat
(
anchor
.
highlights
)
delete
anchor
.
highlights
delete
anchor
.
range
else
# These can be ignored, so push them back onto the new list.
self
.
anchors
.
push
(
anchor
)
# Remove all the highlights that have no corresponding target anymore.
requestAnimationFrame
->
highlighter
.
removeHighlights
(
deadHighlights
)
# Anchor any targets of this annotation that are not anchored already.
for
target
in
annotation
.
target
when
target
not
in
anchoredTargets
anchor
=
locate
(
target
).
then
(
highlight
)
anchors
.
push
(
anchor
)
return
Promise
.
all
(
anchors
).
then
(
sync
)
detach
:
(
annotation
)
->
anchors
=
[]
targets
=
[]
unhighlight
=
[]
for
anchor
in
@
anchors
if
anchor
.
annotation
is
annotation
unhighlight
.
push
(
anchor
.
highlights
?
[])
else
anchors
.
push
(
anchor
)
this
.
anchors
=
anchors
unhighlight
=
Array
::
concat
(
unhighlight
...)
requestAnimationFrame
=>
highlighter
.
removeHighlights
(
unhighlight
)
this
.
plugins
.
BucketBar
?
.
update
()
createAnnotation
:
(
annotation
=
{})
->
self
=
this
root
=
@
element
[
0
]
ranges
=
@
selectedRanges
?
[]
@
selectedRanges
=
null
getSelectors
=
(
range
)
->
options
=
{
cache
:
self
.
anchoringCache
ignoreSelector
:
IGNORE_SELECTOR
}
# Returns an array of selectors for the passed range.
return
self
.
anchoring
.
describe
(
root
,
range
,
options
)
setDocumentInfo
=
(
info
)
->
annotation
.
document
=
info
.
metadata
annotation
.
uri
=
info
.
uri
setTargets
=
([
info
,
selectors
])
->
# `selectors` is an array of arrays: each item is an array of selectors
# identifying a distinct target.
source
=
info
.
uri
annotation
.
target
=
({
source
,
selector
}
for
selector
in
selectors
)
info
=
this
.
getDocumentInfo
()
selectors
=
Promise
.
all
(
ranges
.
map
(
getSelectors
))
metadata
=
info
.
then
(
setDocumentInfo
)
targets
=
Promise
.
all
([
info
,
selectors
]).
then
(
setTargets
)
targets
.
then
(
->
self
.
publish
(
'beforeAnnotationCreated'
,
[
annotation
]))
targets
.
then
(
->
self
.
anchor
(
annotation
))
@
crossframe
?
.
call
(
'showSidebar'
)
unless
annotation
.
$highlight
annotation
createHighlight
:
->
return
this
.
createAnnotation
({
$highlight
:
true
})
# Create a blank comment (AKA "page note")
createComment
:
()
->
annotation
=
{}
self
=
this
prepare
=
(
info
)
->
annotation
.
document
=
info
.
metadata
annotation
.
uri
=
info
.
uri
annotation
.
target
=
[{
source
:
info
.
uri
}]
this
.
getDocumentInfo
()
.
then
(
prepare
)
.
then
(
->
self
.
publish
(
'beforeAnnotationCreated'
,
[
annotation
]))
annotation
# Public: Deletes the annotation by removing the highlight from the DOM.
# Publishes the 'annotationDeleted' event on completion.
#
# annotation - An annotation Object to delete.
#
# Returns deleted annotation.
deleteAnnotation
:
(
annotation
)
->
if
annotation
.
highlights
?
for
h
in
annotation
.
highlights
when
h
.
parentNode
?
$
(
h
).
replaceWith
(
h
.
childNodes
)
this
.
publish
(
'annotationDeleted'
,
[
annotation
])
annotation
showAnnotations
:
(
annotations
)
->
tags
=
(
a
.
$tag
for
a
in
annotations
)
@
crossframe
?
.
call
(
'showAnnotations'
,
tags
)
@
crossframe
?
.
call
(
'showSidebar'
)
toggleAnnotationSelection
:
(
annotations
)
->
tags
=
(
a
.
$tag
for
a
in
annotations
)
@
crossframe
?
.
call
(
'toggleAnnotationSelection'
,
tags
)
updateAnnotations
:
(
annotations
)
->
tags
=
(
a
.
$tag
for
a
in
annotations
)
@
crossframe
?
.
call
(
'updateAnnotations'
,
tags
)
focusAnnotations
:
(
annotations
)
->
tags
=
(
a
.
$tag
for
a
in
annotations
)
@
crossframe
?
.
call
(
'focusAnnotations'
,
tags
)
_onSelection
:
(
range
)
->
selection
=
document
.
getSelection
()
isBackwards
=
rangeUtil
.
isSelectionBackwards
(
selection
)
focusRect
=
rangeUtil
.
selectionFocusRect
(
selection
)
if
!
focusRect
# The selected range does not contain any text
this
.
_onClearSelection
()
return
@
selectedRanges
=
[
range
]
@
toolbar
?
.
newAnnotationType
=
'annotation'
{
left
,
top
,
arrowDirection
}
=
this
.
adderCtrl
.
target
(
focusRect
,
isBackwards
)
this
.
adderCtrl
.
annotationsForSelection
=
annotationsForSelection
()
this
.
adderCtrl
.
showAt
(
left
,
top
,
arrowDirection
)
_onClearSelection
:
()
->
this
.
adderCtrl
.
hide
()
@
selectedRanges
=
[]
@
toolbar
?
.
newAnnotationType
=
'note'
selectAnnotations
:
(
annotations
,
toggle
)
->
if
toggle
this
.
toggleAnnotationSelection
annotations
else
this
.
showAnnotations
annotations
# Did an event originate from an element in the annotator UI? (eg. the sidebar
# frame, or its toolbar)
isEventInAnnotator
:
(
event
)
->
return
closest
(
event
.
target
,
'.annotator-frame'
)
!=
null
# Pass true to show the highlights in the frame or false to disable.
setVisibleHighlights
:
(
shouldShowHighlights
)
->
this
.
toggleHighlightClass
(
shouldShowHighlights
)
toggleHighlightClass
:
(
shouldShowHighlights
)
->
if
shouldShowHighlights
@
element
.
addClass
(
SHOW_HIGHLIGHTS_CLASS
)
else
@
element
.
removeClass
(
SHOW_HIGHLIGHTS_CLASS
)
@
visibleHighlights
=
shouldShowHighlights
@
toolbar
?
.
highlightsVisible
=
shouldShowHighlights
src/annotator/guest.js
0 → 100644
View file @
1f7174f6
import
CustomEvent
from
'custom-event'
;
import
$
from
'jquery'
;
import
scrollIntoView
from
'scroll-into-view'
;
import
Delegator
from
'./delegator'
;
import
*
as
adder
from
'./adder'
;
// @ts-expect-error - Module is CoffeeScript
import
*
as
htmlAnchoring
from
'./anchoring/html'
;
// @ts-expect-error - Module is CoffeeScript
import
*
as
xpathRange
from
'./anchoring/range'
;
import
*
as
highlighter
from
'./highlighter'
;
import
*
as
rangeUtil
from
'./range-util'
;
import
selections
from
'./selections'
;
import
{
closest
}
from
'../shared/dom-element'
;
import
{
normalizeURI
}
from
'./util/url'
;
/**
* @typedef {import('./toolbar').ToolbarController} ToolbarController
*/
const
animationPromise
=
fn
=>
new
Promise
((
resolve
,
reject
)
=>
requestAnimationFrame
(()
=>
{
try
{
resolve
(
fn
());
}
catch
(
error
)
{
reject
(
error
);
}
})
);
function
annotationsForSelection
()
{
const
selection
=
/** @type {Selection} */
(
window
.
getSelection
());
const
range
=
selection
.
getRangeAt
(
0
);
return
rangeUtil
.
itemsForRange
(
range
,
node
=>
$
(
node
).
data
(
'annotation'
));
}
/**
* Return the annotations associated with any highlights that contain a given
* DOM node.
*/
function
annotationsAt
(
node
)
{
if
(
node
.
nodeType
!==
Node
.
ELEMENT_NODE
)
{
node
=
node
.
parentElement
;
}
const
highlights
=
[];
while
(
node
)
{
if
(
node
.
classList
.
contains
(
'hypothesis-highlight'
))
{
highlights
.
push
(
node
);
}
node
=
node
.
parentElement
;
}
return
highlights
.
map
(
h
=>
$
(
h
).
data
(
'annotation'
));
}
// A selector which matches elements added to the DOM by Hypothesis (eg. for
// highlights and annotation UI).
//
// We can simplify this once all classes are converted from an "annotator-"
// prefix to a "hypothesis-" prefix.
const
IGNORE_SELECTOR
=
'[class^="annotator-"],[class^="hypothesis-"]'
;
export
default
class
Guest
extends
Delegator
{
constructor
(
element
,
config
,
anchoring
=
htmlAnchoring
)
{
// TODO - Find out if `defaultConfig` actually does anything and remove it
// if not.
const
defaultConfig
=
{
Document
:
{},
TextSelection
:
{},
};
super
(
element
,
{
...
defaultConfig
,
...
config
});
this
.
visibleHighlights
=
false
;
/** @type {ToolbarController|null} */
this
.
toolbar
=
null
;
this
.
adder
=
$
(
`<hypothesis-adder></hypothesis-adder>`
)
.
appendTo
(
this
.
element
)
.
hide
();
this
.
adderCtrl
=
new
adder
.
Adder
(
this
.
adder
[
0
],
{
onAnnotate
:
()
=>
{
this
.
createAnnotation
();
/** @type {Selection} */
(
document
.
getSelection
()).
removeAllRanges
();
},
onHighlight
:
()
=>
{
this
.
setVisibleHighlights
(
true
);
this
.
createHighlight
();
/** @type {Selection} */
(
document
.
getSelection
()).
removeAllRanges
();
},
onShowAnnotations
:
anns
=>
{
this
.
selectAnnotations
(
anns
);
},
});
this
.
selections
=
selections
(
document
).
subscribe
({
next
:
range
=>
{
if
(
range
)
{
this
.
_onSelection
(
range
);
}
else
{
this
.
_onClearSelection
();
}
},
});
this
.
plugins
=
{};
this
.
anchors
=
[];
// Set the frame identifier if it's available.
// The "top" guest instance will have this as null since it's in a top frame not a sub frame
this
.
frameIdentifier
=
config
.
subFrameIdentifier
||
null
;
this
.
anchoring
=
anchoring
;
const
cfOptions
=
{
config
,
on
:
(
event
,
handler
)
=>
{
this
.
subscribe
(
event
,
handler
);
},
emit
:
(
event
,
...
args
)
=>
{
this
.
publish
(
event
,
args
);
},
};
this
.
addPlugin
(
'CrossFrame'
,
cfOptions
);
this
.
crossframe
=
this
.
plugins
.
CrossFrame
;
this
.
crossframe
.
onConnect
(()
=>
this
.
_setupInitialState
(
config
));
// Whether clicks on non-highlighted text should close the sidebar
this
.
closeSidebarOnDocumentClick
=
true
;
this
.
_connectAnnotationSync
();
this
.
_connectAnnotationUISync
(
this
.
crossframe
);
// Load plugins
for
(
let
name
of
Object
.
keys
(
this
.
options
||
{}))
{
const
opts
=
this
.
options
[
name
];
if
(
!
this
.
plugins
[
name
]
&&
this
.
options
.
pluginClasses
[
name
])
{
this
.
addPlugin
(
name
,
opts
);
}
}
// Setup event handlers on the root element
this
.
_elementEventListeners
=
[];
this
.
_setupElementEvents
();
}
// Add DOM event listeners for clicks, taps etc. on the document and
// highlights.
_setupElementEvents
()
{
const
addListener
=
(
event
,
callback
)
=>
{
this
.
element
[
0
].
addEventListener
(
event
,
callback
);
this
.
_elementEventListeners
.
push
({
event
,
callback
});
};
// Hide the sidebar in response to a document click or tap, so it doesn't obscure
// the document content.
const
maybeHideSidebar
=
event
=>
{
if
(
!
this
.
closeSidebarOnDocumentClick
||
this
.
isEventInAnnotator
(
event
))
{
// Don't hide the sidebar if event occurred inside Hypothesis UI, or
// the user is making a selection, or the behavior was disabled because
// the sidebar doesn't overlap the content.
return
;
}
this
.
crossframe
?.
call
(
'hideSidebar'
);
};
addListener
(
'click'
,
event
=>
{
const
annotations
=
annotationsAt
(
event
.
target
);
if
(
annotations
.
length
&&
this
.
visibleHighlights
)
{
const
toggle
=
event
.
metaKey
||
event
.
ctrlKey
;
this
.
selectAnnotations
(
annotations
,
toggle
);
}
else
{
maybeHideSidebar
(
event
);
}
});
// Allow taps on the document to hide the sidebar as well as clicks, because
// on touch-input devices, not all elements will generate a "click" event.
addListener
(
'touchstart'
,
event
=>
{
if
(
!
annotationsAt
(
event
.
target
).
length
)
{
maybeHideSidebar
(
event
);
}
});
addListener
(
'mouseover'
,
event
=>
{
const
annotations
=
annotationsAt
(
event
.
target
);
if
(
annotations
.
length
&&
this
.
visibleHighlights
)
{
this
.
focusAnnotations
(
annotations
);
}
});
addListener
(
'mouseout'
,
()
=>
{
if
(
this
.
visibleHighlights
)
{
this
.
focusAnnotations
([]);
}
});
}
_removeElementEvents
()
{
this
.
_elementEventListeners
.
forEach
(({
event
,
callback
})
=>
{
this
.
element
[
0
].
removeEventListener
(
event
,
callback
);
});
}
addPlugin
(
name
,
options
)
{
if
(
this
.
plugins
[
name
])
{
console
.
error
(
'You cannot have more than one instance of any plugin.'
);
}
else
{
const
Klass
=
this
.
options
.
pluginClasses
[
name
];
if
(
typeof
Klass
===
'function'
)
{
this
.
plugins
[
name
]
=
new
Klass
(
this
.
element
[
0
],
options
);
this
.
plugins
[
name
].
annotator
=
this
;
this
.
plugins
[
name
].
pluginInit
?.();
}
else
{
console
.
error
(
'Could not load '
+
name
+
' plugin. Have you included the appropriate <script> tag?'
);
}
}
return
this
;
// allow chaining
}
// Get the document info
getDocumentInfo
()
{
let
metadataPromise
;
let
uriPromise
;
if
(
this
.
plugins
.
PDF
)
{
metadataPromise
=
Promise
.
resolve
(
this
.
plugins
.
PDF
.
getMetadata
());
uriPromise
=
Promise
.
resolve
(
this
.
plugins
.
PDF
.
uri
());
}
else
if
(
this
.
plugins
.
Document
)
{
uriPromise
=
Promise
.
resolve
(
this
.
plugins
.
Document
.
uri
());
metadataPromise
=
Promise
.
resolve
(
this
.
plugins
.
Document
.
metadata
);
}
else
{
uriPromise
=
Promise
.
reject
();
metadataPromise
=
Promise
.
reject
();
}
uriPromise
=
uriPromise
.
catch
(()
=>
decodeURIComponent
(
window
.
location
.
href
)
);
metadataPromise
=
metadataPromise
.
catch
(()
=>
({
title
:
document
.
title
,
link
:
[{
href
:
decodeURIComponent
(
window
.
location
.
href
)
}],
}));
return
Promise
.
all
([
metadataPromise
,
uriPromise
]).
then
(
([
metadata
,
href
])
=>
{
return
{
uri
:
normalizeURI
(
href
),
metadata
,
frameIdentifier
:
this
.
frameIdentifier
,
};
}
);
}
_setupInitialState
(
config
)
{
this
.
publish
(
'panelReady'
);
this
.
setVisibleHighlights
(
config
.
showHighlights
===
'always'
);
}
_connectAnnotationSync
()
{
this
.
subscribe
(
'annotationDeleted'
,
annotation
=>
{
this
.
detach
(
annotation
);
});
this
.
subscribe
(
'annotationsLoaded'
,
annotations
=>
{
annotations
.
map
(
annotation
=>
this
.
anchor
(
annotation
));
});
}
_connectAnnotationUISync
(
crossframe
)
{
crossframe
.
on
(
'focusAnnotations'
,
(
tags
=
[])
=>
{
for
(
let
anchor
of
this
.
anchors
)
{
if
(
anchor
.
highlights
)
{
const
toggle
=
tags
.
includes
(
anchor
.
annotation
.
$tag
);
$
(
anchor
.
highlights
).
toggleClass
(
'hypothesis-highlight-focused'
,
toggle
);
}
}
});
crossframe
.
on
(
'scrollToAnnotation'
,
tag
=>
{
for
(
let
anchor
of
this
.
anchors
)
{
if
(
anchor
.
highlights
)
{
if
(
anchor
.
annotation
.
$tag
===
tag
)
{
const
event
=
new
CustomEvent
(
'scrolltorange'
,
{
bubbles
:
true
,
cancelable
:
true
,
detail
:
anchor
.
range
,
});
const
defaultNotPrevented
=
this
.
element
[
0
].
dispatchEvent
(
event
);
if
(
defaultNotPrevented
)
{
scrollIntoView
(
anchor
.
highlights
[
0
]);
}
}
}
}
});
crossframe
.
on
(
'getDocumentInfo'
,
cb
=>
{
this
.
getDocumentInfo
()
.
then
(
info
=>
cb
(
null
,
info
))
.
catch
(
reason
=>
cb
(
reason
));
});
crossframe
.
on
(
'setVisibleHighlights'
,
state
=>
{
this
.
setVisibleHighlights
(
state
);
});
}
destroy
()
{
this
.
_removeElementEvents
();
this
.
selections
.
unsubscribe
();
this
.
adder
.
remove
();
this
.
element
.
find
(
'.hypothesis-highlight'
).
each
(
function
()
{
$
(
this
).
contents
().
insertBefore
(
this
);
$
(
this
).
remove
();
});
this
.
element
.
data
(
'annotator'
,
null
);
for
(
let
name
of
Object
.
keys
(
this
.
plugins
))
{
this
.
plugins
[
name
].
destroy
();
}
super
.
destroy
();
}
anchor
(
annotation
)
{
let
anchor
;
const
root
=
this
.
element
[
0
];
// Anchors for all annotations are in the `anchors` instance property. These
// are anchors for this annotation only. After all the targets have been
// processed these will be appended to the list of anchors known to the
// instance. Anchors hold an annotation, a target of that annotation, a
// document range for that target and an Array of highlights.
const
anchors
=
[];
// The targets that are already anchored. This function consults this to
// determine which targets can be left alone.
const
anchoredTargets
=
[];
// These are the highlights for existing anchors of this annotation with
// targets that have since been removed from the annotation. These will
// be removed by this function.
let
deadHighlights
=
[];
// Initialize the target array.
if
(
!
annotation
.
target
)
{
annotation
.
target
=
[];
}
const
locate
=
target
=>
{
// Check that the anchor has a TextQuoteSelector -- without a
// TextQuoteSelector we have no basis on which to verify that we have
// reanchored correctly and so we shouldn't even try.
//
// Returning an anchor without a range will result in this annotation being
// treated as an orphan (assuming no other targets anchor).
if
(
!
(
target
.
selector
??
[]).
some
(
s
=>
s
.
type
===
'TextQuoteSelector'
))
{
return
Promise
.
resolve
({
annotation
,
target
});
}
// Find a target using the anchoring module.
const
options
=
{
ignoreSelector
:
IGNORE_SELECTOR
,
};
return
this
.
anchoring
.
anchor
(
root
,
target
.
selector
,
options
)
.
then
(
range
=>
({
annotation
,
target
,
range
,
}))
.
catch
(()
=>
({
annotation
,
target
,
}));
};
// Highlight the range for an anchor.
const
highlight
=
anchor
=>
{
if
(
!
anchor
.
range
)
{
return
anchor
;
}
return
animationPromise
(()
=>
{
const
range
=
xpathRange
.
sniff
(
anchor
.
range
);
const
normedRange
=
range
.
normalize
(
root
);
const
highlights
=
highlighter
.
highlightRange
(
normedRange
);
$
(
highlights
).
data
(
'annotation'
,
anchor
.
annotation
);
anchor
.
highlights
=
highlights
;
return
anchor
;
});
};
// Store the results of anchoring.
const
sync
=
anchors
=>
{
// An annotation is considered to be an orphan if it has at least one
// target with selectors, and all targets with selectors failed to anchor
// (i.e. we didn't find it in the page and thus it has no range).
let
hasAnchorableTargets
=
false
;
let
hasAnchoredTargets
=
false
;
for
(
let
anchor
of
anchors
)
{
if
(
anchor
.
target
.
selector
)
{
hasAnchorableTargets
=
true
;
if
(
anchor
.
range
)
{
hasAnchoredTargets
=
true
;
break
;
}
}
}
annotation
.
$orphan
=
hasAnchorableTargets
&&
!
hasAnchoredTargets
;
// Add the anchors for this annotation to instance storage.
this
.
anchors
=
this
.
anchors
.
concat
(
anchors
);
// Let plugins know about the new information.
this
.
plugins
.
BucketBar
?.
update
();
this
.
plugins
.
CrossFrame
?.
sync
([
annotation
]);
return
anchors
;
};
// Remove all the anchors for this annotation from the instance storage.
for
(
anchor
of
this
.
anchors
.
splice
(
0
,
this
.
anchors
.
length
))
{
if
(
anchor
.
annotation
===
annotation
)
{
// Anchors are valid as long as they still have a range and their target
// is still in the list of targets for this annotation.
if
(
anchor
.
range
&&
annotation
.
target
.
includes
(
anchor
.
target
))
{
anchors
.
push
(
anchor
);
anchoredTargets
.
push
(
anchor
.
target
);
}
else
if
(
anchor
.
highlights
)
{
// These highlights are no longer valid and should be removed.
deadHighlights
=
deadHighlights
.
concat
(
anchor
.
highlights
);
delete
anchor
.
highlights
;
delete
anchor
.
range
;
}
}
else
{
// These can be ignored, so push them back onto the new list.
this
.
anchors
.
push
(
anchor
);
}
}
// Remove all the highlights that have no corresponding target anymore.
requestAnimationFrame
(()
=>
highlighter
.
removeHighlights
(
deadHighlights
));
// Anchor any targets of this annotation that are not anchored already.
for
(
let
target
of
annotation
.
target
)
{
if
(
!
anchoredTargets
.
includes
(
target
))
{
anchor
=
locate
(
target
).
then
(
highlight
);
anchors
.
push
(
anchor
);
}
}
return
Promise
.
all
(
anchors
).
then
(
sync
);
}
detach
(
annotation
)
{
const
anchors
=
[];
let
unhighlight
=
[];
for
(
let
anchor
of
this
.
anchors
)
{
if
(
anchor
.
annotation
===
annotation
)
{
unhighlight
.
push
(...(
anchor
.
highlights
??
[]));
}
else
{
anchors
.
push
(
anchor
);
}
}
this
.
anchors
=
anchors
;
requestAnimationFrame
(()
=>
{
highlighter
.
removeHighlights
(
unhighlight
);
this
.
plugins
.
BucketBar
?.
update
();
});
}
createAnnotation
(
annotation
=
{})
{
const
root
=
this
.
element
[
0
];
const
ranges
=
this
.
selectedRanges
??
[];
this
.
selectedRanges
=
null
;
const
getSelectors
=
range
=>
{
const
options
=
{
ignoreSelector
:
IGNORE_SELECTOR
,
};
// Returns an array of selectors for the passed range.
return
this
.
anchoring
.
describe
(
root
,
range
,
options
);
};
const
setDocumentInfo
=
info
=>
{
annotation
.
document
=
info
.
metadata
;
annotation
.
uri
=
info
.
uri
;
};
const
setTargets
=
([
info
,
selectors
])
=>
{
// `selectors` is an array of arrays: each item is an array of selectors
// identifying a distinct target.
const
source
=
info
.
uri
;
annotation
.
target
=
selectors
.
map
(
selector
=>
({
source
,
selector
,
}));
};
const
info
=
this
.
getDocumentInfo
();
info
.
then
(
setDocumentInfo
);
const
selectors
=
Promise
.
all
(
ranges
.
map
(
getSelectors
));
const
targets
=
Promise
.
all
([
info
,
selectors
]).
then
(
setTargets
);
targets
.
then
(()
=>
this
.
publish
(
'beforeAnnotationCreated'
,
[
annotation
]));
targets
.
then
(()
=>
this
.
anchor
(
annotation
));
if
(
!
annotation
.
$highlight
)
{
this
.
crossframe
?.
call
(
'showSidebar'
);
}
return
annotation
;
}
createHighlight
()
{
return
this
.
createAnnotation
({
$highlight
:
true
});
}
// Create a blank comment (AKA "page note")
createComment
()
{
const
annotation
=
{};
const
prepare
=
info
=>
{
annotation
.
document
=
info
.
metadata
;
annotation
.
uri
=
info
.
uri
;
annotation
.
target
=
[{
source
:
info
.
uri
}];
};
this
.
getDocumentInfo
()
.
then
(
prepare
)
.
then
(()
=>
this
.
publish
(
'beforeAnnotationCreated'
,
[
annotation
]));
return
annotation
;
}
// Public: Deletes the annotation by removing the highlight from the DOM.
// Publishes the 'annotationDeleted' event on completion.
//
// annotation - An annotation Object to delete.
//
// Returns deleted annotation.
deleteAnnotation
(
annotation
)
{
if
(
annotation
.
highlights
)
{
for
(
let
h
of
annotation
.
highlights
)
{
if
(
h
.
parentNode
!==
null
)
{
$
(
h
).
replaceWith
(
h
.
childNodes
);
}
}
}
this
.
publish
(
'annotationDeleted'
,
[
annotation
]);
}
showAnnotations
(
annotations
)
{
const
tags
=
annotations
.
map
(
a
=>
a
.
$tag
);
this
.
crossframe
?.
call
(
'showAnnotations'
,
tags
);
this
.
crossframe
?.
call
(
'showSidebar'
);
}
toggleAnnotationSelection
(
annotations
)
{
const
tags
=
annotations
.
map
(
a
=>
a
.
$tag
);
this
.
crossframe
?.
call
(
'toggleAnnotationSelection'
,
tags
);
}
updateAnnotations
(
annotations
)
{
const
tags
=
annotations
.
map
(
a
=>
a
.
$tag
);
this
.
crossframe
?.
call
(
'updateAnnotations'
,
tags
);
}
focusAnnotations
(
annotations
)
{
const
tags
=
annotations
.
map
(
a
=>
a
.
$tag
);
this
.
crossframe
?.
call
(
'focusAnnotations'
,
tags
);
}
_onSelection
(
range
)
{
const
selection
=
/** @type {Selection} */
(
document
.
getSelection
());
const
isBackwards
=
rangeUtil
.
isSelectionBackwards
(
selection
);
const
focusRect
=
rangeUtil
.
selectionFocusRect
(
selection
);
if
(
!
focusRect
)
{
// The selected range does not contain any text
this
.
_onClearSelection
();
return
;
}
this
.
selectedRanges
=
[
range
];
if
(
this
.
toolbar
)
{
this
.
toolbar
.
newAnnotationType
=
'annotation'
;
}
const
{
left
,
top
,
arrowDirection
}
=
this
.
adderCtrl
.
target
(
focusRect
,
isBackwards
);
this
.
adderCtrl
.
annotationsForSelection
=
annotationsForSelection
();
this
.
adderCtrl
.
showAt
(
left
,
top
,
arrowDirection
);
}
_onClearSelection
()
{
this
.
adderCtrl
.
hide
();
this
.
selectedRanges
=
[];
if
(
this
.
toolbar
)
{
this
.
toolbar
.
newAnnotationType
=
'note'
;
}
}
selectAnnotations
(
annotations
,
toggle
)
{
if
(
toggle
)
{
this
.
toggleAnnotationSelection
(
annotations
);
}
else
{
this
.
showAnnotations
(
annotations
);
}
}
// Did an event originate from an element in the annotator UI? (eg. the sidebar
// frame, or its toolbar)
isEventInAnnotator
(
event
)
{
return
closest
(
event
.
target
,
'.annotator-frame'
)
!==
null
;
}
// Pass true to show the highlights in the frame or false to disable.
setVisibleHighlights
(
shouldShowHighlights
)
{
this
.
toggleHighlightClass
(
shouldShowHighlights
);
}
toggleHighlightClass
(
shouldShowHighlights
)
{
const
showHighlightsClass
=
'hypothesis-highlights-always-on'
;
if
(
shouldShowHighlights
)
{
this
.
element
.
addClass
(
showHighlightsClass
);
}
else
{
this
.
element
.
removeClass
(
showHighlightsClass
);
}
this
.
visibleHighlights
=
shouldShowHighlights
;
if
(
this
.
toolbar
)
{
this
.
toolbar
.
highlightsVisible
=
shouldShowHighlights
;
}
}
}
src/annotator/host.js
View file @
1f7174f6
// TODO - Convert this to an ES import once the `Guest` class is converted to JS.
import
Guest
from
'./guest'
;
// @ts-expect-error
const
Guest
=
require
(
'./guest'
);
export
default
class
Host
extends
Guest
{
export
default
class
Host
extends
Guest
{
constructor
(
element
,
config
)
{
constructor
(
element
,
config
)
{
...
...
src/annotator/index.js
View file @
1f7174f6
...
@@ -20,19 +20,18 @@ import iconSet from './icons';
...
@@ -20,19 +20,18 @@ import iconSet from './icons';
registerIcons
(
iconSet
);
registerIcons
(
iconSet
);
import
configFrom
from
'./config/index'
;
import
configFrom
from
'./config/index'
;
import
PdfSidebar
from
'./pdf-sidebar
'
;
import
CrossFramePlugin
from
'./plugin/cross-frame
'
;
import
DocumentPlugin
from
'./plugin/document'
;
import
DocumentPlugin
from
'./plugin/document'
;
import
Guest
from
'./guest'
;
import
PdfSidebar
from
'./pdf-sidebar'
;
import
Sidebar
from
'./sidebar'
;
// Modules that are still written in CoffeeScript and need to be converted to
// Modules that are still written in CoffeeScript and need to be converted to
// JS.
// JS.
// @ts-expect-error
// @ts-expect-error
import
Guest
from
'./guest'
;
// @ts-expect-error
import
BucketBarPlugin
from
'./plugin/bucket-bar'
;
import
BucketBarPlugin
from
'./plugin/bucket-bar'
;
import
CrossFramePlugin
from
'./plugin/cross-frame'
;
// @ts-expect-error
// @ts-expect-error
import
PDFPlugin
from
'./plugin/pdf'
;
import
PDFPlugin
from
'./plugin/pdf'
;
import
Sidebar
from
'./sidebar'
;
const
pluginClasses
=
{
const
pluginClasses
=
{
// UI plugins
// UI plugins
...
@@ -59,6 +58,7 @@ const config = configFrom(window);
...
@@ -59,6 +58,7 @@ const config = configFrom(window);
$
.
noConflict
(
true
)(
function
()
{
$
.
noConflict
(
true
)(
function
()
{
const
isPDF
=
typeof
window_
.
PDFViewerApplication
!==
'undefined'
;
const
isPDF
=
typeof
window_
.
PDFViewerApplication
!==
'undefined'
;
/** @type {new (e: Element, config: any) => Guest} */
let
Klass
=
isPDF
?
PdfSidebar
:
Sidebar
;
let
Klass
=
isPDF
?
PdfSidebar
:
Sidebar
;
if
(
config
.
subFrameIdentifier
)
{
if
(
config
.
subFrameIdentifier
)
{
...
...
src/annotator/test/guest-test.coffee
View file @
1f7174f6
...
@@ -5,7 +5,7 @@ Plugin = require('../plugin')
...
@@ -5,7 +5,7 @@ Plugin = require('../plugin')
{
default
:
Delegator
}
=
require
(
'../delegator'
)
{
default
:
Delegator
}
=
require
(
'../delegator'
)
$
=
require
(
'jquery'
)
$
=
require
(
'jquery'
)
Guest
=
require
(
'../guest'
)
{
default
:
Guest
}
=
require
(
'../guest'
)
{
$imports
}
=
require
(
'../guest'
)
{
$imports
}
=
require
(
'../guest'
)
rangeUtil
=
null
rangeUtil
=
null
selections
=
null
selections
=
null
...
...
Write
Preview
Markdown
is supported
0%
Try again
or
attach a new file
Attach a file
Cancel
You are about to add
0
people
to the discussion. Proceed with caution.
Finish editing this message first!
Cancel
Please
register
or
sign in
to comment