import { DataRowModel } from 'app/client/models/DataRowModel'; import { NewAbstractWidget } from 'app/client/widgets/NewAbstractWidget'; import { CellValue } from 'app/common/DocActions'; import { isVersions } from 'app/common/gristTypes'; import { BaseFormatter } from 'app/common/ValueFormatter'; import { Diff, DIFF_DELETE, DIFF_INSERT, diff_match_patch as DiffMatchPatch, DIFF_EQUAL } from 'diff-match-patch'; import { Computed, dom } from 'grainjs'; /** * * A special widget used for rendering cell-level comparisons and conflicts. * */ export class DiffBox extends NewAbstractWidget { private _diffTool = new DiffMatchPatch(); public buildConfigDom() { return dom('div'); } /** * Render a cell-level diff as a series of styled spans. */ public buildDom(row: DataRowModel) { const formattedValue = Computed.create(null, (use) => { if (use(row._isAddRow) || this.isDisposed() || use(this.field.displayColModel).isDisposed()) { // Work around JS errors during certain changes, following code in Reference.js return [] as Diff[]; } const value = use(row.cells[use(use(this.field.displayColModel).colId)]); const formatter = use(this.valueFormatter); return this._prepareCellDiff(value, formatter); }); return dom( 'div.field_clip', dom.autoDispose(formattedValue), dom.style('text-align', this.options.prop('alignment')), dom.cls('text_wrapping', (use) => Boolean(use(this.options.prop('wrap')))), dom.forEach(formattedValue, ([code, txt]) => { if (code === DIFF_DELETE) { return dom("span.diff-parent", txt); } else if (code === DIFF_INSERT) { return dom("span.diff-remote", txt); } else if (code === DIFF_LOCAL) { return dom("span.diff-local", txt); } else { return dom("span.diff-common", txt); } }), ); } /** * Given the cell value and the formatter, construct a list of fragments in * diff-match-patch format expressing the difference between versions. * The format is a list of [CODE, STRING] pairs, where the possible values of * CODE are: * -1 -- meaning DELETION of the parent value. * 0 -- meaning text common to all versions. * 1 -- meaning INSERTION of the remote value. * 2 -- meaning INSERTION of the local value. * * When a change is made only locally or remotely, then the list returned may * include common text, deletions and insertions in any order. * * When a change is made both locally and remotely, the list returned does not * include any common text, but just reports the parent value, then the local value, * then the remote value. This may be optimized in future. */ private _prepareCellDiff(value: CellValue, formatter: BaseFormatter): Diff[] { if (!isVersions(value)) { // This can happen for reference columns, where the diff widget is // selected on the basis of one column, but we are displaying the // content of another. We have more version information for the // reference column than for its display column. return [[DIFF_EQUAL, formatter.format(value)]]; } const versions = value[1]; if (!('local' in versions)) { // Change was made remotely only. return this._prepareTextDiff(formatter.format(versions.parent), formatter.format(versions.remote)); } else if (!('remote' in versions)) { // Change was made locally only. return this._prepareTextDiff(formatter.format(versions.parent), formatter.format(versions.local)) .map(([code, txt]) => [code === DIFF_INSERT ? DIFF_LOCAL : code, txt]); } // Change was made both locally and remotely. return [[DIFF_DELETE, formatter.format(versions.parent)], [DIFF_LOCAL, formatter.format(versions.local)], [DIFF_INSERT, formatter.format(versions.remote)]]; } // Run diff-match-patch on the text, do its cleanup, and then some extra // ad-hoc cleanup of our own. Diffs are hard to read if they are too // "choppy". private _prepareTextDiff(txt1: string, txt2: string): Diff[] { const diffs = this._diffTool.diff_main(txt1, txt2); this._diffTool.diff_cleanupSemantic(diffs); if (diffs.length > 2 && this._notDiffWorthy(txt1, diffs.length) && this._notDiffWorthy(txt2, diffs.length)) { return [[DIFF_DELETE, txt1], [DIFF_INSERT, txt2]]; } if (diffs.length === 1 && diffs[0][0] === DIFF_DELETE) { // Add an empty set symbol, since otherwise it will be ambiguous // whether the deletion was done locally or remotely. diffs.push([1, '\u2205']); } return diffs; } // Heuristic for whether to show common parts of versions, or to treat them // as entirely distinct. private _notDiffWorthy(txt: string, parts: number) { return txt.length < 5 * parts || this._isMostlyNumeric(txt); } // Check is text has a lot of numeric content. private _isMostlyNumeric(txt: string) { return [...txt].filter(c => c >= '0' && c <= '9').length > txt.length / 2; } } // A constant marking text fragments present locally but not in parent (or remote). // Must be distinct from DiffMatchPatch.DIFF_* constants (-1, 0, 1). const DIFF_LOCAL = 2;