@plone/volto
Version:
Volto
349 lines (327 loc) • 10.3 kB
JSX
/**
* Diff field component.
* @module components/manage/Diff/DiffField
*/
import React from 'react';
import join from 'lodash/join';
import map from 'lodash/map';
import PropTypes from 'prop-types';
import { Grid } from 'semantic-ui-react';
import ReactDOMServer from 'react-dom/server';
import { Provider } from 'react-intl-redux';
import { createBrowserHistory } from 'history';
import { ConnectedRouter } from 'connected-react-router';
import { useSelector } from 'react-redux';
import config from '@plone/volto/registry';
import Api from '@plone/volto/helpers/Api/Api';
import configureStore from '@plone/volto/store';
import RenderBlocks from '@plone/volto/components/theme/View/RenderBlocks';
import { serializeNodes } from '@plone/volto-slate/editor/render';
import { injectLazyLibs } from '@plone/volto/helpers/Loadable/Loadable';
const isHtmlTag = (str) => {
// Match complete HTML tags, including:
// 1. Opening tags like <div>, <img src="example" />, <svg>...</svg>
// 2. Self-closing tags like <img />, <br />
// 3. Closing tags like </div>
return /^<([a-zA-Z]+[0-9]*)\b[^>]*>|^<\/([a-zA-Z]+[0-9]*)\b[^>]*>$|^<([a-zA-Z]+[0-9]*)\b[^>]*\/>$/.test(
str,
);
};
const splitWords = (str) => {
if (typeof str !== 'string') return str;
if (!str) return [];
const result = [];
let currentWord = '';
let insideTag = false;
let insideSpecialTag = false;
let tagBuffer = '';
// Special tags that should not be split (e.g., <img />, <svg> ... </svg>)
const specialTags = ['img', 'svg'];
for (let i = 0; i < str.length; i++) {
const char = str[i];
// Start of an HTML tag
if (char === '<') {
if (currentWord) {
result.push(currentWord); // Push text before the tag
currentWord = '';
}
insideTag = true;
tagBuffer += char;
}
// End of an HTML tag
else if (char === '>') {
tagBuffer += char;
insideTag = false;
// Check if the tagBuffer contains a special tag
const tagNameMatch = tagBuffer.match(/^<\/?([a-zA-Z]+[0-9]*)\b/);
if (tagNameMatch && specialTags.includes(tagNameMatch[1])) {
insideSpecialTag =
tagNameMatch[0].startsWith('<') && !tagNameMatch[0].startsWith('</');
result.push(tagBuffer); // Push the complete special tag as one unit
tagBuffer = '';
continue;
}
result.push(tagBuffer); // Push the complete tag
tagBuffer = '';
}
// Inside the tag or special tag
else if (insideTag || insideSpecialTag) {
tagBuffer += char;
}
// Space outside of tags - push current word
else if (char === ' ' && !insideTag && !insideSpecialTag) {
if (currentWord) {
result.push(currentWord);
currentWord = '';
}
result.push(' ');
} else if (
char === ',' &&
i < str.length - 1 &&
str[i + 1] !== ' ' &&
!insideTag &&
!insideSpecialTag
) {
if (currentWord) {
result.push(currentWord + char);
currentWord = '';
}
result.push(' ');
}
// Accumulate characters outside of tags
else {
currentWord += char;
}
}
// Push any remaining text
if (currentWord) {
result.push(currentWord);
}
if (tagBuffer) {
result.push(tagBuffer); // Push remaining tagBuffer
}
return result;
};
const formatDiffPart = (part, value, side) => {
if (!isHtmlTag(value)) {
if (part.removed && (side === 'left' || side === 'unified')) {
return `<span class="deletion">${value}</span>`;
} else if (part.removed) return '';
else if (part.added && (side === 'right' || side === 'unified')) {
return `<span class="addition">${value}</span>`;
} else if (part.added) return '';
return value;
} else {
if (side === 'unified' && part.added) return value;
else if (side === 'unified' && part.removed) return '';
if (part.removed && side === 'left') {
return value;
} else if (part.removed) return '';
else if (part.added && side === 'right') {
return value;
} else if (part.added) return '';
return value;
}
};
/**
* Diff field component.
* @function DiffField
* @param {*} one Field one
* @param {*} two Field two
* @param {Object} schema Field schema
* @returns {string} Markup of the component.
*/
const DiffField = ({
one,
two,
contentOne,
contentTwo,
view,
schema,
diffLib,
}) => {
const language = useSelector((state) => state.intl.locale);
const readable_date_format = {
dateStyle: 'full',
timeStyle: 'short',
};
const diffWords = (oneStr, twoStr) => {
return diffLib.diffArrays(
splitWords(String(oneStr)),
splitWords(String(twoStr)),
);
};
let parts, oneArray, twoArray;
if (schema.widget) {
switch (schema.widget) {
case 'richtext':
parts = diffWords(one?.data, two?.data);
break;
case 'datetime':
parts = diffWords(
new Intl.DateTimeFormat(language, readable_date_format)
.format(new Date(one))
.replace('\u202F', ' '),
new Intl.DateTimeFormat(language, readable_date_format)
.format(new Date(two))
.replace('\u202F', ' '),
);
break;
case 'json': {
const api = new Api();
const history = createBrowserHistory();
const store = configureStore(window.__data, history, api);
parts = diffWords(
ReactDOMServer.renderToStaticMarkup(
<Provider store={store}>
<ConnectedRouter history={history}>
<RenderBlocks content={contentOne} />
</ConnectedRouter>
</Provider>,
),
ReactDOMServer.renderToStaticMarkup(
<Provider store={store}>
<ConnectedRouter history={history}>
<RenderBlocks content={contentTwo} />
</ConnectedRouter>
</Provider>,
),
);
break;
}
case 'slate': {
const api = new Api();
const history = createBrowserHistory();
const store = configureStore(window.__data, history, api);
parts = diffWords(
ReactDOMServer.renderToStaticMarkup(
<Provider store={store}>
<ConnectedRouter history={history}>
{serializeNodes(one)}
</ConnectedRouter>
</Provider>,
),
ReactDOMServer.renderToStaticMarkup(
<Provider store={store}>
<ConnectedRouter history={history}>
{serializeNodes(two)}
</ConnectedRouter>
</Provider>,
),
);
break;
}
case 'textarea':
default:
const Widget = config.widgets?.views?.widget?.[schema.widget];
if (Widget) {
const api = new Api();
const history = createBrowserHistory();
const store = configureStore(window.__data, history, api);
parts = diffWords(
ReactDOMServer.renderToStaticMarkup(
<Provider store={store}>
<ConnectedRouter history={history}>
<Widget value={one} />
</ConnectedRouter>
</Provider>,
),
ReactDOMServer.renderToStaticMarkup(
<Provider store={store}>
<ConnectedRouter history={history}>
<Widget value={two} />
</ConnectedRouter>
</Provider>,
),
);
} else parts = diffWords(one, two);
break;
}
} else if (schema.type === 'object') {
parts = diffWords(one?.filename || one, two?.filename || two);
} else if (schema.type === 'array') {
oneArray = (one || []).map((i) => i?.title || i);
twoArray = (two || []).map((j) => j?.title || j);
parts = diffWords(oneArray, twoArray);
} else {
parts = diffWords(one?.title || one, two?.title || two);
}
return (
<Grid data-testid="DiffField">
<Grid.Row>
<Grid.Column width={12}>{schema.title}</Grid.Column>
</Grid.Row>
{view === 'split' && (
<Grid.Row>
<Grid.Column width={6} verticalAlign="top">
<span
dangerouslySetInnerHTML={{
__html: join(
map(parts, (part) => {
let combined = (part.value || []).reduce((acc, value) => {
return acc + formatDiffPart(part, value, 'left');
}, '');
return combined;
}),
'',
),
}}
/>
</Grid.Column>
<Grid.Column width={6} verticalAlign="top">
<span
dangerouslySetInnerHTML={{
__html: join(
map(parts, (part) => {
let combined = (part.value || []).reduce((acc, value) => {
return acc + formatDiffPart(part, value, 'right');
}, '');
return combined;
}),
'',
),
}}
/>
</Grid.Column>
</Grid.Row>
)}
{view === 'unified' && (
<Grid.Row>
<Grid.Column width={16} verticalAlign="top">
<span
dangerouslySetInnerHTML={{
__html: join(
map(parts, (part) => {
let combined = (part.value || []).reduce((acc, value) => {
return acc + formatDiffPart(part, value, 'unified');
}, '');
return combined;
}),
'',
),
}}
/>
</Grid.Column>
</Grid.Row>
)}
</Grid>
);
};
/**
* Property types.
* @property {Object} propTypes Property types.
* @static
*/
DiffField.propTypes = {
one: PropTypes.any.isRequired,
two: PropTypes.any.isRequired,
contentOne: PropTypes.any,
contentTwo: PropTypes.any,
view: PropTypes.string.isRequired,
schema: PropTypes.shape({
widget: PropTypes.string,
type: PropTypes.string,
title: PropTypes.string,
}).isRequired,
};
export default injectLazyLibs('diffLib')(DiffField);