Commit d599da8c authored by Christos's avatar Christos

Merge branch 'track-changes' into 'master'

Track changes

See merge request !21
parents 42a62ec1 c8cd5ca5
import React, { Component } from "react";
import styled, { createGlobalStyle } from "styled-components";
import {
orderedList,
bulletList,
listItem,
wrapInList,
splitListItem,
liftListItem,
......@@ -26,28 +23,13 @@ import invisibles, {
import { Wax, CreateSchema, CreateShortCuts } from "wax-prosemirror-core";
import { EditoriaSchema } from "wax-prosemirror-schema";
import { LinkToolTipPlugin } from "wax-prosemirror-plugins";
import { LinkToolTipPlugin, TrackChangePlugin } from "wax-prosemirror-plugins";
import { MainMenuBar, SideMenuBar } from "wax-prosemirror-components";
import "wax-prosemirror-layouts/layouts/editoria-layout.css";
import "wax-prosemirror-layouts/vars/wax-editoria-vars.css";
import "wax-prosemirror-themes/themes/editoria-theme.css";
const extraNodes = {
ordered_list: {
...orderedList,
content: "list_item+",
group: "block"
},
bullet_list: {
...bulletList,
content: "list_item+",
group: "block"
},
list_item: {
...listItem,
content: "paragraph block*",
group: "block"
},
...tableNodes({
tableGroup: "block",
cellContent: "block+"
......@@ -61,6 +43,7 @@ const plugins = [
columnResizing(),
tableEditing(),
// LinkToolTipPlugin,
TrackChangePlugin({ options: {} }),
invisibles([hardBreak()])
];
......@@ -111,6 +94,13 @@ const renderImage = file => {
});
};
const user = {
userId: "1234",
username: "demo"
};
// USE FOR TRACK TEST
const text = `<ul><li><p class="paragraph">this is the li content</p></li><li><p class="paragraph">And another</p></li></ul><h1>this is a title</h1><p class="paragraph" data-track="[{&quot;type&quot;:&quot;block_change&quot;,&quot;user&quot;:&quot;editor.user.id&quot;,&quot;username&quot;:&quot;editor.user.username&quot;,&quot;date&quot;:26069447,&quot;before&quot;:{&quot;type&quot;:&quot;author&quot;,&quot;attrs&quot;:{&quot;class&quot;:&quot;author&quot;}}}]">Lorem Ipsum is simply dummy text of the printing and typesetting industry. Lorem Ipsum has been the industry's standard dummy text ever since the 1500s, when an unknown printer took a galley of type and scrambled it to make a type specimen book. It has survived not only five centuries, but also the leap into electronic typesetting, remaining essentially unchanged. It was popularised in the 1960s with the release of Letraset sheets containing Lorem Ipsum passages, and more recently with desktop publishing software like Aldus PageMaker including versions of Lorem Ipsum.</p><p class="author">Lorem Ipsum is simply dummy text of the printing and typesetting industry. Lorem Ipsum has been the industry's standard dummy text ever since the 1500s, when an unknown printer took a galley of type and scrambled it to make a type specimen book. It has survived not only five centuries, but also the leap into electronic typesetting, remaining essentially unchanged. It was popularised in the 1960s with the release of Letraset sheets containing Lorem Ipsum passages, and more recently with desktop publishing software like Aldus PageMaker including versions of Lorem Ipsum.</p>`;
class Editoria extends Component {
render() {
return (
......@@ -123,7 +113,9 @@ class Editoria extends Component {
theme="editoria"
layout="editoria"
fileUpload={file => renderImage(file)}
debug
value=""
user={user}
>
{({ editor, view, ...props }) => (
<React.Fragment>
......
......@@ -118,19 +118,19 @@ export default {
select: state => true,
menu: props => <Button key={uuid()} {...props} />
},
small_caps: {
title: "Toggle Small Caps",
content: icons.small_caps,
active: state => {
return markActive(state.config.schema.marks.small_caps)(state);
},
run(state, dispatch) {
toggleMark(state.config.schema.marks.small_caps)(state, dispatch);
},
select: state => true,
menu: props => <Button key={uuid()} {...props} />
},
// small_caps: {
// title: "Toggle Small Caps",
// content: icons.small_caps,
// active: state => {
// return markActive(state.config.schema.marks.small_caps)(state);
// },
// run(state, dispatch) {
// toggleMark(state.config.schema.marks.small_caps)(state, dispatch);
// },
//
// select: state => true,
// menu: props => <Button key={uuid()} {...props} />
// },
subscript: {
title: "Toggle subscript",
content: icons.subscript,
......
......@@ -93,9 +93,10 @@ export default {
title: "Change to heading level 1",
content: "Heading 1",
enable: state => {
return setBlockType(state.config.schema.nodes.heading, { level: 1 })(
state
);
return setBlockType(state.config.schema.nodes.heading, {
level: 1,
track: []
})(state);
},
run(state, dispatch) {
setBlockType(state.config.schema.nodes.heading, { level: 1 })(
......@@ -110,9 +111,10 @@ export default {
title: "Change to heading level 2",
content: "Heading 2",
enable: state => {
return setBlockType(state.config.schema.nodes.heading, { level: 2 })(
state
);
return setBlockType(state.config.schema.nodes.heading, {
level: 2,
track: []
})(state);
},
run(state, dispatch) {
setBlockType(state.config.schema.nodes.heading, { level: 2 })(
......@@ -127,9 +129,10 @@ export default {
title: "Change to heading level 3",
content: "Heading 3",
enable: state => {
return setBlockType(state.config.schema.nodes.heading, { level: 3 })(
state
);
return setBlockType(state.config.schema.nodes.heading, {
level: 3,
track: []
})(state);
},
run(state, dispatch) {
setBlockType(state.config.schema.nodes.heading, { level: 3 })(
......
......@@ -82,7 +82,9 @@ class Wax extends Component {
onBlur,
layout,
theme,
debug
debug,
TrackChange,
user
} = this.props;
const defaultRender = ({ editor, state, dispatch, fileUpload }) => (
......@@ -105,6 +107,8 @@ class Wax extends Component {
onBlur={onBlur || (value => true)}
onChange={this.onChange || (value => true)}
debug={debug}
TrackChange={TrackChange}
user={user}
>
{WaxRender}
</WaxView>
......
......@@ -7,6 +7,7 @@ import { EditorView } from "prosemirror-view";
import placeholderPlugin from "./config/plugins/placeholderPlugin";
import "prosemirror-view/style/prosemirror.css";
import "prosemirror-gapcursor/style/gapcursor.css";
import trackedTransaction from "./config/track-changes/trackedTransaction";
class WaxView extends Component {
constructor(props) {
......@@ -93,7 +94,11 @@ class WaxView extends Component {
};
dispatchTransaction = transaction => {
const state = this.view.state.apply(transaction);
const { TrackChange } = this.props;
const tr = TrackChange
? trackedTransaction(transaction, this.view.state, this)
: transaction;
const state = this.view.state.apply(tr);
this.view.updateState(state);
this.props.onChange(state.doc.content);
this.forceUpdate();
......
import { Selection, TextSelection } from "prosemirror-state";
import { Slice } from "prosemirror-model";
import { ReplaceStep, Mapping } from "prosemirror-transform";
const markDeletion = (tr, from, to, user, username, date1, date10) => {
const deletionMark = tr.doc.type.schema.marks.deletion.create({
user,
username,
date: date10
});
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 &&
!mark.attrs.approved
)
) {
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
)
) {
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, username, date: date1 });
tr.setNodeMarkup(
deletionMap.map(pos),
null,
Object.assign({}, node.attrs, { track }),
node.marks
);
}
}
});
return deletionMap;
};
export default markDeletion;
const markInsertion = (
tr,
from,
to,
user,
username,
date1,
date10,
approved
) => {
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,
username,
date: date10,
approved
});
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 = [];
if (!approved) {
track.push({ type: "insertion", user, username, date: date1 });
}
tr.setNodeMarkup(
pos,
null,
Object.assign({}, node.attrs, { track }),
node.marks
);
}
if (node.type.name === "table") {
return false;
}
});
};
export default markInsertion;
const markWrapping = (tr, pos, oldNode, newNode, user, username, date1) => {
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,
username,
date: date1,
before: blockTrack.before
};
track.push(blockTrack);
}
} else {
blockTrack = {
type: "block_change",
user,
username,
date: date1,
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;
export { default as LinkToolTipPlugin } from "./src/LinkToolTipPlugin";
export {
default as TrackChangePlugin
} from "./src/trackChanges/TrackChangePlugin";
import { getFromToMark } from "./helpers";
const findSelectedChanges = state => {
const selection = state.selection,
selectedChanges = {
insertion: false,
deletion: false,
formatChange: false
};
let insertionPos = false,
deletionPos = false,
formatChangePos = false,
insertionMark,
deletionMark,
formatChangeMark,
insertionSize,
deletionSize,
formatChangeSize;
if (selection.empty) {
const resolvedPos = state.doc.resolve(selection.from),
marks = resolvedPos.marks();
if (marks) {
insertionMark = marks.find(
mark => mark.type.name === "insertion" && !mark.attrs.approved
);
if (insertionMark) {
insertionPos = selection.from;
}
deletionMark = marks.find(mark => mark.type.name === "deletion");
if (deletionMark) {
deletionPos = selection.from;
}
formatChangeMark = marks.find(mark => mark.type.name === "format_change");
if (formatChangeMark) {
formatChangePos = selection.from;
}
}
} else {
state.doc.nodesBetween(selection.from, selection.to, (node, pos) => {
if (pos < selection.from) {
return true;
}
if (!insertionMark) {
insertionMark = node.attrs.track
? node.attrs.track.find(trackAttr => trackAttr.type === "insertion")
: node.marks.find(
mark => mark.type.name === "insertion" && !mark.attrs.approved
);
if (insertionMark) {
insertionPos = pos;
if (!node.isInline) {
insertionSize = node.nodeSize;
}
}
}
if (!deletionMark) {
deletionMark = node.attrs.track
? node.attrs.track.find(trackAttr => trackAttr.type === "deletion")
: node.marks.find(mark => mark.type.name === "deletion");
if (deletionMark) {
deletionPos = pos;
if (!node.isInline) {
deletionSize = node.nodeSize;
}
}
}
if (!formatChangeMark) {
formatChangeMark = node.marks.find(
mark => mark.type.name === "format_change"
);
if (formatChangeMark) {
formatChangePos = pos;
if (!node.isInline) {
formatChangeSize = node.nodeSize;
}
}
}
});
}
if (insertionMark) {
selectedChanges.insertion = insertionSize
? { from: insertionPos, to: insertionPos + insertionSize }
: getFromToMark(state.doc, insertionPos, insertionMark);
}
if (deletionMark) {
selectedChanges.deletion = deletionSize
? { from: deletionPos, to: deletionPos + deletionSize }
: getFromToMark(state.doc, deletionPos, deletionMark);
}
if (formatChangeMark) {
selectedChanges.formatChange = formatChangeSize
? { from: formatChangePos, to: formatChangePos + formatChangeSize }
: getFromToMark(state.doc, formatChangePos, formatChangeMark);
}
return selectedChanges;
};
export { findSelectedChanges };
import { Plugin, PluginKey } from "prosemirror-state";
import { Decoration, DecorationSet } from "prosemirror-view";
import { findSelectedChanges } from "./FindSelectedChanges";
import { deactivateAllSelectedChanges } from "./helpers";
export const key = new PluginKey("track");
export const selectedInsertionSpec = {};
export const selectedDeletionSpec = {};
export const selectedChangeFormatSpec = {};
export const selectedChangeBlockSpec = {};
export default options => {
return new Plugin({
key,
state: {
init(config, state) {
const userIds = ["33"];
state.doc.descendants(node => {
if (node.attrs.track) {
node.attrs.track.forEach(track => {
if (!userIds.includes(track.user) && track.user !== 0) {
userIds.push(track.user);
}
});
} else {
node.marks.forEach(mark => {
if (
["deletion", "insertion", "format_change"].includes(
mark.type.name
) &&
!userIds.includes(mark.attrs.user) &&
mark.attrs.user !== 0
) {
userIds.push(mark.attrs.user);
}
});
}
});
return {
decos: DecorationSet.empty
};
},
apply(tr, prev, oldState, state) {
const meta = tr.getMeta(key);
if (meta) {
// There has been an update, return values from meta instead
// of previous values
return meta;
}
let { decos } = this.getState(oldState);
if (tr.selectionSet) {
const { insertion, deletion, formatChange } = findSelectedChanges(
state
);
decos = DecorationSet.empty;
const decoType = tr.selection.node
? Decoration.node
: Decoration.inline;
if (insertion) {
decos = decos.add(tr.doc, [
decoType(
insertion.from,
insertion.to,
{
class: "selected-insertion"
},
selectedInsertionSpec
)
]);
}
if (deletion) {
decos = decos.add(tr.doc, [
decoType(
deletion.from,
deletion.to,
{
class: "selected-deletion"
},
selectedDeletionSpec
)
]);
}
if (formatChange) {
decos = decos.add(tr.doc, [
decoType(
formatChange.from,
formatChange.to,
{
class: "selected-format-change"
},
selectedChangeFormatSpec
)
]);
}
} else {
decos = decos.map(tr.mapping, tr.doc);
}
return {
decos
};
}
},
props: {
decorations(state) {
const { decos } = this.getState(state);
return decos;
},
handleDOMEvents: {
focus: (view, _event) => {
view.dispatch(deactivateAllSelectedChanges(view.state.tr));
}
}
}
});
};
import {Decoration, DecorationSet} from "prosemirror-view"
import {
key,
selectedInsertionSpec,
selectedDeletionSpec,
selectedChangeFormatSpec,
selectedChangeBlockSpec
} from "./TrackChangePlugin"
export function getSelectedChanges(state) {
const {decos} = key.getState(state)
const insertion = decos.find(undefined, undefined, spec => spec === selectedInsertionSpec)[0],
deletion = decos.find(undefined, undefined, spec => spec === selectedDeletionSpec)[0],
format_change = decos.find(undefined, undefined, spec => spec === selectedChangeFormatSpec)[0],
block_change = decos.find(undefined, undefined, spec => spec === selectedChangeBlockSpec)[0]
return {insertion, deletion, format_change, block_change}
}
export function setSelectedChanges(state, type, pos) {
const tr = state.tr,
node = tr.doc.nodeAt(pos),
mark = node.attrs.track ?
node.attrs.track.find(trackAttr => trackAttr.type===type) :
node.marks.find(mark => mark.type.name===type)
if (!mark) {
return
}
const selectedChange = node.isInline ? getFromToMark(tr.doc, pos, mark) : {from: pos, to: pos + node.nodeSize}
let decos = DecorationSet.empty, spec
if (type==='insertion') {
spec = selectedInsertionSpec
} else if (type==='deletion') {
spec = selectedDeletionSpec
} else if (type==='format_change') {
spec = selectedChangeFormatSpec
} else if (type==='block_change') {
spec = selectedChangeBlockSpec
}
const decoType = node.isInline ? Decoration.inline : Decoration.node
decos = decos.add(tr.doc, [decoType(selectedChange.from, selectedChange.to, {
class: `selected-${type}`
}, spec)])
return tr.setMeta(key, {decos}).setMeta('track', true)
}
export function deactivateAllSelectedChanges(tr) {
const pluginState = {
decos: DecorationSet.empty
}
return tr.setMeta(key, pluginState).setMeta('track', true)
}
// From https://discuss.prosemirror.net/t/expanding-the-selection-to-the-active-mark/478/2 with some bugs fixed
export function getFromToMark(doc, pos, mark) {
const $pos = doc.resolve(pos), parent = $pos.parent
const start = parent.childAfter($pos.parentOffset)
if (!start.node) {
return null
}
let startIndex = $pos.index(), startPos = $pos.start() + start.offset
while (startIndex > 0 && mark.isInSet(parent.child(startIndex - 1).marks)) {
startPos -= parent.child(--startIndex).nodeSize
}
let endIndex = $pos.index() + 1, endPos = $pos.start() + start.offset + start.node.nodeSize
while (endIndex < parent.childCount && mark.isInSet(parent.child(endIndex).marks)) {
endPos += parent.child(endIndex++).nodeSize
}
return {from: startPos, to: endPos}
}