Skip to content

Commit

Permalink
Handle cases where added text should inherit marks from previous char…
Browse files Browse the repository at this point in the history
…acters
  • Loading branch information
jonathonherbert committed Sep 8, 2020
1 parent 8ca0c71 commit 08aa67f
Show file tree
Hide file tree
Showing 3 changed files with 119 additions and 36 deletions.
46 changes: 26 additions & 20 deletions src/ts/commands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -108,7 +108,6 @@ export const stopHoverCommand = (): Command => (state, dispatch) => {
return true;
};


/**
* Indicate the user is highlighting a match decoration.
*
Expand Down Expand Up @@ -322,28 +321,35 @@ const maybeApplySuggestions = (
return false;
}

if (dispatch) {
const tr = state.tr;
suggestionsToApply.forEach(
({ from, to, text }) => {
if (!text) {
return;
if (!dispatch) {
return true;
}

const tr = state.tr;
suggestionsToApply.forEach(({ from, to, text }) => {
if (!text) {
return;
}
const mappedFrom = tr.mapping.map(from);
const mappedTo = tr.mapping.map(to);
const replacementFrags = getReplacementFragmentsFromReplacement(
tr,
mappedFrom,
mappedTo,
text
);
replacementFrags.forEach(
({ text: fragText, marks, from: fragFrom, to: fragTo }) => {
if (fragText) {
const node = state.schema.text(fragText, marks);
return tr.insert(fragFrom, node);
}
const mappedFrom = tr.mapping.map(from);
const mappedTo = tr.mapping.map(to);
const replacements = getReplacementFragmentsFromReplacement(tr, mappedFrom, mappedTo, text);
replacements.forEach(({ text: fragText, marks, from: fragFrom, to: fragTo}) => {
const node = state.schema.text(fragText, marks)
tr.replaceWith(
fragFrom,
fragTo,
node
)
})
tr.delete(fragFrom, fragTo);
}
);
dispatch(tr);
}
});

dispatch(tr);

return true;
};
Expand Down
38 changes: 35 additions & 3 deletions src/ts/test/commands.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ describe("Commands", () => {
expect(getByText(editorElement, "An improved sentence")).toBeTruthy();
});

it("should keep marks across the whole replaced text when suggestions are applied", () => {
it("should keep marks across the whole replaced text when suggestions are applied and additions are made to the end of the range", () => {
const match = createMatch(4, 11, [
{ text: "Example", type: "TEXT_SUGGESTION" }
]);
Expand All @@ -35,7 +35,23 @@ describe("Commands", () => {
expect(element.innerHTML).toBe("An <strong>improved</strong> sentence")
});

it("should keep marks across parts of the replaced text when suggestions are applied", () => {
it("should keep marks across the whole replaced text when suggestions are applied and additions are made to the beginning of the range", () => {
const match = createMatch(5, 9, [
{ text: "beggar", type: "TEXT_SUGGESTION" }
]);
const {
editorElement,
commands
} = createEditor("<p>Two <strong>eggs</strong></p>", [match]);

commands.applySuggestions([{ text: "beggars", matchId: match.matchId }]);

// The found element's text node is missing 'improved', as that text is nested
const element = getByText(editorElement, "Two")
expect(element.innerHTML).toBe("Two <strong>beggars</strong>")
});

it("should keep marks across parts of the replaced text when suggestions are applied with additions", () => {
const match = createMatch(4, 11, [
{ text: "Example", type: "TEXT_SUGGESTION" }
]);
Expand All @@ -46,9 +62,25 @@ describe("Commands", () => {

commands.applySuggestions([{ text: "Example", matchId: match.matchId }]);

// The found element's text node is missing 'improved', as that text is nested
// Again, we only match with the text directly contained by the element. It's a bit awkward.
const element = getByText(editorElement, "An ale sentence")
expect(element.innerHTML).toBe("An <strong>Ex</strong>a<em>mp</em>le sentence")
});

it("should keep marks across parts of the replaced text when suggestions are applied with deletions", () => {
const match = createMatch(4, 11, [
{ text: "ample", type: "TEXT_SUGGESTION" }
]);
const {
editorElement,
commands
} = createEditor("<p>An <strong>ex</strong>a<em>mp</em>le sentence</p>", [match]);

commands.applySuggestions([{ text: "ample", matchId: match.matchId }]);

// Again, we only match with the text directly contained by the element. It's a bit awkward.
const element = getByText(editorElement, "An ale sentence")
expect(element.innerHTML).toBe("An a<em>mp</em>le sentence")
});
});
});
71 changes: 58 additions & 13 deletions src/ts/utils/prosemirror.ts
Original file line number Diff line number Diff line change
Expand Up @@ -100,27 +100,72 @@ export const getReplacementFragmentsFromReplacement = (
replacement: string
): ISuggestionFragment[] => {
const currentText = tr.doc.textBetween(from, to);
const diffs = jsDiff.diffChars(currentText, replacement, {
const patches = jsDiff.diffChars(currentText, replacement, {
ignoreCase: false
});
const { fragments } = diffs.reduce(
(acc, diff) => {
// We're only interested in additions – they represent replacements
// that differ from the text in the document as it stands.
if (!diff.added || !diff.count) {
return acc;

const { fragments } = patches.reduce(
({ fragments: currentFragments, currentPos }, patch) => {
// If there are no chars, ignore.
if (!patch.count) {
return {
fragments: currentFragments,
currentPos
};
}

// Find all of the marks that span the text we're replacing.
const { fragments: currentFragments, currentPos } = acc;
// If this patch hasn't changed anything, ignore it and
// increment the count.
if (!patch.added && !patch.removed) {
return {
fragments: currentFragments,
currentPos: currentPos + patch.count
};
}
const $from = tr.doc.resolve(from + currentPos);
const $to = tr.doc.resolve($from.pos + diff.count);
const marks = $from.marksAcross($to) || Mark.none;
const newFragment = { text: diff.value, marks, from: $from.pos, to: $to.pos};
const $to = tr.doc.resolve(Math.min($from.pos + patch.count, tr.doc.nodeSize - 2));

// If this patch removes chars, create a fragment for
// the range, and leave the cursor where it is.
if (patch.removed) {
return {
fragments: currentFragments.concat({
from: $from.pos,
to: $to.pos,
text: "",
marks: []
}),
currentPos
};
}

const prevFragment = currentFragments[currentFragments.length - 1];
const isThisPatchNew =
!!prevFragment && (prevFragment.to || 0) < $from.pos;

let marks;
if (isThisPatchNew) {
// If this patch adds characters and the previous patch left
// a range intact, inherit the marks from the last character
// of that range.
const $lastCharFrom = tr.doc.resolve($from.pos - 1);
marks = $lastCharFrom.marksAcross($from) || Mark.none;
} else {
// If this patch adds characters, find all of the marks
// that span the text we're replacing, and copy them across.
marks = $from.marksAcross($to) || Mark.none;
}

const newFragment = {
text: patch.value,
marks,
from: $from.pos,
to: $to.pos
};

return {
fragments: currentFragments.concat(newFragment),
currentPos: currentPos + diff.count
currentPos: currentPos + patch.count
};
},
{
Expand Down

0 comments on commit 08aa67f

Please sign in to comment.