mirror of
https://github.com/overleaf/overleaf.git
synced 2024-12-27 22:12:11 +00:00
209 lines
6.7 KiB
CoffeeScript
209 lines
6.7 KiB
CoffeeScript
# A simple text implementation
|
|
#
|
|
# Operations are lists of components.
|
|
# Each component either inserts or deletes at a specified position in the document.
|
|
#
|
|
# Components are either:
|
|
# {i:'str', p:100}: Insert 'str' at position 100 in the document
|
|
# {d:'str', p:100}: Delete 'str' at position 100 in the document
|
|
#
|
|
# Components in an operation are executed sequentially, so the position of components
|
|
# assumes previous components have already executed.
|
|
#
|
|
# Eg: This op:
|
|
# [{i:'abc', p:0}]
|
|
# is equivalent to this op:
|
|
# [{i:'a', p:0}, {i:'b', p:1}, {i:'c', p:2}]
|
|
|
|
# NOTE: The global scope here is shared with other sharejs files when built with closure.
|
|
# Be careful what ends up in your namespace.
|
|
|
|
text = {}
|
|
|
|
text.name = 'text'
|
|
|
|
text.create = -> ''
|
|
|
|
strInject = (s1, pos, s2) -> s1[...pos] + s2 + s1[pos..]
|
|
|
|
checkValidComponent = (c) ->
|
|
throw new Error 'component missing position field' if typeof c.p != 'number'
|
|
|
|
i_type = typeof c.i
|
|
d_type = typeof c.d
|
|
throw new Error 'component needs an i or d field' unless (i_type == 'string') ^ (d_type == 'string')
|
|
|
|
throw new Error 'position cannot be negative' unless c.p >= 0
|
|
|
|
checkValidOp = (op) ->
|
|
checkValidComponent(c) for c in op
|
|
true
|
|
|
|
text.apply = (snapshot, op) ->
|
|
checkValidOp op
|
|
for component in op
|
|
if component.i?
|
|
snapshot = strInject snapshot, component.p, component.i
|
|
else
|
|
deleted = snapshot[component.p...(component.p + component.d.length)]
|
|
throw new Error "Delete component '#{component.d}' does not match deleted text '#{deleted}'" unless component.d == deleted
|
|
snapshot = snapshot[...component.p] + snapshot[(component.p + component.d.length)..]
|
|
|
|
snapshot
|
|
|
|
|
|
# Exported for use by the random op generator.
|
|
#
|
|
# For simplicity, this version of append does not compress adjacent inserts and deletes of
|
|
# the same text. It would be nice to change that at some stage.
|
|
text._append = append = (newOp, c) ->
|
|
return if c.i == '' or c.d == ''
|
|
if newOp.length == 0
|
|
newOp.push c
|
|
else
|
|
last = newOp[newOp.length - 1]
|
|
|
|
# Compose the insert into the previous insert if possible
|
|
if last.i? && c.i? and last.p <= c.p <= (last.p + last.i.length)
|
|
newOp[newOp.length - 1] = {i:strInject(last.i, c.p - last.p, c.i), p:last.p}
|
|
else if last.d? && c.d? and c.p <= last.p <= (c.p + c.d.length)
|
|
newOp[newOp.length - 1] = {d:strInject(c.d, last.p - c.p, last.d), p:c.p}
|
|
else
|
|
newOp.push c
|
|
|
|
text.compose = (op1, op2) ->
|
|
checkValidOp op1
|
|
checkValidOp op2
|
|
|
|
newOp = op1.slice()
|
|
append newOp, c for c in op2
|
|
|
|
newOp
|
|
|
|
# Attempt to compress the op components together 'as much as possible'.
|
|
# This implementation preserves order and preserves create/delete pairs.
|
|
text.compress = (op) -> text.compose [], op
|
|
|
|
text.normalize = (op) ->
|
|
newOp = []
|
|
|
|
# Normalize should allow ops which are a single (unwrapped) component:
|
|
# {i:'asdf', p:23}.
|
|
# There's no good way to test if something is an array:
|
|
# http://perfectionkills.com/instanceof-considered-harmful-or-how-to-write-a-robust-isarray/
|
|
# so this is probably the least bad solution.
|
|
op = [op] if op.i? or op.p?
|
|
|
|
for c in op
|
|
c.p ?= 0
|
|
append newOp, c
|
|
|
|
newOp
|
|
|
|
# This helper method transforms a position by an op component.
|
|
#
|
|
# If c is an insert, insertAfter specifies whether the transform
|
|
# is pushed after the insert (true) or before it (false).
|
|
#
|
|
# insertAfter is optional for deletes.
|
|
transformPosition = (pos, c, insertAfter) ->
|
|
if c.i?
|
|
if c.p < pos || (c.p == pos && insertAfter)
|
|
pos + c.i.length
|
|
else
|
|
pos
|
|
else
|
|
# I think this could also be written as: Math.min(c.p, Math.min(c.p - otherC.p, otherC.d.length))
|
|
# but I think its harder to read that way, and it compiles using ternary operators anyway
|
|
# so its no slower written like this.
|
|
if pos <= c.p
|
|
pos
|
|
else if pos <= c.p + c.d.length
|
|
c.p
|
|
else
|
|
pos - c.d.length
|
|
|
|
# Helper method to transform a cursor position as a result of an op.
|
|
#
|
|
# Like transformPosition above, if c is an insert, insertAfter specifies whether the cursor position
|
|
# is pushed after an insert (true) or before it (false).
|
|
text.transformCursor = (position, op, side) ->
|
|
insertAfter = side == 'right'
|
|
position = transformPosition position, c, insertAfter for c in op
|
|
position
|
|
|
|
# Transform an op component by another op component. Asymmetric.
|
|
# The result will be appended to destination.
|
|
#
|
|
# exported for use in JSON type
|
|
text._tc = transformComponent = (dest, c, otherC, side) ->
|
|
checkValidOp [c]
|
|
checkValidOp [otherC]
|
|
|
|
if c.i?
|
|
append dest, {i:c.i, p:transformPosition(c.p, otherC, side == 'right')}
|
|
|
|
else # Delete
|
|
if otherC.i? # delete vs insert
|
|
s = c.d
|
|
if c.p < otherC.p
|
|
append dest, {d:s[...otherC.p - c.p], p:c.p}
|
|
s = s[(otherC.p - c.p)..]
|
|
if s != ''
|
|
append dest, {d:s, p:c.p + otherC.i.length}
|
|
|
|
else # Delete vs delete
|
|
if c.p >= otherC.p + otherC.d.length
|
|
append dest, {d:c.d, p:c.p - otherC.d.length}
|
|
else if c.p + c.d.length <= otherC.p
|
|
append dest, c
|
|
else
|
|
# They overlap somewhere.
|
|
newC = {d:'', p:c.p}
|
|
if c.p < otherC.p
|
|
newC.d = c.d[...(otherC.p - c.p)]
|
|
if c.p + c.d.length > otherC.p + otherC.d.length
|
|
newC.d += c.d[(otherC.p + otherC.d.length - c.p)..]
|
|
|
|
# This is entirely optional - just for a check that the deleted
|
|
# text in the two ops matches
|
|
intersectStart = Math.max c.p, otherC.p
|
|
intersectEnd = Math.min c.p + c.d.length, otherC.p + otherC.d.length
|
|
cIntersect = c.d[intersectStart - c.p...intersectEnd - c.p]
|
|
otherIntersect = otherC.d[intersectStart - otherC.p...intersectEnd - otherC.p]
|
|
throw new Error 'Delete ops delete different text in the same region of the document' unless cIntersect == otherIntersect
|
|
|
|
if newC.d != ''
|
|
# This could be rewritten similarly to insert v delete, above.
|
|
newC.p = transformPosition newC.p, otherC
|
|
append dest, newC
|
|
|
|
dest
|
|
|
|
invertComponent = (c) ->
|
|
if c.i?
|
|
{d:c.i, p:c.p}
|
|
else
|
|
{i:c.d, p:c.p}
|
|
|
|
# No need to use append for invert, because the components won't be able to
|
|
# cancel with one another.
|
|
text.invert = (op) -> (invertComponent c for c in op.slice().reverse())
|
|
|
|
|
|
if WEB?
|
|
exports.types ||= {}
|
|
|
|
# This is kind of awful - come up with a better way to hook this helper code up.
|
|
bootstrapTransform(text, transformComponent, checkValidOp, append)
|
|
|
|
# [] is used to prevent closure from renaming types.text
|
|
exports.types.text = text
|
|
else
|
|
module.exports = text
|
|
|
|
# The text type really shouldn't need this - it should be possible to define
|
|
# an efficient transform function by making a sort of transform map and passing each
|
|
# op component through it.
|
|
require('./helpers').bootstrapTransform(text, transformComponent, checkValidOp, append)
|
|
|