@wordpress/editor
Version:
Enhanced block editor for WordPress posts.
473 lines (472 loc) • 14.7 kB
JavaScript
// packages/editor/src/components/post-revisions-preview/block-diff.js
import { diffArrays } from "diff/lib/diff/array";
import { diffWords } from "diff/lib/diff/word";
import { parse as grammarParse } from "@wordpress/block-serialization-default-parser";
import {
privateApis as blocksPrivateApis,
getBlockType
} from "@wordpress/blocks";
import {
RichTextData,
create,
slice,
concat,
applyFormat
} from "@wordpress/rich-text";
import { __, _n, sprintf } from "@wordpress/i18n";
import { unlock } from "../../lock-unlock.mjs";
var { parseRawBlock } = unlock(blocksPrivateApis);
function stringifyValue(value) {
if (value === null || value === void 0) {
return "";
}
if (typeof value === "object") {
return JSON.stringify(value, null, 2);
}
return String(value);
}
function textSimilarity(text1, text2) {
if (!text1 && !text2) {
return 1;
}
if (!text1 || !text2) {
return 0;
}
const segmenter = new Intl.Segmenter(void 0, {
granularity: "word"
});
const wordLikeRegex = /[\p{L}\p{N}]/u;
const getWords = (text) => {
const words = [];
for (const { segment, isWordLike } of segmenter.segment(text)) {
if (isWordLike || wordLikeRegex.test(segment)) {
words.push(segment);
}
}
return words;
};
const words1 = getWords(text1);
const words2 = getWords(text2);
if (words1.length === 0 && words2.length === 0) {
return 1;
}
const set1 = new Set(words1);
let intersection = 0;
for (const word of words2) {
if (set1.has(word)) {
intersection++;
}
}
const total = Math.max(words1.length, words2.length);
return total > 0 ? intersection / total : 0;
}
function pairSimilarBlocks(blocks) {
const removed = [];
const added = [];
blocks.forEach((block, index) => {
const status = block.__revisionDiffStatus?.status;
if (status === "removed") {
removed.push({ block, index });
} else if (status === "added") {
added.push({ block, index });
}
});
if (removed.length === 0 || added.length === 0) {
return blocks;
}
const pairedRemoved = /* @__PURE__ */ new Set();
const pairedAdded = /* @__PURE__ */ new Set();
const modifications = /* @__PURE__ */ new Map();
const SIMILARITY_THRESHOLD = 0.5;
const addedByName = /* @__PURE__ */ new Map();
for (const add of added) {
const name = add.block.blockName;
if (!addedByName.has(name)) {
addedByName.set(name, []);
}
addedByName.get(name).push(add);
}
const removedByName = /* @__PURE__ */ new Map();
for (const rem of removed) {
const name = rem.block.blockName;
if (!removedByName.has(name)) {
removedByName.set(name, []);
}
removedByName.get(name).push(rem);
}
let maxPairedAddedIndex = -1;
for (const rem of removed) {
const candidates = addedByName.get(rem.block.blockName) || [];
const sameNameRemoved = removedByName.get(rem.block.blockName) || [];
const unpaired = candidates.filter(
(add) => !modifications.has(add.index) && add.index > maxPairedAddedIndex
);
if (unpaired.length === 0) {
continue;
}
let bestMatch = null;
if (sameNameRemoved.length === 1 && unpaired.length === 1) {
const add = unpaired[0];
const attrsMatch = JSON.stringify(rem.block.attrs) === JSON.stringify(add.block.attrs);
const contentMatch = (rem.block.innerHTML || "") === (add.block.innerHTML || "");
if (!contentMatch || !attrsMatch) {
bestMatch = add;
}
} else {
let bestScore = 0;
for (const add of unpaired) {
const score = textSimilarity(
rem.block.innerHTML || "",
add.block.innerHTML || ""
);
const attrsMatch = JSON.stringify(rem.block.attrs) === JSON.stringify(add.block.attrs);
if (score > bestScore && score > SIMILARITY_THRESHOLD && (score < 1 || !attrsMatch)) {
bestScore = score;
bestMatch = add;
}
}
}
if (bestMatch) {
maxPairedAddedIndex = bestMatch.index;
const modifiedBlock = {
...bestMatch.block,
__revisionDiffStatus: { status: "modified" },
__previousRawBlock: rem.block
};
const lo = Math.min(rem.index, bestMatch.index);
const hi = Math.max(rem.index, bestMatch.index);
let hasAddedBetween = false;
for (let i = lo + 1; i < hi; i++) {
if (blocks[i].__revisionDiffStatus?.status === "added" && !pairedAdded.has(i)) {
hasAddedBetween = true;
break;
}
}
if (hasAddedBetween) {
modifications.set(bestMatch.index, modifiedBlock);
pairedRemoved.add(rem.index);
} else {
modifications.set(rem.index, modifiedBlock);
pairedAdded.add(bestMatch.index);
}
}
}
return blocks.map((block, index) => {
if (pairedRemoved.has(index) || pairedAdded.has(index)) {
return null;
}
if (modifications.has(index)) {
return modifications.get(index);
}
return block;
}).filter(Boolean);
}
function diffRawBlocks(currentRaw, previousRaw) {
const createBlockSignature = (rawBlock) => JSON.stringify({
name: rawBlock.blockName,
attrs: rawBlock.attrs,
// Use innerContent filtered to non-null and non-whitespace-only strings.
// This excludes whitespace between inner blocks which changes based on count.
html: (rawBlock.innerContent || []).filter(
(c) => c !== null && c.trim() !== ""
)
});
const currentSigs = currentRaw.map(createBlockSignature);
const previousSigs = previousRaw.map(createBlockSignature);
const diff = diffArrays(previousSigs, currentSigs);
const result = [];
let currIdx = 0;
let prevIdx = 0;
for (const part of diff) {
if (part.added) {
for (let i = 0; i < part.count; i++) {
result.push({
...currentRaw[currIdx++],
__revisionDiffStatus: { status: "added" }
});
}
} else if (part.removed) {
for (let i = 0; i < part.count; i++) {
result.push({
...previousRaw[prevIdx++],
__revisionDiffStatus: { status: "removed" }
});
}
} else {
for (let i = 0; i < part.count; i++) {
const currBlock = currentRaw[currIdx++];
const prevBlock = previousRaw[prevIdx++];
const diffedInnerBlocks = diffRawBlocks(
currBlock.innerBlocks || [],
prevBlock.innerBlocks || []
);
result.push({
...currBlock,
innerBlocks: diffedInnerBlocks
});
}
}
}
return pairSimilarBlocks(result);
}
function hasFormatChangedAtIndex(currentFormats, previousFormats, currentIndex, previousIndex) {
const currFmts = currentFormats[currentIndex] || [];
const prevFmts = previousFormats[previousIndex] || [];
if (currFmts.length !== prevFmts.length) {
return true;
}
for (const fmt of currFmts) {
const match = prevFmts.find(
(pf) => pf.type === fmt.type && JSON.stringify(pf.attributes) === JSON.stringify(fmt.attributes)
);
if (!match) {
return true;
}
}
return false;
}
function describeFormatChange(currentFormats, previousFormats, currIdx, prevIdx) {
const currFmts = currentFormats[currIdx] || [];
const prevFmts = previousFormats[prevIdx] || [];
let addedCount = 0;
let removedCount = 0;
let changedCount = 0;
for (const fmt of currFmts) {
const match = prevFmts.find((pf) => pf.type === fmt.type);
if (!match) {
addedCount++;
} else if (JSON.stringify(fmt.attributes) !== JSON.stringify(match.attributes)) {
changedCount++;
}
}
for (const fmt of prevFmts) {
const match = currFmts.find((cf) => cf.type === fmt.type);
if (!match) {
removedCount++;
}
}
if (addedCount > 0 && removedCount === 0 && changedCount === 0) {
return {
type: "added",
description: sprintf(
/* translators: %d: number of formats added */
_n("%d format added", "%d formats added", addedCount),
addedCount
)
};
}
if (removedCount > 0 && addedCount === 0 && changedCount === 0) {
return {
type: "removed",
description: sprintf(
/* translators: %d: number of formats removed */
_n("%d format removed", "%d formats removed", removedCount),
removedCount
)
};
}
const parts = [];
if (addedCount > 0) {
parts.push(
sprintf(
/* translators: %d: number of formats added */
_n("%d format added", "%d formats added", addedCount),
addedCount
)
);
}
if (removedCount > 0) {
parts.push(
sprintf(
/* translators: %d: number of formats removed */
_n("%d format removed", "%d formats removed", removedCount),
removedCount
)
);
}
if (changedCount > 0) {
parts.push(
sprintf(
/* translators: %d: number of formats changed */
_n("%d format changed", "%d formats changed", changedCount),
changedCount
)
);
}
return {
type: "changed",
description: parts.join(", ") || __("Formatting changed")
};
}
function applyRichTextDiff(currentRichText, previousRichText) {
const currentText = currentRichText.toPlainText();
const previousText = previousRichText.toPlainText();
const textDiff = diffWords(previousText, currentText);
let result = create({ text: "" });
let currentIdx = 0;
let previousIdx = 0;
for (const part of textDiff) {
if (part.removed) {
const removedSlice = slice(
previousRichText,
previousIdx,
previousIdx + part.value.length
);
const formatted = applyFormat(
removedSlice,
{
type: "revision/diff-removed",
attributes: { title: __("Removed") }
},
0,
part.value.length
);
result = concat(result, formatted);
previousIdx += part.value.length;
} else if (part.added) {
const addedSlice = slice(
currentRichText,
currentIdx,
currentIdx + part.value.length
);
const formatted = applyFormat(
addedSlice,
{
type: "revision/diff-added",
attributes: { title: __("Added") }
},
0,
part.value.length
);
result = concat(result, formatted);
currentIdx += part.value.length;
} else {
const currentFormats = currentRichText.formats || [];
const previousFormats = previousRichText.formats || [];
const len = part.value.length;
const checkFormatChanged = (offset) => hasFormatChangedAtIndex(
currentFormats,
previousFormats,
currentIdx + offset,
previousIdx + offset
);
let rangeStart = 0;
let rangeFormatChanged = checkFormatChanged(0);
for (let i = 1; i <= len; i++) {
const formatChanged = i < len && checkFormatChanged(i);
if (i === len || formatChanged !== rangeFormatChanged) {
const rangeSlice = slice(
currentRichText,
currentIdx + rangeStart,
currentIdx + i
);
if (rangeFormatChanged) {
const { type, description } = describeFormatChange(
currentFormats,
previousFormats,
currentIdx + rangeStart,
previousIdx + rangeStart
);
const formatType = {
added: "revision/diff-format-added",
removed: "revision/diff-format-removed",
changed: "revision/diff-format-changed"
}[type];
const marked = applyFormat(
rangeSlice,
{
type: formatType,
attributes: { title: description }
},
0,
i - rangeStart
);
result = concat(result, marked);
} else {
result = concat(result, rangeSlice);
}
rangeStart = i;
rangeFormatChanged = formatChanged;
}
}
currentIdx += part.value.length;
previousIdx += part.value.length;
}
}
return new RichTextData(result);
}
function applyDiffToBlock(currentBlock, previousBlock, diffStatus) {
const blockType = getBlockType(currentBlock.name);
if (!blockType) {
return;
}
const changedAttributes = {};
for (const [attrName, attrDef] of Object.entries(
blockType.attributes
)) {
if (attrDef.source === "rich-text") {
const currentRichText = currentBlock.attributes[attrName];
const previousRichText = previousBlock.attributes[attrName];
if (currentRichText instanceof RichTextData && previousRichText instanceof RichTextData) {
currentBlock.attributes[attrName] = applyRichTextDiff(
currentRichText,
previousRichText
);
}
} else {
const currStr = stringifyValue(
currentBlock.attributes[attrName]
);
const prevStr = stringifyValue(
previousBlock.attributes[attrName]
);
if (currStr !== prevStr) {
changedAttributes[attrName] = diffWords(prevStr, currStr);
}
}
}
if (Object.keys(changedAttributes).length > 0) {
diffStatus.changedAttributes = changedAttributes;
}
}
function applyDiffRecursively(parsedBlock, rawBlock) {
if (rawBlock.__revisionDiffStatus) {
if (rawBlock.__revisionDiffStatus.status === "modified" && rawBlock.__previousRawBlock) {
const previousParsed = parseRawBlock(rawBlock.__previousRawBlock);
if (previousParsed) {
applyDiffToBlock(
parsedBlock,
previousParsed,
rawBlock.__revisionDiffStatus
);
}
}
parsedBlock.__revisionDiffStatus = rawBlock.__revisionDiffStatus;
parsedBlock.attributes.__revisionDiffStatus = rawBlock.__revisionDiffStatus;
}
if (parsedBlock.innerBlocks && rawBlock.innerBlocks) {
for (let i = 0; i < parsedBlock.innerBlocks.length; i++) {
const parsedInner = parsedBlock.innerBlocks[i];
const rawInner = rawBlock.innerBlocks[i];
if (parsedInner && rawInner) {
applyDiffRecursively(parsedInner, rawInner);
}
}
}
}
function diffRevisionContent(currentContent, previousContent) {
const currentRaw = grammarParse(currentContent || "");
const previousRaw = grammarParse(previousContent || "");
const mergedRaw = diffRawBlocks(currentRaw, previousRaw);
return mergedRaw.map((rawBlock) => {
const parsed = parseRawBlock(rawBlock);
if (parsed) {
applyDiffRecursively(parsed, rawBlock);
}
return parsed;
}).filter(Boolean);
}
export {
diffRevisionContent
};
//# sourceMappingURL=block-diff.mjs.map