import { clone, each, filter, find, findIndex, includes, keys, last, map, maxBy, minBy, pickBy, some, sortBy } from 'lodash' import { createAnnotation, deleteCharacter as deleteChar, deleteNode, deleteSelection as deleteSel, expandAnnotation, truncateAnnotation, TOCProvider } 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) } /* 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) 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() } 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 // 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) const mode = this.getMode() const key = direction.key // 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) ) { if (mode) return this.selectCharacterAndMarkDeleted(options) pass = true } 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) }) 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') 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') } if (selection.isNodeSelection()) { if (selection.isCollapsed()) return this.deleteCharacter('left') return this.deleteSelection(selection) } const transformation = (tx, args) => { const newNode = { selection: selection, node: { status: status, type: 'track-change', user: { id: user.id, 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) } /* 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)) } /* ANNOTATION HELPERS */ 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 } 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) } // 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', 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) } /* 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 */ getCommandManager () { return this.config.commandManager } getCurrentUser () { return this.config.user.id } getDocumentSession () { return this.config.documentSession } getMode () { const trackState = this.getTrackState() return trackState.mode } 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