diff --git a/wax-prosemirror-services/src/TrackChangeService/track-changes/helpers/addMarkStep.js b/wax-prosemirror-services/src/TrackChangeService/track-changes/helpers/addMarkStep.js new file mode 100644 index 0000000000000000000000000000000000000000..f5031d00cdf72939c2696a74cec017ace1e5683f --- /dev/null +++ b/wax-prosemirror-services/src/TrackChangeService/track-changes/helpers/addMarkStep.js @@ -0,0 +1,60 @@ +const addMarkStep = (state, tr, step, newTr, map, doc, user, date) => { + doc.nodesBetween(step.from, step.to, (node, pos) => { + if (!node.isInline) { + return true; + } + if (node.marks.find(mark => mark.type.name === "deletion")) { + return false; + } else { + newTr.addMark( + Math.max(step.from, pos), + Math.min(step.to, pos + node.nodeSize), + step.mark + ); + } + if ( + ["em", "strong", "underline"].includes(step.mark.type.name) && + !node.marks.find(mark => mark.type === step.mark.type) + ) { + const formatChangeMark = node.marks.find( + mark => mark.type.name === "format_change" + ); + let after, before; + if (formatChangeMark) { + if (formatChangeMark.attrs.before.includes(step.mark.type.name)) { + before = formatChangeMark.attrs.before.filter( + markName => markName !== step.mark.type.name + ); + after = formatChangeMark.attrs.after; + } else { + before = formatChangeMark.attrs.before; + after = formatChangeMark.attrs.after.concat(step.mark.type.name); + } + } else { + before = []; + after = [step.mark.type.name]; + } + if (after.length || before.length) { + newTr.addMark( + Math.max(step.from, pos), + Math.min(step.to, pos + node.nodeSize), + state.schema.marks.format_change.create({ + user: user.userId, + username: user.username, + date, + before, + after + }) + ); + } else if (formatChangeMark) { + newTr.removeMark( + Math.max(step.from, pos), + Math.min(step.to, pos + node.nodeSize), + formatChangeMark + ); + } + } + }); +}; + +export default addMarkStep; diff --git a/wax-prosemirror-services/src/TrackChangeService/track-changes/helpers/markDeletion.js b/wax-prosemirror-services/src/TrackChangeService/track-changes/helpers/markDeletion.js new file mode 100644 index 0000000000000000000000000000000000000000..b4c76ef12c7d93330e415335a38d6245fc0fdd3f --- /dev/null +++ b/wax-prosemirror-services/src/TrackChangeService/track-changes/helpers/markDeletion.js @@ -0,0 +1,102 @@ +import { Selection, TextSelection } from "prosemirror-state"; +import { Slice } from "prosemirror-model"; +import { ReplaceStep, Mapping } from "prosemirror-transform"; + +const markDeletion = (tr, from, to, user, date) => { + const deletionMark = tr.doc.type.schema.marks.deletion.create({ + user: user.userId, + username: user.username, + date + }); + let firstTableCellChild = false; + const deletionMap = new Mapping(); + // Add deletion mark to block nodes (figures, text blocks) and find already deleted inline nodes (and leave them alone) + tr.doc.nodesBetween(from, to, (node, pos) => { + if (pos < from && node.type.name === "table_cell") { + firstTableCellChild = true; + return true; + } else if ((pos < from && node.isBlock) || firstTableCellChild) { + firstTableCellChild = false; + return true; + } else if (["table_row", "table_cell"].includes(node.type.name)) { + return false; + } else if ( + node.isInline && + node.marks.find( + mark => + mark.type.name === "insertion" && mark.attrs.user === user.userId + ) + ) { + const removeStep = new ReplaceStep( + deletionMap.map(Math.max(from, pos)), + deletionMap.map(Math.min(to, pos + node.nodeSize)), + Slice.empty + ); + if (!tr.maybeStep(removeStep).failed) { + deletionMap.appendMap(removeStep.getMap()); + } + } else if ( + node.isInline && + !node.marks.find(mark => mark.type.name === "deletion") + ) { + tr.addMark( + deletionMap.map(Math.max(from, pos)), + deletionMap.map(Math.min(to, pos + node.nodeSize)), + deletionMark + ); + } else if ( + node.attrs.track && + !node.attrs.track.find(trackAttr => trackAttr.type === "deletion") && + !["bullet_list", "ordered_list"].includes(node.type.name) + ) { + if ( + node.attrs.track.find( + trackAttr => + trackAttr.type === "insertion" && trackAttr.user === user.userId + ) + ) { + let removeStep; + // user has created element. so (s)he is allowed to delete it again. + if (node.isTextblock && to < pos + node.nodeSize) { + // The node is a textblock. So we need to merge into the last possible position inside the last text block. + const selectionBefore = Selection.findFrom(tr.doc.resolve(pos), -1); + if (selectionBefore instanceof TextSelection) { + removeStep = new ReplaceStep( + deletionMap.map(selectionBefore.$anchor.pos), + deletionMap.map(to), + Slice.empty + ); + } + } else { + removeStep = new ReplaceStep( + deletionMap.map(Math.max(from, pos)), + deletionMap.map(Math.min(to, pos + node.nodeSize)), + Slice.empty + ); + } + + if (!tr.maybeStep(removeStep).failed) { + deletionMap.appendMap(removeStep.getMap()); + } + } else { + const track = node.attrs.track.slice(); + track.push({ + type: "deletion", + user: user.userId, + username: user.username, + date + }); + tr.setNodeMarkup( + deletionMap.map(pos), + null, + Object.assign({}, node.attrs, { track }), + node.marks + ); + } + } + }); + + return deletionMap; +}; + +export default markDeletion; diff --git a/wax-prosemirror-services/src/TrackChangeService/track-changes/helpers/markInsertion.js b/wax-prosemirror-services/src/TrackChangeService/track-changes/helpers/markInsertion.js new file mode 100644 index 0000000000000000000000000000000000000000..7d06539058e62114dae60b7fd07996bd4c125afb --- /dev/null +++ b/wax-prosemirror-services/src/TrackChangeService/track-changes/helpers/markInsertion.js @@ -0,0 +1,47 @@ +const markInsertion = (tr, from, to, user, date) => { + tr.removeMark(from, to, tr.doc.type.schema.marks.deletion); + tr.removeMark(from, to, tr.doc.type.schema.marks.insertion); + const insertionMark = tr.doc.type.schema.marks.insertion.create({ + user: user.userId, + username: user.username, + date + }); + tr.addMark(from, to, insertionMark); + // Add insertion mark also to block nodes (figures, text blocks) but not table cells/rows and lists. + tr.doc.nodesBetween(from, to, (node, pos) => { + if ( + pos < from || + ["bullet_list", "ordered_list"].includes(node.type.name) + ) { + return true; + } else if ( + node.isInline || + ["table_row", "table_cell"].includes(node.type.name) + ) { + return false; + } + if (node.attrs.track) { + const track = []; + + track.push({ + type: "insertion", + user: user.userId, + username: user.username, + date + }); + + tr.setNodeMarkup( + pos, + null, + Object.assign({}, node.attrs, { track }), + node.marks + ); + } + if (node.type.name === "table") { + // A table was inserted. We don't add track marks to elements inside of it. + return false; + } + }); +}; + +export default markInsertion; diff --git a/wax-prosemirror-services/src/TrackChangeService/track-changes/helpers/markWrapping.js b/wax-prosemirror-services/src/TrackChangeService/track-changes/helpers/markWrapping.js new file mode 100644 index 0000000000000000000000000000000000000000..248cc97621204705d94cc1d47102c20fdcc1ccdf --- /dev/null +++ b/wax-prosemirror-services/src/TrackChangeService/track-changes/helpers/markWrapping.js @@ -0,0 +1,41 @@ +import { v4 as uuidv4 } from "uuid"; + +const markWrapping = (tr, pos, oldNode, newNode, user, date) => { + let track = oldNode.attrs.track.slice(), + blockTrack = track.find(track => track.type === "block_change"); + + if (blockTrack) { + track = track.filter(track => track !== blockTrack); + if ( + blockTrack.before.type !== newNode.type.name || + blockTrack.before.attrs.level !== newNode.attrs.level + ) { + blockTrack = { + type: "block_change", + user: user.id, + username: user.username, + date, + before: blockTrack.before + }; + track.push(blockTrack); + } + } else { + blockTrack = { + type: "block_change", + user: user.id, + username: user.username, + date, + before: { type: oldNode.type.name, attrs: oldNode.attrs } + }; + if (blockTrack.before.attrs.id) { + delete blockTrack.before.attrs.id; + } + if (blockTrack.before.attrs.track) { + delete blockTrack.before.attrs.track; + } + track.push(blockTrack); + } + tr.setNodeMarkup(pos, null, Object.assign({}, newNode.attrs, { track })); +}; + +export default markWrapping; diff --git a/wax-prosemirror-services/src/TrackChangeService/track-changes/helpers/removeMarkStep.js b/wax-prosemirror-services/src/TrackChangeService/track-changes/helpers/removeMarkStep.js new file mode 100644 index 0000000000000000000000000000000000000000..f53d6af877edd9d7a3fa6a0809e399759cfe71d9 --- /dev/null +++ b/wax-prosemirror-services/src/TrackChangeService/track-changes/helpers/removeMarkStep.js @@ -0,0 +1,61 @@ +const removeMarkStep = (state, tr, step, newTr, map, doc, user, date) => { + doc.nodesBetween(step.from, step.to, (node, pos) => { + if (!node.isInline) { + return true; + } + if (node.marks.find(mark => mark.type.name === "deletion")) { + return false; + } else { + newTr.removeMark( + Math.max(step.from, pos), + Math.min(step.to, pos + node.nodeSize), + step.mark + ); + } + + if ( + ["em", "strong", "underline"].includes(step.mark.type.name) && + node.marks.find(mark => mark.type === step.mark.type) + ) { + const formatChangeMark = node.marks.find( + mark => mark.type.name === "format_change" + ); + let after, before; + if (formatChangeMark) { + if (formatChangeMark.attrs.after.includes(step.mark.type.name)) { + after = formatChangeMark.attrs.after.filter( + markName => markName !== step.mark.type.name + ); + before = formatChangeMark.attrs.before; + } else { + after = formatChangeMark.attrs.after; + before = formatChangeMark.attrs.before.concat(step.mark.type.name); + } + } else { + after = []; + before = [step.mark.type.name]; + } + if (after.length || before.length) { + newTr.addMark( + Math.max(step.from, pos), + Math.min(step.to, pos + node.nodeSize), + state.schema.marks.format_change.create({ + user: user.userId, + username: user.username, + date, + before, + after + }) + ); + } else if (formatChangeMark) { + newTr.removeMark( + Math.max(step.from, pos), + Math.min(step.to, pos + node.nodeSize), + formatChangeMark + ); + } + } + }); +}; + +export default removeMarkStep; diff --git a/wax-prosemirror-services/src/TrackChangeService/track-changes/helpers/replaceAroundStep.js b/wax-prosemirror-services/src/TrackChangeService/track-changes/helpers/replaceAroundStep.js new file mode 100644 index 0000000000000000000000000000000000000000..f7eb19824f4469286694c8e443f41301aaacaf0d --- /dev/null +++ b/wax-prosemirror-services/src/TrackChangeService/track-changes/helpers/replaceAroundStep.js @@ -0,0 +1,57 @@ +import markDeletion from "./markDeletion"; +import markInsertion from "./markInsertion"; +import markWrapping from "./markWrapping"; + +const replaceAroundStep = (state, tr, step, newTr, map, doc, user, date) => { + if (step.from === step.gapFrom && step.to === step.gapTo) { + // wrapped in something + newTr.step(step); + const from = step.getMap().map(step.from, -1); + const to = step.getMap().map(step.gapFrom); + markInsertion(newTr, from, to, user, date); + } else if (!step.slice.size) { + // unwrapped from something + map.appendMap(step.invert(doc).getMap()); + map.appendMap(markDeletion(newTr, step.from, step.gapFrom, user, date)); + } else if ( + step.slice.size === 2 && + step.gapFrom - step.from === 1 && + step.to - step.gapTo === 1 + ) { + // Replaced one wrapping with another + newTr.step(step); + const oldNode = doc.nodeAt(step.from); + if (oldNode.attrs.track) { + markWrapping( + newTr, + step.from, + oldNode, + step.slice.content.firstChild, + user, + date + ); + } + } else { + newTr.step(step); + const ranges = [ + { + from: step.getMap().map(step.from, -1), + to: step.getMap().map(step.gapFrom) + }, + { + from: step.getMap().map(step.gapTo, -1), + to: step.getMap().map(step.to) + } + ]; + ranges.forEach(range => + doc.nodesBetween(range.from, range.to, (node, pos) => { + if (pos < range.from) { + return true; + } + markInsertion(newTr, range.from, range.to, user, date); + }) + ); + } +}; + +export default replaceAroundStep; diff --git a/wax-prosemirror-services/src/TrackChangeService/track-changes/helpers/replaceStep.js b/wax-prosemirror-services/src/TrackChangeService/track-changes/helpers/replaceStep.js new file mode 100644 index 0000000000000000000000000000000000000000..e0bfd55efbdbe50ef3d8215587167ad9b75f27f9 --- /dev/null +++ b/wax-prosemirror-services/src/TrackChangeService/track-changes/helpers/replaceStep.js @@ -0,0 +1,51 @@ +import { ReplaceStep } from "prosemirror-transform"; +import { CellSelection } from "prosemirror-tables"; + +import markDeletion from "./markDeletion"; +import markInsertion from "./markInsertion"; + +const replaceStep = (state, tr, step, newTr, map, doc, user, date) => { + // We only insert content if this is not directly a tr for cell deletion. This is because tables delete rows by deleting the + // contents of each cell and replacing it with an empty paragraph. + const cellDeleteTr = + ["deleteContentBackward", "deleteContentForward"].includes( + tr.getMeta("inputType") + ) && state.selection instanceof CellSelection; + + const newStep = !cellDeleteTr + ? new ReplaceStep( + step.to, // We insert all the same steps, but with "from"/"to" both set to "to" in order not to delete content. Mapped as needed. + step.to, + step.slice, + step.structure + ) + : false; + // We didn't apply the original step in its original place. We adjust the map accordingly. + map.appendMap(step.invert(doc).getMap()); + if (newStep) { + const trTemp = state.apply(newTr).tr; + if (trTemp.maybeStep(newStep).failed) { + return; + } + const mappedNewStepTo = newStep.getMap().map(newStep.to); + markInsertion(trTemp, newStep.from, mappedNewStepTo, user, date); + // We condense it down to a single replace step. + const condensedStep = new ReplaceStep( + newStep.from, + newStep.to, + trTemp.doc.slice(newStep.from, mappedNewStepTo) + ); + + newTr.step(condensedStep); + const mirrorIndex = map.maps.length - 1; + map.appendMap(condensedStep.getMap(), mirrorIndex); + if (!newTr.selection.eq(trTemp.selection)) { + newTr.setSelection(trTemp.selection); + } + } + if (step.from !== step.to) { + map.appendMap(markDeletion(newTr, step.from, step.to, user, date)); + } +}; + +export default replaceStep; diff --git a/wax-prosemirror-services/src/TrackChangeService/track-changes/trackedTransaction.js b/wax-prosemirror-services/src/TrackChangeService/track-changes/trackedTransaction.js index ce80335e46a03a96b655920868553ed70b5ee745..cf30f6c3daeeab3535edf6b4c33cba1c3ed4341e 100644 --- a/wax-prosemirror-services/src/TrackChangeService/track-changes/trackedTransaction.js +++ b/wax-prosemirror-services/src/TrackChangeService/track-changes/trackedTransaction.js @@ -5,7 +5,6 @@ License included in folder. */ import { Selection, TextSelection } from "prosemirror-state"; -import { Slice } from "prosemirror-model"; import { ReplaceStep, ReplaceAroundStep, @@ -13,11 +12,11 @@ import { RemoveMarkStep, Mapping } from "prosemirror-transform"; -import { CellSelection } from "prosemirror-tables"; -import markDeletion from "./markDeletion"; -import markInsertion from "./markInsertion"; -import markWrapping from "./markWrapping"; +import replaceStep from "./helpers/replaceStep"; +import replaceAroundStep from "./helpers/replaceAroundStep"; +import addMarkStep from "./helpers/addMarkStep"; +import removeMarkStep from "./helpers/removeMarkStep"; const trackedTransaction = (tr, state, user) => { if ( @@ -32,15 +31,9 @@ const trackedTransaction = (tr, state, user) => { return tr; } - const newTr = state.tr, - map = new Mapping(), - date = Math.floor(Date.now() / 60000), // 1 minute interval - // We only insert content if this is not directly a tr for cell deletion. This is because tables delete rows by deleting the - // contents of each cell and replacing it with an empty paragraph. - cellDeleteTr = - ["deleteContentBackward", "deleteContentForward"].includes( - tr.getMeta("inputType") - ) && state.selection instanceof CellSelection; + const newTr = state.tr; + const map = new Mapping(); + const date = Math.floor(Date.now() / 60000); tr.steps.forEach(originalStep => { const step = originalStep.map(map), @@ -50,215 +43,19 @@ const trackedTransaction = (tr, state, user) => { } if (step instanceof ReplaceStep) { - const newStep = !cellDeleteTr - ? new ReplaceStep( - step.to, // We insert all the same steps, but with "from"/"to" both set to "to" in order not to delete content. Mapped as needed. - step.to, - step.slice, - step.structure - ) - : false; - - // We didn't apply the original step in its original place. We adjust the map accordingly. - map.appendMap(step.invert(doc).getMap()); - if (newStep) { - const trTemp = state.apply(newTr).tr; - if (trTemp.maybeStep(newStep).failed) { - return; - } - - const mappedNewStepTo = newStep.getMap().map(newStep.to); - markInsertion(trTemp, newStep.from, mappedNewStepTo, user, date); - // We condense it down to a single replace step. - const condensedStep = new ReplaceStep( - newStep.from, - newStep.to, - trTemp.doc.slice(newStep.from, mappedNewStepTo) - ); - - newTr.step(condensedStep); - const mirrorIndex = map.maps.length - 1; - map.appendMap(condensedStep.getMap(), mirrorIndex); - if (!newTr.selection.eq(trTemp.selection)) { - newTr.setSelection(trTemp.selection); - } - } - if (step.from !== step.to) { - map.appendMap(markDeletion(newTr, step.from, step.to, user, date)); - } + replaceStep(state, tr, step, newTr, map, doc, user, date); } if (step instanceof ReplaceAroundStep) { - if (step.from === step.gapFrom && step.to === step.gapTo) { - // wrapped in something - newTr.step(step); - const from = step.getMap().map(step.from, -1); - const to = step.getMap().map(step.gapFrom); - markInsertion(newTr, from, to, user, date); - } else if (!step.slice.size) { - // unwrapped from something - map.appendMap(step.invert(doc).getMap()); - map.appendMap(markDeletion(newTr, step.from, step.gapFrom, user, date)); - } else if ( - step.slice.size === 2 && - step.gapFrom - step.from === 1 && - step.to - step.gapTo === 1 - ) { - // Replaced one wrapping with another - newTr.step(step); - const oldNode = doc.nodeAt(step.from); - if (oldNode.attrs.track) { - markWrapping( - newTr, - step.from, - oldNode, - step.slice.content.firstChild, - user, - date - ); - } - } else { - newTr.step(step); - const ranges = [ - { - from: step.getMap().map(step.from, -1), - to: step.getMap().map(step.gapFrom) - }, - { - from: step.getMap().map(step.gapTo, -1), - to: step.getMap().map(step.to) - } - ]; - ranges.forEach(range => - doc.nodesBetween(range.from, range.to, (node, pos) => { - if (pos < range.from) { - return true; - } - markInsertion(newTr, range.from, range.to, user, date); - }) - ); - } + replaceAroundStep(state, tr, step, newTr, map, doc, user, date); } if (step instanceof AddMarkStep) { - doc.nodesBetween(step.from, step.to, (node, pos) => { - if (!node.isInline) { - return true; - } - if (node.marks.find(mark => mark.type.name === "deletion")) { - return false; - } else { - newTr.addMark( - Math.max(step.from, pos), - Math.min(step.to, pos + node.nodeSize), - step.mark - ); - } - if ( - ["em", "strong", "underline"].includes(step.mark.type.name) && - !node.marks.find(mark => mark.type === step.mark.type) - ) { - const formatChangeMark = node.marks.find( - mark => mark.type.name === "format_change" - ); - let after, before; - if (formatChangeMark) { - if (formatChangeMark.attrs.before.includes(step.mark.type.name)) { - before = formatChangeMark.attrs.before.filter( - markName => markName !== step.mark.type.name - ); - after = formatChangeMark.attrs.after; - } else { - before = formatChangeMark.attrs.before; - after = formatChangeMark.attrs.after.concat(step.mark.type.name); - } - } else { - before = []; - after = [step.mark.type.name]; - } - if (after.length || before.length) { - newTr.addMark( - Math.max(step.from, pos), - Math.min(step.to, pos + node.nodeSize), - state.schema.marks.format_change.create({ - user: user.userId, - username: user.username, - date, - before, - after - }) - ); - } else if (formatChangeMark) { - newTr.removeMark( - Math.max(step.from, pos), - Math.min(step.to, pos + node.nodeSize), - formatChangeMark - ); - } - } - }); + addMarkStep(state, tr, step, newTr, map, doc, user, date); } if (step instanceof RemoveMarkStep) { - doc.nodesBetween(step.from, step.to, (node, pos) => { - if (!node.isInline) { - return true; - } - if (node.marks.find(mark => mark.type.name === "deletion")) { - return false; - } else { - newTr.removeMark( - Math.max(step.from, pos), - Math.min(step.to, pos + node.nodeSize), - step.mark - ); - } - - if ( - ["em", "strong", "underline"].includes(step.mark.type.name) && - node.marks.find(mark => mark.type === step.mark.type) - ) { - const formatChangeMark = node.marks.find( - mark => mark.type.name === "format_change" - ); - let after, before; - if (formatChangeMark) { - if (formatChangeMark.attrs.after.includes(step.mark.type.name)) { - after = formatChangeMark.attrs.after.filter( - markName => markName !== step.mark.type.name - ); - before = formatChangeMark.attrs.before; - } else { - after = formatChangeMark.attrs.after; - before = formatChangeMark.attrs.before.concat( - step.mark.type.name - ); - } - } else { - after = []; - before = [step.mark.type.name]; - } - if (after.length || before.length) { - newTr.addMark( - Math.max(step.from, pos), - Math.min(step.to, pos + node.nodeSize), - state.schema.marks.format_change.create({ - user: user.userId, - username: user.username, - date, - before, - after - }) - ); - } else if (formatChangeMark) { - newTr.removeMark( - Math.max(step.from, pos), - Math.min(step.to, pos + node.nodeSize), - formatChangeMark - ); - } - } - }); + removeMarkStep(state, tr, step, newTr, map, doc, user, date); } });