import { clone, each, filter, find, keys, last, maxBy, minBy } from 'lodash' import { createAnnotation, deleteCharacter as deleteChar, deleteNode, deleteSelection as deleteSel, expandAnnotation, truncateAnnotation } from 'substance' class TrackChangesProvider { constructor (config) { this.config = config this.config.documentSession.on('didUpdate', this.handleUndoRedo, this) } /* HANDLERS */ handleTransaction (options) { options.selection = this.getSelection() this.chooseHanlder(options) } chooseHanlder (options) { if (options.status === 'add') return this.handleAdd(options) if (options.status === 'delete') return this.handleDelete(options) } handleAdd (options) { 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) if (isOnAdd) { // annotation gets expanded automatically, unless the selection is on its left edge const annotation = this.getAnnotationByStatus('add') const isOnLeftEdge = this.isOnLeftEdge(annotation) this.insertText(event) if (isOnLeftEdge) this.expandAnnotationToDirection(annotation) return } 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 if (selection.endOffset > selection.startOffset) { return this.deleteSelectedAndCreateAddition(options) } // 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) { console.log('delete collapsed on add') const annotation = this.getAnnotationByStatus('add') const isOnLeftEdge = this.isOnLeftEdge(annotation) const isOnRightEdge = this.isOnRightEdge(annotation) const key = direction.key // 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') ) { return this.selectCharacterAndMarkDeleted(options) } return this.deleteCharacter(direction.move) } if (isOnDelete) { const annotation = this.getAnnotationByStatus('delete') const isOnLeftEdge = this.isOnLeftEdge(annotation) const isOnRightEdge = this.isOnRightEdge(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) return this.expandAnnotationToDirection(annotation, direction) } } 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) { // TODO -- for same user const additions = this.getAllAnnotationsByStatus('add') const originalSelection = selection || this.getSelection() let shortenBy = 0 each(additions, (annotation) => { const selection = annotation.getSelection() // make sure only part of 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')) // filter by own deletions console.log(selection, deletions) // const originalSelection = clone(selection) const selectionArray = [selection] each(deletions, (annotation) => { const annotationSelection = annotation.getSelection() const contained = selection.contains(annotationSelection) if (!contained) { selectionArray.push(annotationSelection) } console.log('fkdjsjf') 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') options.event = null } 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) } /* TRANSFORMATIONS */ createAddAnnotation (selection) { this.createTrackAnnotation(selection, 'add') } createDeleteAnnotation (selection) { this.createTrackAnnotation(selection, 'delete') } // TODO -- selection could default to current selection createTrackAnnotation (selection, status) { const surface = this.getSurface() const info = this.getInfo() const { user } = this.config if (selection.isContainerSelection()) { return console.warn('Cannot delete a container') } const transformation = (tx, args) => { const newNode = { selection: selection, node: { status: status, type: 'track-change', user: { roles: user.roles, username: user.username } } } createAnnotation(tx, newNode) } surface.transaction(transformation, info) } deleteCharacter (direction) { const surface = this.getSurface() const info = { action: 'delete' } 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) { 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) } insertText (event) { if (!event) return const surface = this.getSurface() surface.transaction(function (tx, args) { if (surface.domSelection) surface.domSelection.clear() 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 surface = this.getSurface() const info = this.getInfo() const transformation = (tx, args) => { args.anno = annotation args.selection = selection truncateAnnotation(tx, args) } surface.transaction(transformation, info) } /* ANNOTATION HELPERS */ // is annotation from the same user getAllAnnotationsByStatus (status) { const annotations = this.getAllExistingTrackAnnotations() const annotationsByStatus = filter(annotations, (annotation) => { 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] // } getAllExistingTrackAnnotations () { const documentSession = this.getDocumentSession() const selectionState = documentSession.getSelectionState() const annotations = selectionState.getAnnotationsForType('track-change') return annotations } // 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 } } isNotOnTrackAnnotation () { const annotations = this.getAllExistingTrackAnnotations() return (annotations.length === 0) } // 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) } /** HISTORY HANDLERS */ // TODO -- shouldn't run both all the time handleUndoRedo (update, info) { if (!info.replay) return // console.log(update) // console.log(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) } /* SELECTION HANDLERS */ isAnnotationContainedWithinSelection (annotation, strict) { const selection = this.getSelection() const annotationSelection = annotation.getSelection() return selection.contains(annotationSelection, strict) } isSelectionCollapsed () { const selection = this.getSelection() const isCollapsed = selection.isCollapsed() return isCollapsed } // TODO -- refactor this and isAnnotationContainedWithinSelection into one isSelectionContainedWithin (annotation, strict) { const selection = this.getSelection() // console.trace() const annotationSelection = annotation.getSelection() 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() const surface = this.getSurface() // TODO -- use substance's selection.collapse(direction) 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) { const selection = this.getSelection() if (direction === 'left') selection.startOffset -= 1 if (direction === 'right') selection.endOffset += 1 return selection } updateSelection (selection, startOffset, endOffset) { const surface = this.getSurface() selection.startOffset = startOffset selection.endOffset = endOffset surface.setSelection(selection) return selection } /* GETTERS */ getDocumentSession () { return this.config.documentSession } getSelection () { const surface = this.getSurface() return surface.getSelection() } getSurface () { const surfaceManager = this.config.surfaceManager const id = this.config.containerId return surfaceManager.getSurface(id) } } export default TrackChangesProvider