-
Notifications
You must be signed in to change notification settings - Fork 538
Expand file tree
/
Copy pathrange.coffee
More file actions
470 lines (412 loc) · 15.7 KB
/
range.coffee
File metadata and controls
470 lines (412 loc) · 15.7 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
Range = {}
# Public: Determines the type of Range of the provided object and returns
# a suitable Range instance.
#
# r - A range Object.
#
# Examples
#
# selection = window.getSelection()
# Range.sniff(selection.getRangeAt(0))
# # => Returns a BrowserRange instance.
#
# Returns a Range object or false.
Range.sniff = (r) ->
if r.commonAncestorContainer?
new Range.BrowserRange(r)
else if typeof r.start is "string"
new Range.SerializedRange(r)
else if r.start and typeof r.start is "object"
new Range.NormalizedRange(r)
else
console.error(_t("Could not sniff range type"))
false
# Public: Finds an Element Node using an XPath relative to the document root.
#
# If the document is served as application/xhtml+xml it will try and resolve
# any namespaces within the XPath.
#
# xpath - An XPath String to query.
#
# Examples
#
# node = Range.nodeFromXPath('/html/body/div/p[2]')
# if node
# # Do something with the node.
#
# Returns the Node if found otherwise null.
Range.nodeFromXPath = (xpath, root=document) ->
evaluateXPath = (xp, nsResolver=null) ->
try
document.evaluate('.' + xp, root, nsResolver, XPathResult.FIRST_ORDERED_NODE_TYPE, null).singleNodeValue
catch exception
# There are cases when the evaluation fails, because the
# HTML documents contains nodes with invalid names,
# for example tags with equal signs in them, or something like that.
# In these cases, the XPath expressions will have these abominations,
# too, and then they can not be evaluated.
# In these cases, we get an XPathException, with error code 52.
# See http://www.w3.org/TR/DOM-Level-3-XPath/xpath.html#XPathException
# This does not necessarily make any sense, but this what we see
# happening.
console.log "XPath evaluation failed."
console.log "Trying fallback..."
# We have a an 'evaluator' for the really simple expressions that
# should work for the simple expressions we generate.
Util.nodeFromXPath(xp, root)
if not $.isXMLDoc document.documentElement
evaluateXPath xpath
else
# We're in an XML document, create a namespace resolver function to try
# and resolve any namespaces in the current document.
# https://developer.mozilla.org/en/DOM/document.createNSResolver
customResolver = document.createNSResolver(
if document.ownerDocument == null
document.documentElement
else
document.ownerDocument.documentElement
)
node = evaluateXPath xpath, customResolver
unless node
# If the previous search failed to find a node then we must try to
# provide a custom namespace resolver to take into account the default
# namespace. We also prefix all node names with a custom xhtml namespace
# eg. 'div' => 'xhtml:div'.
xpath = (for segment in xpath.split '/'
if segment and segment.indexOf(':') == -1
segment.replace(/^([a-z]+)/, 'xhtml:$1')
else segment
).join('/')
# Find the default document namespace.
namespace = document.lookupNamespaceURI null
# Try and resolve the namespace, first seeing if it is an xhtml node
# otherwise check the head attributes.
customResolver = (ns) ->
if ns == 'xhtml' then namespace
else document.documentElement.getAttribute('xmlns:' + ns)
node = evaluateXPath xpath, customResolver
node
class Range.RangeError extends Error
constructor: (@type, @message, @parent=null) ->
super(@message)
# Public: Creates a wrapper around a range object obtained from a DOMSelection.
class Range.BrowserRange
# Public: Creates an instance of BrowserRange.
#
# object - A range object obtained via DOMSelection#getRangeAt().
#
# Examples
#
# selection = window.getSelection()
# range = new Range.BrowserRange(selection.getRangeAt(0))
#
# Returns an instance of BrowserRange.
constructor: (obj) ->
@commonAncestorContainer = obj.commonAncestorContainer
@startContainer = obj.startContainer
@startOffset = obj.startOffset
@endContainer = obj.endContainer
@endOffset = obj.endOffset
# Public: normalize works around the fact that browsers don't generate
# ranges/selections in a consistent manner. Some (Safari) will create
# ranges that have (say) a textNode startContainer and elementNode
# endContainer. Others (Firefox) seem to only ever generate
# textNode/textNode or elementNode/elementNode pairs.
#
# Returns an instance of Range.NormalizedRange
normalize: (root) ->
if @tainted
console.error(_t("You may only call normalize() once on a BrowserRange!"))
return false
else
@tainted = true
r = {}
# Look at the start
if @startContainer.nodeType is Node.ELEMENT_NODE
# We are dealing with element nodes
r.start = Util.getFirstTextNodeNotBefore @startContainer.childNodes[@startOffset]
r.startOffset = 0
else
# We are dealing with simple text nodes
r.start = @startContainer
r.startOffset = @startOffset
# Look at the end
if @endContainer.nodeType is Node.ELEMENT_NODE
# Get specified node.
node = @endContainer.childNodes[@endOffset]
if node? # Does that node exist?
# Look for a text node either at the immediate beginning of node
n = node
while n? and (n.nodeType isnt Node.TEXT_NODE)
n = n.firstChild
if n? # Did we find a text node at the start of this element?
r.end = n
r.endOffset = 0
unless r.end?
# We need to find a text node in the previous sibling of the node at the
# given offset, if one exists, or in the previous sibling of its container.
if @endOffset
node = @endContainer.childNodes[@endOffset - 1]
else
node = @endContainer.previousSibling
r.end = Util.getLastTextNodeUpTo node
r.endOffset = r.end.nodeValue.length
else # We are dealing with simple text nodes
r.end = @endContainer
r.endOffset = @endOffset
# We have collected the initial data.
# Now let's start to slice & dice the text elements!
nr = {}
if r.startOffset > 0
# Do we really have to cut?
if r.start.nodeValue.length > r.startOffset
# Yes. Cut.
nr.start = r.start.splitText(r.startOffset)
else
# Avoid splitting off zero-length pieces.
nr.start = r.start.nextSibling
else
nr.start = r.start
# is the whole selection inside one text element ?
if r.start is r.end
if nr.start.nodeValue.length > (r.endOffset - r.startOffset)
nr.start.splitText(r.endOffset - r.startOffset)
nr.end = nr.start
else # no, the end of the selection is in a separate text element
# does the end need to be cut?
if r.end.nodeValue.length > r.endOffset
r.end.splitText(r.endOffset)
nr.end = r.end
# Make sure the common ancestor is an element node.
nr.commonAncestor = @commonAncestorContainer
while nr.commonAncestor.nodeType isnt Node.ELEMENT_NODE
nr.commonAncestor = nr.commonAncestor.parentNode
new Range.NormalizedRange(nr)
# Public: Creates a range suitable for storage.
#
# root - A root Element from which to anchor the serialisation.
# ignoreSelector - A selector String of elements to ignore. For example
# elements injected by the annotator.
#
# Returns an instance of SerializedRange.
serialize: (root, ignoreSelector) ->
this.normalize(root).serialize(root, ignoreSelector)
# Public: A normalised range is most commonly used throughout the annotator.
# its the result of a deserialised SerializedRange or a BrowserRange with
# out browser inconsistencies.
class Range.NormalizedRange
# Public: Creates an instance of a NormalizedRange.
#
# This is usually created by calling the .normalize() method on one of the
# other Range classes rather than manually.
#
# obj - An Object literal. Should have the following properties.
# commonAncestor: A Element that encompasses both the start and end nodes
# start: The first TextNode in the range.
# end The last TextNode in the range.
#
# Returns an instance of NormalizedRange.
constructor: (obj) ->
@commonAncestor = obj.commonAncestor
@start = obj.start
@end = obj.end
# Public: For API consistency.
#
# Returns itself.
normalize: (root) ->
this
# Public: Limits the nodes within the NormalizedRange to those contained
# withing the bounds parameter. It returns an updated range with all
# properties updated. NOTE: Method returns null if all nodes fall outside
# of the bounds.
#
# bounds - An Element to limit the range to.
#
# Returns updated self or null.
limit: (bounds) ->
if @commonAncestor == bounds or $.contains(bounds, @commonAncestor)
return this
if not $.contains(@commonAncestor, bounds)
return null
document = bounds.ownerDocument
if not $.contains(bounds, @start)
walker = document.createTreeWalker(bounds, NodeFilter.SHOW_TEXT)
@start = walker.firstChild()
if not $.contains(bounds, @end)
walker = document.createTreeWalker(bounds, NodeFilter.SHOW_TEXT)
@end = walker.lastChild()
return null unless @start and @end
startParents = $(@start).parents()
for parent in $(@end).parents()
if startParents.index(parent) != -1
@commonAncestor = parent
break
this
# Convert this range into an object consisting of two pairs of (xpath,
# character offset), which can be easily stored in a database.
#
# root - The root Element relative to which XPaths should be calculated
# ignoreSelector - A selector String of elements to ignore. For example
# elements injected by the annotator.
#
# Returns an instance of SerializedRange.
serialize: (root, ignoreSelector) ->
serialization = (node, isEnd) ->
if ignoreSelector
origParent = $(node).parents(":not(#{ignoreSelector})").eq(0)
else
origParent = $(node).parent()
xpath = Util.xpathFromNode(origParent, root)[0]
textNodes = Util.getTextNodes(origParent)
# Calculate real offset as the combined length of all the
# preceding textNode siblings. We include the length of the
# node if it's the end node.
nodes = textNodes.slice(0, textNodes.index(node))
offset = 0
for n in nodes
offset += n.nodeValue.length
if isEnd then [xpath, offset + node.nodeValue.length] else [xpath, offset]
start = serialization(@start)
end = serialization(@end, true)
new Range.SerializedRange({
# XPath strings
start: start[0]
end: end[0]
# Character offsets (integer)
startOffset: start[1]
endOffset: end[1]
})
# Public: Creates a concatenated String of the contents of all the text nodes
# within the range.
#
# Returns a String.
text: ->
(for node in this.textNodes()
node.nodeValue
).join ''
# Public: Fetches only the text nodes within th range.
#
# Returns an Array of TextNode instances.
textNodes: ->
textNodes = Util.getTextNodes($(this.commonAncestor))
[start, end] = [textNodes.index(this.start), textNodes.index(this.end)]
# Return the textNodes that fall between the start and end indexes.
$.makeArray textNodes[start..end]
# Public: Converts the Normalized range to a native browser range.
#
# See: https://developer.mozilla.org/en/DOM/range
#
# Examples
#
# selection = window.getSelection()
# selection.removeAllRanges()
# selection.addRange(normedRange.toRange())
#
# Returns a Range object.
toRange: ->
range = document.createRange()
range.setStartBefore(@start)
range.setEndAfter(@end)
range
# Public: A range suitable for storing in local storage or serializing to JSON.
class Range.SerializedRange
# Public: Creates a SerializedRange
#
# obj - The stored object. It should have the following properties.
# start: An xpath to the Element containing the first TextNode
# relative to the root Element.
# startOffset: The offset to the start of the selection from obj.start.
# end: An xpath to the Element containing the last TextNode
# relative to the root Element.
# startOffset: The offset to the end of the selection from obj.end.
#
# Returns an instance of SerializedRange
constructor: (obj) ->
@start = obj.start
@startOffset = obj.startOffset
@end = obj.end
@endOffset = obj.endOffset
# Public: Creates a NormalizedRange.
#
# root - The root Element from which the XPaths were generated.
#
# Returns a NormalizedRange instance.
normalize: (root) ->
range = {}
for p in ['start', 'end']
try
node = Range.nodeFromXPath(this[p], root)
catch e
throw new Range.RangeError(p, "Error while finding #{p} node: #{this[p]}: " + e, e)
if not node
throw new Range.RangeError(p, "Couldn't find #{p} node: #{this[p]}")
# Unfortunately, we *can't* guarantee only one textNode per
# elementNode, so we have to walk along the element's textNodes until
# the combined length of the textNodes to that point exceeds or
# matches the value of the offset.
length = 0
targetOffset = this[p + 'Offset']
# Range excludes its endpoint because it describes the boundary position.
# Target the string index of the last character inside the range.
if p is 'end' then targetOffset--
for tn in Util.getTextNodes($(node))
if (length + tn.nodeValue.length > targetOffset)
range[p + 'Container'] = tn
range[p + 'Offset'] = this[p + 'Offset'] - length
break
else
length += tn.nodeValue.length
# If we fall off the end of the for loop without having set
# 'startOffset'/'endOffset', the element has shorter content than when
# we annotated, so throw an error:
if not range[p + 'Offset']?
throw new Range.RangeError("#{p}offset", "Couldn't find offset #{this[p + 'Offset']} in element #{this[p]}")
# Here's an elegant next step...
#
# range.commonAncestorContainer = $(range.startContainer).parents().has(range.endContainer)[0]
#
# ...but unfortunately Node.contains() is broken in Safari 5.1.5 (7534.55.3)
# and presumably other earlier versions of WebKit. In particular, in a
# document like
#
# <p>Hello</p>
#
# the code
#
# p = document.getElementsByTagName('p')[0]
# p.contains(p.firstChild)
#
# returns `false`. Yay.
#
# So instead, we step through the parents from the bottom up and use
# Node.compareDocumentPosition() to decide when to set the
# commonAncestorContainer and bail out.
contains =
if not document.compareDocumentPosition?
# IE
(a, b) -> a.contains(b)
else
# Everyone else
(a, b) -> a.compareDocumentPosition(b) & 16
$(range.startContainer).parents().each ->
if contains(this, range.endContainer)
range.commonAncestorContainer = this
return false
new Range.BrowserRange(range).normalize(root)
# Public: Creates a range suitable for storage.
#
# root - A root Element from which to anchor the serialisation.
# ignoreSelector - A selector String of elements to ignore. For example
# elements injected by the annotator.
#
# Returns an instance of SerializedRange.
serialize: (root, ignoreSelector) ->
this.normalize(root).serialize(root, ignoreSelector)
# Public: Returns the range as an Object literal.
toObject: ->
{
start: @start
startOffset: @startOffset
end: @end
endOffset: @endOffset
}