Skip to content
Snippets Groups Projects
TrackChangesProvider.js 23.5 KiB
Newer Older
john's avatar
john committed
import {
john's avatar
john committed
  filter,
  find,
  includes,
john's avatar
john committed
  keys,
  some,
john's avatar
john committed
} from 'lodash'

import {
  createAnnotation,
  deleteCharacter as deleteChar,
john's avatar
john committed
  expandAnnotation,
  truncateAnnotation,
  TOCProvider
john's avatar
john committed
} from 'substance'
class TrackChangesProvider extends TOCProvider {
  constructor (document, config) {
    super(document, config)
    config.documentSession.on('didUpdate', this.handleUndoRedo, this)

    // HACK -- use TOCProvider's event to capture new / deleted changes
    this.on('toc:updated', this.reComputeEntries, this)
john's avatar
john committed
  handleTransaction (options) {
    options.selection = this.getSelection()
    this.chooseHanlder(options)
john's avatar
john committed
  }

  chooseHanlder (options) {
    if (options.status === 'add') return this.handleAdd(options)
    if (options.status === 'delete') return this.handleDelete(options)
  }
john's avatar
john committed

john's avatar
john committed
    const isSelectionCollapsed = this.isSelectionCollapsed()

    if (isSelectionCollapsed) return this.handleAddCollapsed(options)
    if (!isSelectionCollapsed) return this.handleAddNonCollapsed(options)
  }
  handleAddCollapsed (options) {
    const { event } = options
    const notOnTrack = this.isNotOnTrackAnnotation()
    const isOnAdd = this.isOnAnnotation('add')
    const isOnDelete = this.isOnAnnotation('delete')
    if (notOnTrack) return this.insertCharacterWithAddAnnotation(options)
      // annotation gets expanded automatically, unless the selection is on its left edge
      const annotation = this.getAnnotationByStatus('add')
      const isOnLeftEdge = this.isOnLeftEdge(annotation)
      const isOnRightEdge = this.isOnRightEdge(annotation)
      const isFromSameUser = this.isAnnotationFromTheSameUser(annotation)
      const mode = this.getMode()

      // console.log(annotation)

      if (isFromSameUser) {
        this.insertText(event)
        if (isOnLeftEdge) this.expandAnnotationToDirection(annotation)
      }

      if (!isFromSameUser) {
        if (isOnRightEdge) {
          this.insertCharacterWithoutExpandingAnnotation(annotation, options)
        } else {
          this.insertText(event)
        }

        if (mode) this.createAdditionAnnotationOnLastChar()
      }
    if (isOnDelete) {
      const annotation = this.getAnnotationByStatus('delete')
      const isOnLeftEdge = this.isOnLeftEdge(annotation)
      const isOnRightEdge = this.isOnRightEdge(annotation)
      const withinAnnotation = this.isSelectionContainedWithin(annotation, true)
      // if contained within the delete annotation, move it to the edge,
      // insert character, set event to null so that the character does
      // not get inserted twice, and handle again
      if (withinAnnotation) {
        this.moveCursorTo(annotation.endOffset)
        this.insertCharacterWithoutExpandingAnnotation(annotation, options)
        options.selection = this.getSelection()
        return this.handleAdd(options)
      if (isOnLeftEdge) return this.insertCharacterWithAddAnnotation(options)
      if (isOnRightEdge) {
        this.insertCharacterWithoutExpandingAnnotation(annotation, options)
        return this.handleAdd(options)
  handleAddNonCollapsed (options) {
    let { selection } = options

    const notOnTrack = this.isNotOnTrackAnnotation()
    const isOnDelete = this.isOnAnnotation('delete')

    if (notOnTrack) return this.deleteSelectedAndCreateAddition(options)

    // delete all additions of the same user and
    // shorten selection by the number of deleted characters
    const shortenBy = this.deleteAllOwnAdditions(selection)
    const startOffset = selection.startOffset
    const endOffset = selection.endOffset - shortenBy
    selection = this.updateSelection(selection, startOffset, endOffset)
    options.selection = selection

    if (isOnDelete) {
      const annotation = this.getAnnotationByStatus('delete')
      const withinAnnotation = this.isSelectionContainedWithin(annotation)

      if (withinAnnotation) {
        // if selection is wholly contained within a delete annotation,
        // move to the end of the annotation and handle again
        this.moveCursorTo(annotation.endOffset)
        options.selection = this.getSelection()
        return this.handleAddCollapsed(options)
      }
    }

    // after deleting all own additions, there is still text selected
    // mark it as deleted and add new addition annotation at the end
    // TODO -- use selection.isCollapsed()
    if (selection.endOffset > selection.startOffset) {
      this.deleteOrMergeAllOwnDeletions(selection)
      this.deleteSelectedAndCreateAddition(options)
      return
    }

    // if you got here, deleting all own additions left a collapsed selection
    // (ie. the whole selection was on an own addition annotation)
    // since selection is collapsed, handle it as such
    return this.handleAddCollapsed(options)
  handleDelete (options) {
    const { key, move } = options
    const isSelectionCollapsed = this.isSelectionCollapsed()
    options.direction = {
      cursorTo: (move === 'left') ? 'start' : 'end',
      key: key,
      move: move
    }
    if (isSelectionCollapsed) return this.handleDeleteCollapsed(options)
    if (!isSelectionCollapsed) return this.handleDeleteNonCollapsed(options)
  handleDeleteCollapsed (options) {
    const { direction } = options
    const notOnTrack = this.isNotOnTrackAnnotation()
    const isOnAdd = this.isOnAnnotation('add')
    const isOnDelete = this.isOnAnnotation('delete')
    if (notOnTrack) return this.selectCharacterAndMarkDeleted(options)

    if (isOnAdd) {
      const annotation = this.getAnnotationByStatus('add')
      const isOnLeftEdge = this.isOnLeftEdge(annotation)
      const isOnRightEdge = this.isOnRightEdge(annotation)
      const isFromSameUser = this.isAnnotationFromTheSameUser(annotation)
john's avatar
john committed
      const mode = this.getMode()
      const key = direction.key

john's avatar
john committed
      // use this variable to throw the flow to the isOnDelete handler underneath
      let pass = false

      // when on own additions, simply delete the character
      // unless it is on the edge: then make a deletion annotation

      // TODO -- watch it for different users
      if (
        (isOnLeftEdge && key === 'BACKSPACE') ||
        (isOnRightEdge && key === 'DELETE') ||
        (!isFromSameUser && !isOnDelete)
john's avatar
john committed
        if (mode) return this.selectCharacterAndMarkDeleted(options)
        pass = true
john's avatar
john committed
      if (!isFromSameUser && isOnDelete) pass = true
      if (!pass) return this.deleteCharacter(direction.move)
    }

    if (isOnDelete) {
      const annotation = this.getAnnotationByStatus('delete')
      const isOnLeftEdge = this.isOnLeftEdge(annotation)
      const isOnRightEdge = this.isOnRightEdge(annotation)
      const isFromSameUser = this.isAnnotationFromTheSameUser(annotation)
      const key = direction.key

      let moveOnly, point

      if (!isOnLeftEdge && !isOnRightEdge) {
        point = annotation[direction.cursorTo + 'Offset']
        moveOnly = true
      } else if (isOnLeftEdge && key === 'DELETE') {
        point = annotation.endOffset
        moveOnly = true
      } else if (isOnRightEdge && key === 'BACKSPACE') {
        point = annotation.startOffset
        moveOnly = true
      }

      if (moveOnly) return this.moveCursorTo(point)

      if (isFromSameUser) {
        return this.expandAnnotationToDirection(annotation, direction)
      } else {
        return this.selectCharacterAndMarkDeleted(options)
      }
  handleDeleteNonCollapsed (options) {
    const { direction } = options
    let { selection } = options

    const notOnTrack = this.isNotOnTrackAnnotation()
    const isOnDelete = this.isOnAnnotation('delete')

    if (notOnTrack) return this.markSelectionAsDeleted(options)

    const shortenBy = this.deleteAllOwnAdditions(selection)
    const startOffset = selection.startOffset
    const endOffset = selection.endOffset - shortenBy
    this.updateSelection(selection, startOffset, endOffset)

    // console.log('collapsed', selection.isCollapsed())
    if (selection.isCollapsed()) return this.handleDeleteCollapsed(options)

    if (isOnDelete) {
      // console.log
      const annotation = this.getAnnotationByStatus('delete')
      const containedWithin = this.isSelectionContainedWithin(annotation)

      if (containedWithin) {
        const point = annotation[direction.cursorTo + 'Offset']
        return this.moveCursorTo(point)
      }

      // const selection = this.getSelection()
      // this.expandTrackAnnotation(selection, annotation)
      //
      // const key = direction.key
      //
      // let point
      // if (key === 'BACKSPACE') point = selection.startOffset
      // if (key === 'DELETE') point = selection.endOffset
      // this.moveCursorTo(point)
    }

    options.selection = this.deleteOrMergeAllOwnDeletions(selection)
    // console.log(this.getSelection())
    // options.selection = this.getSelection()
    this.markSelectionAsDeleted(options)
  /*
    HANDLER COMMON FUNCTIONS
  */
  createAdditionAnnotationOnLastChar () {
    const selection = this.setSelectionPlusOne('left')
    this.createAddAnnotation(selection)
    this.moveCursorTo('end')
  }

  deleteAllOwnAdditions (selection) {
    const originalSelection = selection || this.getSelection()
    let shortenBy = 0

    const additions = this.getAllAnnotationsByStatus('add')
    const ownAdditions = filter(additions, annotation => {
      return this.isAnnotationFromTheSameUser(annotation)
    })

    each(ownAdditions, (annotation) => {
      const selection = annotation.getSelection()

      // make sure only the part of the annotation that is selected is deleted
      if (annotation.startOffset < originalSelection.startOffset) {
        selection.startOffset = originalSelection.startOffset
      if (annotation.endOffset > originalSelection.endOffset) {
        selection.endOffset = originalSelection.endOffset
      }

      shortenBy += (selection.endOffset - selection.startOffset)
      this.deleteSelection(selection)
    })

    return shortenBy    // return how much shorter the selection should now be
  deleteOrMergeAllOwnDeletions (selection) {
    const deletions = clone(this.getAllAnnotationsByStatus('delete'))
    const ownDeletions = filter(deletions, annotation => {
      return this.isAnnotationFromTheSameUser(annotation)
    })
john's avatar
john committed

    const selectionArray = [selection]
    each(ownDeletions, (annotation) => {
      const annotationSelection = annotation.getSelection()
      const contained = selection.contains(annotationSelection)
      if (!contained) {
        selectionArray.push(annotationSelection)
      }
      this.removeTrackAnnotation(annotation)
    })
    selection.startOffset = minBy(selectionArray, 'startOffset').startOffset
    selection.endOffset = maxBy(selectionArray, 'endOffset').endOffset
    return selection
    // TODO
    // this.updateSelection(selection, startOffset, endOffset)
  }
  deleteSelectedAndCreateAddition (options) {
    let { selection } = options
    this.createDeleteAnnotation(selection)
    this.moveCursorTo('end', selection)
    // selection is now collapsed, so handle it as collapsed
    this.handleAddCollapsed(options)
  }
  expandAnnotationToDirection (annotation, options) {
    if (!options) options = {}
    const move = options.move || 'left'
    const cursorTo = options.cursorTo || 'end'
    const selection = this.setSelectionPlusOne(move)
    this.expandTrackAnnotation(selection, annotation)
    this.moveCursorTo(cursorTo)
  }
  insertCharacterWithAddAnnotation (options) {
    const { event } = options
    this.insertText(event)
    // TODO -- watch it with additions by other users
    this.createAdditionAnnotationOnLastChar()
  }
  insertCharacterWithoutExpandingAnnotation (annotation, options) {
    const { event } = options
    let selection = options
    this.insertText(event)
    selection = this.setSelectionPlusOne('left')
    this.truncateTrackAnnotation(selection, annotation)
    this.moveCursorTo('end')
  markSelectionAsDeleted (options) {
    const { direction, selection } = options
    this.createDeleteAnnotation(selection)
    this.moveCursorTo(direction.cursorTo)
  }
  selectCharacterAndMarkDeleted (options) {
    const { direction } = options
    const selection = this.setSelectionPlusOne(direction.move)
    this.createDeleteAnnotation(selection)
    this.moveCursorTo(direction.cursorTo)
john's avatar
john committed
  createAddAnnotation (selection) {
    this.createTrackAnnotation(selection, 'add')
  }

  createDeleteAnnotation (selection) {
    this.createTrackAnnotation(selection, 'delete')
  }

  // TODO -- selection could default to current selection
john's avatar
john committed
  createTrackAnnotation (selection, status) {
    const surface = this.getSurface()
    const info = this.getInfo()
    const { user } = this.config
john's avatar
john committed

    if (selection.isContainerSelection()) {
      return console.warn('Cannot delete a container')
    }

    if (selection.isNodeSelection()) {
      if (selection.isCollapsed()) return this.deleteCharacter('left')
      return this.deleteSelection(selection)
    }

    const transformation = (tx, args) => {
john's avatar
john committed
      const newNode = {
        selection: selection,
        node: {
          status: status,
          type: 'track-change',
            roles: user.roles,
            username: user.username
          }
john's avatar
john committed
        }
      }
      createAnnotation(tx, newNode)
    }

    surface.transaction(transformation, info)
  deleteCharacter (direction) {
john's avatar
john committed
    const surface = this.getSurface()
    const info = { action: 'delete' }
john's avatar
john committed

    const transformation = (tx, args) => {
      args.direction = direction
      return deleteChar(tx, args)
    }

    surface.transaction(transformation, info)
  deleteSelection (selection) {
    const surface = this.getSurface()
    const info = { action: 'delete' }

    const transformation = (tx, args) => {
      args.selection = selection
      return deleteSel(tx, args)
    }

    surface.transaction(transformation, info)
  }

  expandTrackAnnotation (selection, annotation) {
john's avatar
john committed
    const surface = this.getSurface()
    const info = this.getInfo()
    const transformation = (tx, args) => {
      args.selection = selection
      args.anno = annotation

      expandAnnotation(tx, args)
    }

    surface.transaction(transformation, info)
john's avatar
john committed
    const surface = this.getSurface()
    surface.transaction(function (tx, args) {
      if (surface.domSelection) surface.domSelection.clear()
john's avatar
john committed
      args.text = event.data || ' '    // if no data, it's a space key
      return surface.insertText(tx, args)
    }, { action: 'type' })
  removeTrackAnnotation (annotation) {
    const surface = this.getSurface()

    const transformation = (tx, args) => {
      args.nodeId = annotation.id
      deleteNode(tx, args)
    surface.transaction(transformation)
  truncateTrackAnnotation (selection, annotation) {
    const info = this.getInfo()
      args.anno = annotation
      truncateAnnotation(tx, args)
  /*

    ACCEPT / REJECT

  */

  resolve (annotation, action) {
    const next = this.getNext(annotation)
    const selection = annotation.getSelection()
    const status = annotation.status

    this.removeTrackAnnotation(annotation)

    if (
      (action === 'accept' && status === 'delete') ||
      (action === 'reject' && status === 'add')
    ) {
      console.log('should delete')
      this.deleteSelection(selection)
    }

    this.focus(next)
  }

  computeEntries () {
    const doc = this.getDocument()
    const nodes = doc.getNodes()
    const changes = pickBy(nodes, node => {
      return node.type === 'track-change'
    })

    const entries = this.sortNodes(changes)
    return entries
  }

  reComputeEntries () {
    this.entries = this.computeEntries()
  }

  sortNodes (nodes) {
    const changes = clone(nodes)
    const doc = this.getDocument()
    const container = doc.get('body')

    const changesArray = map(changes, annotation => {
      const blockId = annotation.path[0]
      const blockPosition = container.getPosition(blockId)
      const nodePosition = annotation.startOffset

      return {
        id: annotation.id,
        blockPosition: blockPosition,
        nodePosition: nodePosition,
        node: annotation
      }
    })

    return sortBy(changesArray, ['blockPosition', 'nodePosition'])
  }

  getNext (annotation) {
    const entries = this.entries
    if (entries.length <= 1) return

    const position = findIndex(entries, change => {
      return change.node.id === annotation.id
    })
    if (position === -1) return
    const next = position + 1

    if (next >= entries.length) return entries[0].node
    return entries[next].node
  }

  focus (annotation) {
    if (!annotation) return
    const surface = this.getSurface()
    const { controller } = this.config

    controller.scrollTo(annotation.id)

    const selection = annotation.getSelection()
    surface.setSelection(selection)
    this.moveCursorTo('start')
  canAct () {
    const { user } = this.config
    const accepted = ['admin', 'production-editor', 'copy-editor']
    return some(accepted, (role) => includes(user.roles, role))
  }

john's avatar
john committed
  /*

    ANNOTATION HELPERS

  */

john's avatar
john committed
    const annotations = this.getAllExistingTrackAnnotations()
john's avatar
john committed
    const annotationsByStatus = filter(annotations, (annotation) => {
john's avatar
john committed
      return annotation.status === status
    })
    return annotationsByStatus
  }

  // TODO -- handle multiple delete and add annotations
  getAnnotationByStatus (status) {
    const annotationsForStatus = this.getAllAnnotationsByStatus(status)
    return annotationsForStatus[0]
  // getExistingAnnotation () {
  //   const documentSession = this.getDocumentSession()
  //   const selectionState = documentSession.getSelectionState()
  //   const annotations = selectionState.getAnnotationsForType('track-change')
  //   return annotations[0]
  // }
john's avatar
john committed

  getAllExistingTrackAnnotations () {
    const documentSession = this.getDocumentSession()
    const selectionState = documentSession.getSelectionState()
    const annotations = selectionState.getAnnotationsForType('track-change')
    return annotations
  }

  getAnnotationUser (annotation) {
    return annotation.user.id
  }

  // getDistinctAnnotationsForSelection () {
  //   const selection = this.getSelection()
  //   const annotations = this.getAllExistingTrackAnnotations()
  //
  //   console.log(selection)
  //   console.log(annotations)
  //
  //   console.log(selection.getFragments())
  //
  //   // get all annotations by status (add / delete)
  //   // get all pieces of free text
  // }

  // prevent substance from running getBoundingRectangle,
  // as we will unset the selection manually
  getInfo () {
    return { skipSelection: true }
  }

  isAnnotationFromTheSameUser (annotation) {
    const annotationUser = this.getAnnotationUser(annotation)
    const currentUser = this.getCurrentUser()

    if (annotationUser === currentUser) return true
    return false
  }

  isNotOnTrackAnnotation () {
    const annotations = this.getAllExistingTrackAnnotations()
    return (annotations.length === 0)
  }

john's avatar
john committed
  // returns whether the selection is on an add / delete tracked change
  isOnAnnotation (status) {
    const annotations = this.getAllExistingTrackAnnotations()
    const annotation = find(annotations, (annotation) => {
      return annotation.status === status
    })

    if (!annotation) return false
    return true
  }

  isOnLeftEdge (annotation) {
    const selection = this.getSelection()
    return (selection.startOffset === annotation.startOffset)
  }

  isOnRightEdge (annotation) {
    const selection = this.getSelection()
    return (selection.endOffset === annotation.endOffset)
  }

john's avatar
john committed
  /**

    HISTORY HANDLERS

  */

  // TODO -- shouldn't run both all the time
  handleUndoRedo (update, info) {
    if (!info.replay) return
    // console.log('update', update)
    // console.log('info', info)
    // this.handleUndo(update)
    // this.handleRedo(update)
  }

  handleUndo (update) {
    const deleted = update.change.deleted
    const deletedLength = keys(deleted).length

    // console.log(keys(deleted))

    if (deletedLength === 0) return
    if (deletedLength > 1) {
      return console.warn('FIXME: Multiple operations in track changes replay!')
    }

    const deletedOp = deleted[keys(deleted)[0]]
    if (!deletedOp.type === 'track-change') return

    const documentSession = this.getDocumentSession()
    documentSession.undo()
  handleRedo () {
    const documentSession = this.getDocumentSession()
    const undoneChanges = documentSession.undoneChanges
    const lastChange = last(undoneChanges)
    const op = last(lastChange.ops)

    const isTrack = op.path[0].split('-').slice(0, -1).join('-') === 'track-change'
    console.log(isTrack)
john's avatar
john committed
  /*

    SELECTION HANDLERS

  */

  isAnnotationContainedWithinSelection (annotation, strict) {
    const selection = this.getSelection()
    const annotationSelection = annotation.getSelection()
john's avatar
john committed

    return selection.contains(annotationSelection, strict)
  isSelectionCollapsed () {
john's avatar
john committed
    const selection = this.getSelection()
    const isCollapsed = selection.isCollapsed()
    return isCollapsed
  }
john's avatar
john committed

  // TODO -- refactor this and isAnnotationContainedWithinSelection into one
  isSelectionContainedWithin (annotation, strict) {
    const selection = this.getSelection()
    // console.trace()
    const annotationSelection = annotation.getSelection()
john's avatar
john committed

    return annotationSelection.contains(selection, strict)

    // const leftSide = (selection.startOffset < annotation.startOffset)
    // const rightSide = (selection.endOffset > annotation.endOffset)
    //
    // if (leftSide || rightSide) return false
    // return true
  moveCursorTo (point, sel) {
    const selection = sel || this.getSelection()
john's avatar
john committed
    const surface = this.getSurface()

    // TODO -- use substance's selection.collapse(direction)
john's avatar
john committed
    if (point === 'start') {
      selection.endOffset = selection.startOffset
    } else if (point === 'end') {
      selection.startOffset = selection.endOffset
    } else {
      selection.startOffset = point
      selection.endOffset = point
    }

    surface.setSelection(selection)
  }
  setSelectionPlusOne (direction) {
    if (direction === 'left') selection.startOffset -= 1
    if (direction === 'right') selection.endOffset += 1
  updateSelection (selection, startOffset, endOffset) {
    const surface = this.getSurface()
    selection.startOffset = startOffset
    selection.endOffset = endOffset
    surface.setSelection(selection)
    return selection
  getCommandManager () {
    return this.config.commandManager
  }

  getCurrentUser () {
    return this.config.user.id
  }

john's avatar
john committed
  getDocumentSession () {
    return this.config.documentSession
  getMode () {
    const trackState = this.getTrackState()
    return trackState.mode
  }

john's avatar
john committed
  getSelection () {
    const surface = this.getSurface()
    return surface.getSelection()
  }

  getSurface () {
    const surfaceManager = this.config.surfaceManager
    const id = this.config.containerId

    return surfaceManager.getSurface(id)
  }

  getTrackState () {
    const commandManager = this.getCommandManager()
    const commandStates = commandManager.getCommandStates()
    return commandStates['track-change']
  }
TOCProvider.tocTypes = ['track-change']

export default TrackChangesProvider