UNPKG

@plone/volto

Version:
349 lines (327 loc) 10.3 kB
/** * 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);