@atlaskit/editor-plugin-show-diff 10.1.23 → 10.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +63 -0
- package/calculate-diff/package.json +8 -0
- package/dist/cjs/entry-points/calculate-diff.js +12 -0
- package/dist/cjs/pm-plugins/calculateDiff/calculateDiffDecorations.js +132 -31
- package/dist/cjs/pm-plugins/calculateDiff/computeDiffChanges.js +112 -0
- package/dist/cjs/pm-plugins/calculateDiff/groupChangesByBlock.js +4 -2
- package/dist/cjs/pm-plugins/calculateDiff/smart/classifySmartChanges.js +841 -0
- package/dist/cjs/pm-plugins/calculateDiff/smart/helpers.js +135 -0
- package/dist/cjs/pm-plugins/calculateDiff/smart/segmentText.js +331 -0
- package/dist/cjs/pm-plugins/calculateDiff/smart/thresholds.js +46 -0
- package/dist/cjs/pm-plugins/decorations/createBlockChangedDecoration.js +24 -18
- package/dist/cjs/pm-plugins/decorations/createChangedRowDecorationWidgets.js +18 -12
- package/dist/cjs/pm-plugins/decorations/createInlineChangedDecoration.js +6 -5
- package/dist/cjs/pm-plugins/decorations/createNodeChangedDecorationWidget.js +30 -17
- package/dist/cjs/pm-plugins/decorations/decorationKeys.js +8 -5
- package/dist/cjs/pm-plugins/decorations/utils/wrapBlockNodeView.js +52 -30
- package/dist/cjs/pm-plugins/getScrollableDecorations.js +6 -6
- package/dist/cjs/pm-plugins/isExtendedEnabled.js +20 -0
- package/dist/cjs/pm-plugins/main.js +21 -16
- package/dist/es2019/entry-points/calculate-diff.js +2 -0
- package/dist/es2019/pm-plugins/calculateDiff/calculateDiffDecorations.js +119 -29
- package/dist/es2019/pm-plugins/calculateDiff/computeDiffChanges.js +94 -0
- package/dist/es2019/pm-plugins/calculateDiff/groupChangesByBlock.js +4 -3
- package/dist/es2019/pm-plugins/calculateDiff/smart/classifySmartChanges.js +689 -0
- package/dist/es2019/pm-plugins/calculateDiff/smart/helpers.js +106 -0
- package/dist/es2019/pm-plugins/calculateDiff/smart/segmentText.js +283 -0
- package/dist/es2019/pm-plugins/calculateDiff/smart/thresholds.js +45 -0
- package/dist/es2019/pm-plugins/decorations/createBlockChangedDecoration.js +24 -18
- package/dist/es2019/pm-plugins/decorations/createChangedRowDecorationWidgets.js +18 -12
- package/dist/es2019/pm-plugins/decorations/createInlineChangedDecoration.js +6 -5
- package/dist/es2019/pm-plugins/decorations/createNodeChangedDecorationWidget.js +31 -16
- package/dist/es2019/pm-plugins/decorations/decorationKeys.js +9 -5
- package/dist/es2019/pm-plugins/decorations/utils/wrapBlockNodeView.js +52 -34
- package/dist/es2019/pm-plugins/getScrollableDecorations.js +6 -6
- package/dist/es2019/pm-plugins/isExtendedEnabled.js +12 -0
- package/dist/es2019/pm-plugins/main.js +21 -16
- package/dist/esm/entry-points/calculate-diff.js +2 -0
- package/dist/esm/pm-plugins/calculateDiff/calculateDiffDecorations.js +132 -31
- package/dist/esm/pm-plugins/calculateDiff/computeDiffChanges.js +107 -0
- package/dist/esm/pm-plugins/calculateDiff/groupChangesByBlock.js +4 -3
- package/dist/esm/pm-plugins/calculateDiff/smart/classifySmartChanges.js +834 -0
- package/dist/esm/pm-plugins/calculateDiff/smart/helpers.js +128 -0
- package/dist/esm/pm-plugins/calculateDiff/smart/segmentText.js +325 -0
- package/dist/esm/pm-plugins/calculateDiff/smart/thresholds.js +39 -0
- package/dist/esm/pm-plugins/decorations/createBlockChangedDecoration.js +24 -18
- package/dist/esm/pm-plugins/decorations/createChangedRowDecorationWidgets.js +18 -12
- package/dist/esm/pm-plugins/decorations/createInlineChangedDecoration.js +6 -5
- package/dist/esm/pm-plugins/decorations/createNodeChangedDecorationWidget.js +30 -17
- package/dist/esm/pm-plugins/decorations/decorationKeys.js +9 -5
- package/dist/esm/pm-plugins/decorations/utils/wrapBlockNodeView.js +52 -30
- package/dist/esm/pm-plugins/getScrollableDecorations.js +6 -6
- package/dist/esm/pm-plugins/isExtendedEnabled.js +14 -0
- package/dist/esm/pm-plugins/main.js +21 -16
- package/dist/types/entry-points/calculate-diff.d.ts +2 -0
- package/dist/types/entry-points/show-diff-plugin-type.d.ts +1 -1
- package/dist/types/pm-plugins/calculateDiff/calculateDiffDecorations.d.ts +3 -1
- package/dist/types/pm-plugins/calculateDiff/computeDiffChanges.d.ts +46 -0
- package/dist/types/pm-plugins/calculateDiff/groupChangesByBlock.d.ts +9 -0
- package/dist/types/pm-plugins/calculateDiff/smart/classifySmartChanges.d.ts +26 -0
- package/dist/types/pm-plugins/calculateDiff/smart/helpers.d.ts +44 -0
- package/dist/types/pm-plugins/calculateDiff/smart/segmentText.d.ts +32 -0
- package/dist/types/pm-plugins/calculateDiff/smart/thresholds.d.ts +44 -0
- package/dist/types/pm-plugins/decorations/createBlockChangedDecoration.d.ts +3 -2
- package/dist/types/pm-plugins/decorations/createChangedRowDecorationWidgets.d.ts +3 -2
- package/dist/types/pm-plugins/decorations/createInlineChangedDecoration.d.ts +3 -2
- package/dist/types/pm-plugins/decorations/createNodeChangedDecorationWidget.d.ts +4 -2
- package/dist/types/pm-plugins/decorations/decorationKeys.d.ts +5 -3
- package/dist/types/pm-plugins/decorations/utils/wrapBlockNodeView.d.ts +6 -4
- package/dist/types/pm-plugins/getScrollableDecorations.d.ts +2 -1
- package/dist/types/pm-plugins/isExtendedEnabled.d.ts +11 -0
- package/dist/types/pm-plugins/main.d.ts +11 -1
- package/dist/types/showDiffPluginType.d.ts +20 -1
- package/docs/smart-diff-design.md +240 -0
- package/package.json +8 -3
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The granularity level a `smart` change was promoted to. Stored on span `data` so the
|
|
3
|
+
* decoration pipeline can render each level differently (e.g. node-level deletions shown
|
|
4
|
+
* below the new content). `null` for non-promoted (inline) changes.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
/** Span `data` payload carried by promoted `smart` changes. */
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Build the `deleted`/`inserted` span arrays a `Change` needs. A length of 0 yields an
|
|
11
|
+
* empty array (no change on that side).
|
|
12
|
+
*/
|
|
13
|
+
export const createSpans = (length, data = null) => length > 0 ? [{
|
|
14
|
+
length,
|
|
15
|
+
data
|
|
16
|
+
}] : [];
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Build a promoted `Change` spanning `[fromA,toA)` in the original doc and `[fromB,toB)`
|
|
20
|
+
* in the new doc, with both sides marked changed (mirrors `groupChangesByBlock` so the
|
|
21
|
+
* decoration pipeline renders both the inserted-block decoration and the deleted-node
|
|
22
|
+
* widget).
|
|
23
|
+
*
|
|
24
|
+
* `level` tags the change's spans so downstream decoration logic can treat node-level
|
|
25
|
+
* promotions specially (see `smartChangeLevel`).
|
|
26
|
+
*/
|
|
27
|
+
export const makePromotedChange = (fromA, toA, fromB, toB, level) => {
|
|
28
|
+
const data = level ? {
|
|
29
|
+
smartLevel: level
|
|
30
|
+
} : null;
|
|
31
|
+
// `deleted` and `inserted` must be independent arrays: downstream code may mutate one
|
|
32
|
+
// side (push/splice), which would silently corrupt the other if they shared a reference.
|
|
33
|
+
return {
|
|
34
|
+
fromA,
|
|
35
|
+
toA,
|
|
36
|
+
fromB,
|
|
37
|
+
toB,
|
|
38
|
+
deleted: createSpans(Math.max(0, toA - fromA), data),
|
|
39
|
+
inserted: createSpans(Math.max(0, toB - fromB), data)
|
|
40
|
+
};
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Read the `smartLevel` tag off a change's spans, if present. Returns `undefined` for
|
|
45
|
+
* changes not produced by the `smart` classifier (e.g. inline/block/step diff types).
|
|
46
|
+
*/
|
|
47
|
+
export const smartChangeLevel = change => {
|
|
48
|
+
const spans = [...change.inserted, ...change.deleted];
|
|
49
|
+
for (const span of spans) {
|
|
50
|
+
const data = span.data;
|
|
51
|
+
if (data && typeof data === 'object' && 'smartLevel' in data) {
|
|
52
|
+
return data.smartLevel;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
return undefined;
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
/** True when a change has no original-side (deleted) content — a pure insertion. */
|
|
59
|
+
const isPureInsertion = change => change.toA <= change.fromA;
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Coalesce changes that overlap in new-doc (B) coordinates.
|
|
63
|
+
*
|
|
64
|
+
* Merging two changes takes the UNION of their A (original-doc) ranges. That is only safe when
|
|
65
|
+
* their A ranges actually touch/overlap — OR when at least one is a pure insertion (empty A).
|
|
66
|
+
* Otherwise, merging a deletion at A[110,152] with a later edit at A[162,168] would fabricate an
|
|
67
|
+
* A span [110,168] covering the untouched gap, which then overlaps a *different* change's A range
|
|
68
|
+
* (making the same original content appear deleted twice). So we require B-overlap AND
|
|
69
|
+
* (A-overlap OR a pure insertion) before coalescing.
|
|
70
|
+
*/
|
|
71
|
+
export const mergeOverlappingByNewDocRange = changes => {
|
|
72
|
+
if (changes.length <= 1) {
|
|
73
|
+
return changes;
|
|
74
|
+
}
|
|
75
|
+
const sorted = [...changes].sort((l, r) => l.fromB - r.fromB);
|
|
76
|
+
const merged = [];
|
|
77
|
+
let current = {
|
|
78
|
+
...sorted[0]
|
|
79
|
+
};
|
|
80
|
+
for (let i = 1; i < sorted.length; i++) {
|
|
81
|
+
const next = sorted[i];
|
|
82
|
+
const bOverlaps = next.fromB <= current.toB;
|
|
83
|
+
// A ranges may be unioned only if they touch, or if either side contributes no A content.
|
|
84
|
+
const aMergeable = isPureInsertion(current) || isPureInsertion(next) || rangesOverlap(current.fromA, current.toA, next.fromA, next.toA) || next.fromA <= current.toA; // adjacency (sorted by B, A usually monotonic)
|
|
85
|
+
if (bOverlaps && aMergeable) {
|
|
86
|
+
current = {
|
|
87
|
+
fromA: Math.min(current.fromA, next.fromA),
|
|
88
|
+
toA: Math.max(current.toA, next.toA),
|
|
89
|
+
fromB: Math.min(current.fromB, next.fromB),
|
|
90
|
+
toB: Math.max(current.toB, next.toB),
|
|
91
|
+
deleted: [...current.deleted, ...next.deleted],
|
|
92
|
+
inserted: [...current.inserted, ...next.inserted]
|
|
93
|
+
};
|
|
94
|
+
} else {
|
|
95
|
+
merged.push(current);
|
|
96
|
+
current = {
|
|
97
|
+
...next
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
merged.push(current);
|
|
102
|
+
return merged;
|
|
103
|
+
};
|
|
104
|
+
|
|
105
|
+
/** True when two half-open ranges overlap. */
|
|
106
|
+
export const rangesOverlap = (aFrom, aTo, bFrom, bTo) => aFrom < bTo && bFrom < aTo;
|
|
@@ -0,0 +1,283 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A half-open offset span `[from, to)` measured in *content offsets* relative to the
|
|
3
|
+
* start of a textblock (i.e. the same coordinate space as `buildCharsByOffset`).
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Minimal local typing for `Intl.Segmenter`. The TS lib target configured for this
|
|
8
|
+
* package does not include the `Intl.Segmenter` declarations, so we declare just the
|
|
9
|
+
* surface we use and access it via a runtime-checked cast. Remove when the lib target is
|
|
10
|
+
* bumped to include `es2022.intl`/`esnext.intl`.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
// A character is treated as part of a word unless it is whitespace or common punctuation.
|
|
14
|
+
// We deliberately avoid Unicode property escapes (`\p{...}`) and the `u` flag here: the `u`
|
|
15
|
+
// flag is only valid when targeting es6+, and the declaration-generation build config for
|
|
16
|
+
// this package targets an older lib, which rejects it with TS1501. This negated approach
|
|
17
|
+
// keeps word segmentation reasonable across scripts (any non-punctuation, non-space glyph
|
|
18
|
+
// counts as a word char) without needing Unicode-aware regex.
|
|
19
|
+
const NON_WORD_CHARS = ' \t\n\r\f\v.,;:!?…。!?、"\'`“”‘’()[]{}<>/\\|@#$%^&*-+=~';
|
|
20
|
+
const NON_WORD_CHAR_SET = new Set(NON_WORD_CHARS.split(''));
|
|
21
|
+
const isWordChar = ch => !NON_WORD_CHAR_SET.has(ch);
|
|
22
|
+
// Whitespace is checked via a Set rather than a regex to avoid both the `require-unicode-regexp`
|
|
23
|
+
// lint rule and the TS1501 error the `u` flag triggers under this package's build lib target.
|
|
24
|
+
const WHITESPACE_CHAR_SET = new Set([' ', '\t', '\n', '\r', '\f', '\v', '\u00a0']);
|
|
25
|
+
const isWhitespaceChar = ch => WHITESPACE_CHAR_SET.has(ch);
|
|
26
|
+
// Sentence terminators used by the regex fallback. We intentionally keep this small and
|
|
27
|
+
// conservative — the Intl.Segmenter path is the source of truth when available.
|
|
28
|
+
const SENTENCE_TERMINATORS = new Set(['.', '!', '?', '…', '。', '!', '?']);
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Build a per-content-offset view of a textblock's characters.
|
|
32
|
+
*
|
|
33
|
+
* `chars[i]` is the character at content offset `i`, or `null` when that offset lies
|
|
34
|
+
* inside a non-text inline node (mention, date, emoji, hardBreak, …). A `null` acts as an
|
|
35
|
+
* *opaque single token*: it counts as one word and never terminates a sentence.
|
|
36
|
+
*/
|
|
37
|
+
export const buildCharsByOffset = parent => {
|
|
38
|
+
const chars = Array.from({
|
|
39
|
+
length: parent.content.size
|
|
40
|
+
}, () => null);
|
|
41
|
+
parent.content.forEach((child, offset) => {
|
|
42
|
+
var _child$text;
|
|
43
|
+
if (!child.isText) {
|
|
44
|
+
return;
|
|
45
|
+
}
|
|
46
|
+
const text = (_child$text = child.text) !== null && _child$text !== void 0 ? _child$text : '';
|
|
47
|
+
for (let i = 0; i < text.length; i++) {
|
|
48
|
+
chars[offset + i] = text[i];
|
|
49
|
+
}
|
|
50
|
+
});
|
|
51
|
+
return chars;
|
|
52
|
+
};
|
|
53
|
+
const getSegmenterCtor = () => {
|
|
54
|
+
if (typeof Intl === 'undefined') {
|
|
55
|
+
return undefined;
|
|
56
|
+
}
|
|
57
|
+
const ctor = Intl.Segmenter;
|
|
58
|
+
return typeof ctor === 'function' ? ctor : undefined;
|
|
59
|
+
};
|
|
60
|
+
let cachedLocale;
|
|
61
|
+
let cachedSentenceSegmenter;
|
|
62
|
+
let cachedWordSegmenter;
|
|
63
|
+
const getSegmenters = locale => {
|
|
64
|
+
const Ctor = getSegmenterCtor();
|
|
65
|
+
if (!Ctor) {
|
|
66
|
+
return undefined;
|
|
67
|
+
}
|
|
68
|
+
if (cachedLocale !== locale || !cachedSentenceSegmenter || !cachedWordSegmenter) {
|
|
69
|
+
cachedLocale = locale;
|
|
70
|
+
cachedSentenceSegmenter = new Ctor(locale, {
|
|
71
|
+
granularity: 'sentence'
|
|
72
|
+
});
|
|
73
|
+
cachedWordSegmenter = new Ctor(locale, {
|
|
74
|
+
granularity: 'word'
|
|
75
|
+
});
|
|
76
|
+
}
|
|
77
|
+
return {
|
|
78
|
+
sentence: cachedSentenceSegmenter,
|
|
79
|
+
word: cachedWordSegmenter
|
|
80
|
+
};
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Convert a char-view into a plain string plus an index map back to content offsets.
|
|
85
|
+
*
|
|
86
|
+
* Opaque inline tokens (`null`) are represented in the string by a single sentinel
|
|
87
|
+
* character (Unicode Object Replacement Character, U+FFFC) so that:
|
|
88
|
+
* - the segmenter treats them as a word-like glyph rather than whitespace/punctuation;
|
|
89
|
+
* - they never look like a sentence terminator.
|
|
90
|
+
* `indexMap[j]` gives the content offset of string index `j`.
|
|
91
|
+
*/
|
|
92
|
+
const OBJECT_REPLACEMENT = '\uFFFC';
|
|
93
|
+
const flattenChars = chars => {
|
|
94
|
+
let text = '';
|
|
95
|
+
const indexMap = [];
|
|
96
|
+
for (let offset = 0; offset < chars.length; offset++) {
|
|
97
|
+
const ch = chars[offset];
|
|
98
|
+
text += ch === null ? OBJECT_REPLACEMENT : ch;
|
|
99
|
+
indexMap.push(offset);
|
|
100
|
+
}
|
|
101
|
+
return {
|
|
102
|
+
text,
|
|
103
|
+
indexMap
|
|
104
|
+
};
|
|
105
|
+
};
|
|
106
|
+
const stringIndexToOffset = (indexMap, strIdx, fallback) => {
|
|
107
|
+
if (strIdx < 0) {
|
|
108
|
+
return fallback;
|
|
109
|
+
}
|
|
110
|
+
if (strIdx >= indexMap.length) {
|
|
111
|
+
// One-past-the-end maps to the offset after the last char.
|
|
112
|
+
return indexMap.length > 0 ? indexMap[indexMap.length - 1] + 1 : fallback;
|
|
113
|
+
}
|
|
114
|
+
return indexMap[strIdx];
|
|
115
|
+
};
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Segment a textblock char-view into sentence spans (content-offset coordinates).
|
|
119
|
+
* Uses Intl.Segmenter when available, otherwise a conservative regex fallback.
|
|
120
|
+
*/
|
|
121
|
+
export const segmentSentences = (chars, locale) => {
|
|
122
|
+
if (chars.length === 0) {
|
|
123
|
+
return [];
|
|
124
|
+
}
|
|
125
|
+
const {
|
|
126
|
+
text,
|
|
127
|
+
indexMap
|
|
128
|
+
} = flattenChars(chars);
|
|
129
|
+
const segmenters = getSegmenters(locale);
|
|
130
|
+
if (segmenters) {
|
|
131
|
+
const spans = [];
|
|
132
|
+
for (const seg of segmenters.sentence.segment(text)) {
|
|
133
|
+
const startOffset = stringIndexToOffset(indexMap, seg.index, 0);
|
|
134
|
+
const endOffset = stringIndexToOffset(indexMap, seg.index + seg.segment.length, chars.length);
|
|
135
|
+
if (endOffset > startOffset) {
|
|
136
|
+
spans.push({
|
|
137
|
+
from: startOffset,
|
|
138
|
+
to: endOffset
|
|
139
|
+
});
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
return spans.length > 0 ? spans : [{
|
|
143
|
+
from: 0,
|
|
144
|
+
to: chars.length
|
|
145
|
+
}];
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// Regex fallback: split after a sentence terminator followed by whitespace/EOL.
|
|
149
|
+
// A `null` (OBJECT_REPLACEMENT) is never a terminator.
|
|
150
|
+
const spans = [];
|
|
151
|
+
let start = 0;
|
|
152
|
+
for (let i = 0; i < chars.length; i++) {
|
|
153
|
+
const ch = chars[i];
|
|
154
|
+
if (ch !== null && SENTENCE_TERMINATORS.has(ch)) {
|
|
155
|
+
// Consume trailing spaces as part of this sentence.
|
|
156
|
+
let end = i + 1;
|
|
157
|
+
while (end < chars.length && chars[end] !== null && isWhitespaceChar(chars[end])) {
|
|
158
|
+
end++;
|
|
159
|
+
}
|
|
160
|
+
spans.push({
|
|
161
|
+
from: start,
|
|
162
|
+
to: end
|
|
163
|
+
});
|
|
164
|
+
start = end;
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
if (start < chars.length) {
|
|
168
|
+
spans.push({
|
|
169
|
+
from: start,
|
|
170
|
+
to: chars.length
|
|
171
|
+
});
|
|
172
|
+
}
|
|
173
|
+
return spans.length > 0 ? spans : [{
|
|
174
|
+
from: 0,
|
|
175
|
+
to: chars.length
|
|
176
|
+
}];
|
|
177
|
+
};
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* Count word-like tokens within a content-offset range of the char-view.
|
|
181
|
+
* Each opaque inline token (`null`) counts as exactly one word.
|
|
182
|
+
*/
|
|
183
|
+
export const countWords = (chars, span, locale) => {
|
|
184
|
+
const slice = chars.slice(span.from, span.to);
|
|
185
|
+
if (slice.length === 0) {
|
|
186
|
+
return 0;
|
|
187
|
+
}
|
|
188
|
+
const segmenters = getSegmenters(locale);
|
|
189
|
+
const {
|
|
190
|
+
text
|
|
191
|
+
} = flattenChars(slice);
|
|
192
|
+
if (segmenters) {
|
|
193
|
+
let count = 0;
|
|
194
|
+
for (const seg of segmenters.word.segment(text)) {
|
|
195
|
+
// Object-replacement sentinel is word-like; count it.
|
|
196
|
+
if (seg.isWordLike || seg.segment.includes(OBJECT_REPLACEMENT)) {
|
|
197
|
+
count++;
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
return count;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
// Regex fallback: a run of word chars OR a single opaque token is one word.
|
|
204
|
+
let count = 0;
|
|
205
|
+
let inWord = false;
|
|
206
|
+
for (const ch of slice) {
|
|
207
|
+
if (ch === null) {
|
|
208
|
+
count++;
|
|
209
|
+
inWord = false;
|
|
210
|
+
continue;
|
|
211
|
+
}
|
|
212
|
+
if (isWordChar(ch)) {
|
|
213
|
+
if (!inWord) {
|
|
214
|
+
count++;
|
|
215
|
+
inWord = true;
|
|
216
|
+
}
|
|
217
|
+
} else {
|
|
218
|
+
inWord = false;
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
return count;
|
|
222
|
+
};
|
|
223
|
+
|
|
224
|
+
/**
|
|
225
|
+
* Return the word-token spans (content-offset coordinates) within a range. Used to decide
|
|
226
|
+
* which words a change overlaps. Each opaque inline token is its own span.
|
|
227
|
+
*/
|
|
228
|
+
export const segmentWordSpans = (chars, span, locale) => {
|
|
229
|
+
const segmenters = getSegmenters(locale);
|
|
230
|
+
const slice = chars.slice(span.from, span.to);
|
|
231
|
+
const {
|
|
232
|
+
text,
|
|
233
|
+
indexMap
|
|
234
|
+
} = flattenChars(slice);
|
|
235
|
+
const spans = [];
|
|
236
|
+
if (segmenters) {
|
|
237
|
+
for (const seg of segmenters.word.segment(text)) {
|
|
238
|
+
if (seg.isWordLike || seg.segment.includes(OBJECT_REPLACEMENT)) {
|
|
239
|
+
const from = span.from + stringIndexToOffset(indexMap, seg.index, 0);
|
|
240
|
+
const to = span.from + stringIndexToOffset(indexMap, seg.index + seg.segment.length, slice.length);
|
|
241
|
+
spans.push({
|
|
242
|
+
from,
|
|
243
|
+
to
|
|
244
|
+
});
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
return spans;
|
|
248
|
+
}
|
|
249
|
+
let wordStart = -1;
|
|
250
|
+
for (let i = 0; i < slice.length; i++) {
|
|
251
|
+
const ch = slice[i];
|
|
252
|
+
if (ch === null) {
|
|
253
|
+
if (wordStart !== -1) {
|
|
254
|
+
spans.push({
|
|
255
|
+
from: span.from + wordStart,
|
|
256
|
+
to: span.from + i
|
|
257
|
+
});
|
|
258
|
+
wordStart = -1;
|
|
259
|
+
}
|
|
260
|
+
spans.push({
|
|
261
|
+
from: span.from + i,
|
|
262
|
+
to: span.from + i + 1
|
|
263
|
+
});
|
|
264
|
+
} else if (isWordChar(ch)) {
|
|
265
|
+
if (wordStart === -1) {
|
|
266
|
+
wordStart = i;
|
|
267
|
+
}
|
|
268
|
+
} else if (wordStart !== -1) {
|
|
269
|
+
spans.push({
|
|
270
|
+
from: span.from + wordStart,
|
|
271
|
+
to: span.from + i
|
|
272
|
+
});
|
|
273
|
+
wordStart = -1;
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
if (wordStart !== -1) {
|
|
277
|
+
spans.push({
|
|
278
|
+
from: span.from + wordStart,
|
|
279
|
+
to: span.from + slice.length
|
|
280
|
+
});
|
|
281
|
+
}
|
|
282
|
+
return spans;
|
|
283
|
+
};
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Configurable thresholds for the `smart` diffType.
|
|
3
|
+
*
|
|
4
|
+
* See docs/smart-diff-design.md §3 and §6 for the exact semantics of each level.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
export const DEFAULT_SMART_THRESHOLDS = {
|
|
8
|
+
sentence: {
|
|
9
|
+
ratio: 0.4,
|
|
10
|
+
minChanged: 2
|
|
11
|
+
},
|
|
12
|
+
paragraph: {
|
|
13
|
+
ratio: 0.4,
|
|
14
|
+
minChanged: 2
|
|
15
|
+
},
|
|
16
|
+
node: {
|
|
17
|
+
ratio: 0.6,
|
|
18
|
+
textBearingRatio: 0.6
|
|
19
|
+
}
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Merge caller overrides onto the defaults. Overrides are shallow-merged per level so a
|
|
24
|
+
* caller can tune a single field (e.g. only `sentence.ratio`) without re-specifying the
|
|
25
|
+
* rest.
|
|
26
|
+
*/
|
|
27
|
+
export const resolveThresholds = overrides => {
|
|
28
|
+
if (!overrides) {
|
|
29
|
+
return DEFAULT_SMART_THRESHOLDS;
|
|
30
|
+
}
|
|
31
|
+
return {
|
|
32
|
+
sentence: {
|
|
33
|
+
...DEFAULT_SMART_THRESHOLDS.sentence,
|
|
34
|
+
...overrides.sentence
|
|
35
|
+
},
|
|
36
|
+
paragraph: {
|
|
37
|
+
...DEFAULT_SMART_THRESHOLDS.paragraph,
|
|
38
|
+
...overrides.paragraph
|
|
39
|
+
},
|
|
40
|
+
node: {
|
|
41
|
+
...DEFAULT_SMART_THRESHOLDS.node,
|
|
42
|
+
...overrides.node
|
|
43
|
+
}
|
|
44
|
+
};
|
|
45
|
+
};
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { convertToInlineCss } from '@atlaskit/editor-common/lazy-node-view';
|
|
2
2
|
import { Decoration } from '@atlaskit/editor-prosemirror/view';
|
|
3
|
-
import {
|
|
3
|
+
import { isExtendedEnabled } from '../isExtendedEnabled';
|
|
4
4
|
import { standardDecorationMarkerVariable, deletedDecorationMarkerVariable, editingStyleQuoteNode, editingStyleRuleNode, editingStyleCardBlockNode, editingStyleNode, deletedContentStyleNew, deletedStyleQuoteNode, addedCellOverlayStyle, deletedCellOverlayStyle } from './colorSchemes/standard';
|
|
5
5
|
import { traditionalDecorationMarkerVariableActive, traditionalDecorationMarkerVariableNew, traditionalDeletedDecorationMarkerVariableActive, traditionalDeletedDecorationMarkerVariableNew, traditionalStyleQuoteNodeActive, traditionalStyleQuoteNodeNew, traditionalStyleRuleNodeActive, traditionalStyleRuleNodeNew, traditionalStyleCardBlockNodeActive, traditionalStyleCardBlockNodeNew, traditionalStyleNodeActive, traditionalStyleNodeNew, getDeletedTraditionalInlineStyle, deletedTraditionalStyleQuoteNode, traditionalAddedCellOverlayStyle, deletedTraditionalCellOverlayStyle } from './colorSchemes/traditional';
|
|
6
6
|
import { createBlockIndicatorAnchorWidgets } from './createAnchorDecorationWidgets';
|
|
@@ -20,7 +20,8 @@ const getBlockNodeStyle = ({
|
|
|
20
20
|
nodeName,
|
|
21
21
|
colorScheme,
|
|
22
22
|
isInserted = true,
|
|
23
|
-
isActive = false
|
|
23
|
+
isActive = false,
|
|
24
|
+
diffType
|
|
24
25
|
}) => {
|
|
25
26
|
const isTraditional = colorScheme === 'traditional';
|
|
26
27
|
if (['mediaSingle', 'mediaGroup', 'table',
|
|
@@ -34,13 +35,13 @@ const getBlockNodeStyle = ({
|
|
|
34
35
|
// Media nodes inside mediaSingle should not get position:relative
|
|
35
36
|
// as it shifts the image outside its parent container (e.g. panel)
|
|
36
37
|
if (nodeName === 'media') {
|
|
37
|
-
if (!isInserted &&
|
|
38
|
+
if (!isInserted && isExtendedEnabled(diffType)) {
|
|
38
39
|
return isTraditional ? getDeletedTraditionalInlineStyle(false) : deletedDecorationMarkerVariable;
|
|
39
40
|
}
|
|
40
41
|
return isTraditional ? isActive ? traditionalStyleNodeActive : traditionalStyleNodeNew : editingStyleNode;
|
|
41
42
|
}
|
|
42
43
|
if (['tableCell', 'tableHeader'].includes(nodeName)) {
|
|
43
|
-
if (
|
|
44
|
+
if (isExtendedEnabled(diffType)) {
|
|
44
45
|
// This is used for positioning the cell overlay widget decorations
|
|
45
46
|
return convertToInlineCss({
|
|
46
47
|
position: 'relative'
|
|
@@ -50,13 +51,13 @@ const getBlockNodeStyle = ({
|
|
|
50
51
|
return undefined;
|
|
51
52
|
}
|
|
52
53
|
if (nodeName === 'panel') {
|
|
53
|
-
if (!isInserted &&
|
|
54
|
+
if (!isInserted && isExtendedEnabled(diffType)) {
|
|
54
55
|
return isTraditional ? getDeletedTraditionalInlineStyle(false) : deletedDecorationMarkerVariable;
|
|
55
56
|
}
|
|
56
57
|
return isTraditional ? isActive ? traditionalStyleNodeActive : traditionalStyleNodeNew : editingStyleNode;
|
|
57
58
|
}
|
|
58
59
|
if (['extension', 'embedCard', 'listItem'].includes(nodeName)) {
|
|
59
|
-
if (
|
|
60
|
+
if (isExtendedEnabled(diffType)) {
|
|
60
61
|
if (isInserted) {
|
|
61
62
|
return isTraditional && isActive ? traditionalDecorationMarkerVariableActive : isTraditional ? traditionalDecorationMarkerVariableNew : standardDecorationMarkerVariable;
|
|
62
63
|
} else {
|
|
@@ -69,7 +70,7 @@ const getBlockNodeStyle = ({
|
|
|
69
70
|
return isTraditional && isActive ? traditionalDecorationMarkerVariableActive : isTraditional ? traditionalDecorationMarkerVariableNew : standardDecorationMarkerVariable;
|
|
70
71
|
}
|
|
71
72
|
if (nodeName === 'blockquote') {
|
|
72
|
-
if (
|
|
73
|
+
if (isExtendedEnabled(diffType)) {
|
|
73
74
|
if (isInserted) {
|
|
74
75
|
return isTraditional ? isActive ? traditionalStyleQuoteNodeActive : traditionalStyleQuoteNodeNew : editingStyleQuoteNode;
|
|
75
76
|
} else {
|
|
@@ -79,7 +80,7 @@ const getBlockNodeStyle = ({
|
|
|
79
80
|
return isTraditional ? isActive ? traditionalStyleQuoteNodeActive : traditionalStyleQuoteNodeNew : editingStyleQuoteNode;
|
|
80
81
|
}
|
|
81
82
|
if (nodeName === 'rule') {
|
|
82
|
-
if (
|
|
83
|
+
if (isExtendedEnabled(diffType)) {
|
|
83
84
|
if (isInserted) {
|
|
84
85
|
return isTraditional ? isActive ? traditionalStyleRuleNodeActive : traditionalStyleRuleNodeNew : editingStyleRuleNode;
|
|
85
86
|
} else {
|
|
@@ -89,7 +90,7 @@ const getBlockNodeStyle = ({
|
|
|
89
90
|
return isTraditional ? isActive ? traditionalStyleRuleNodeActive : traditionalStyleRuleNodeNew : editingStyleRuleNode;
|
|
90
91
|
}
|
|
91
92
|
if (nodeName === 'blockCard') {
|
|
92
|
-
if (
|
|
93
|
+
if (isExtendedEnabled(diffType)) {
|
|
93
94
|
if (isInserted) {
|
|
94
95
|
return isTraditional ? isActive ? traditionalStyleCardBlockNodeActive : traditionalStyleCardBlockNodeNew : editingStyleCardBlockNode;
|
|
95
96
|
} else {
|
|
@@ -98,7 +99,7 @@ const getBlockNodeStyle = ({
|
|
|
98
99
|
}
|
|
99
100
|
return isTraditional ? isActive ? traditionalStyleCardBlockNodeActive : traditionalStyleCardBlockNodeNew : editingStyleCardBlockNode;
|
|
100
101
|
}
|
|
101
|
-
if (
|
|
102
|
+
if (isExtendedEnabled(diffType)) {
|
|
102
103
|
if (isInserted) {
|
|
103
104
|
return isTraditional ? isActive ? traditionalStyleNodeActive : traditionalStyleNodeNew : editingStyleNode;
|
|
104
105
|
} else {
|
|
@@ -123,7 +124,8 @@ export const createBlockChangedDecoration = ({
|
|
|
123
124
|
isActive = false,
|
|
124
125
|
shouldHideDeleted = false,
|
|
125
126
|
showIndicators = false,
|
|
126
|
-
doc
|
|
127
|
+
doc,
|
|
128
|
+
diffType
|
|
127
129
|
}) => {
|
|
128
130
|
const decorations = [];
|
|
129
131
|
const diffId = crypto.randomUUID();
|
|
@@ -134,11 +136,12 @@ export const createBlockChangedDecoration = ({
|
|
|
134
136
|
decorationType: 'block',
|
|
135
137
|
diffId,
|
|
136
138
|
isActive,
|
|
137
|
-
nodeName: change.name
|
|
139
|
+
nodeName: change.name,
|
|
140
|
+
diffType
|
|
138
141
|
}))];
|
|
139
142
|
}
|
|
140
143
|
let style;
|
|
141
|
-
if (
|
|
144
|
+
if (isExtendedEnabled(diffType) && ['tableCell', 'tableHeader'].includes(change.name)) {
|
|
142
145
|
const cellOverlay = document.createElement('div');
|
|
143
146
|
const cellOverlayStyle = isInserted ? colorScheme === 'traditional' ? traditionalAddedCellOverlayStyle : addedCellOverlayStyle : colorScheme === 'traditional' ? deletedTraditionalCellOverlayStyle : deletedCellOverlayStyle;
|
|
144
147
|
cellOverlay.setAttribute('style', cellOverlayStyle);
|
|
@@ -150,18 +153,20 @@ export const createBlockChangedDecoration = ({
|
|
|
150
153
|
key: 'cell-overlay-decoration'
|
|
151
154
|
}));
|
|
152
155
|
}
|
|
153
|
-
if (
|
|
156
|
+
if (isExtendedEnabled(diffType)) {
|
|
154
157
|
style = getBlockNodeStyle({
|
|
155
158
|
nodeName: change.name,
|
|
156
159
|
colorScheme,
|
|
157
160
|
isInserted,
|
|
158
|
-
isActive
|
|
161
|
+
isActive,
|
|
162
|
+
diffType
|
|
159
163
|
});
|
|
160
164
|
} else {
|
|
161
165
|
style = getBlockNodeStyle({
|
|
162
166
|
nodeName: change.name,
|
|
163
167
|
colorScheme,
|
|
164
|
-
isActive
|
|
168
|
+
isActive,
|
|
169
|
+
diffType
|
|
165
170
|
});
|
|
166
171
|
}
|
|
167
172
|
const className = getNodeClass(change.name);
|
|
@@ -174,10 +179,11 @@ export const createBlockChangedDecoration = ({
|
|
|
174
179
|
decorationType: 'block',
|
|
175
180
|
diffId,
|
|
176
181
|
isActive,
|
|
177
|
-
nodeName: change.name
|
|
182
|
+
nodeName: change.name,
|
|
183
|
+
diffType
|
|
178
184
|
})));
|
|
179
185
|
}
|
|
180
|
-
if (decorations.length > 0 && showIndicators && doc &&
|
|
186
|
+
if (decorations.length > 0 && showIndicators && doc && isExtendedEnabled(diffType)) {
|
|
181
187
|
decorations.push(...createBlockIndicatorAnchorWidgets({
|
|
182
188
|
doc,
|
|
183
189
|
from: change.from,
|
|
@@ -2,7 +2,7 @@ import { areNodesEqualIgnoreAttrs } from '@atlaskit/editor-common/utils/document
|
|
|
2
2
|
import { findParentNodeClosestToPos } from '@atlaskit/editor-prosemirror/utils';
|
|
3
3
|
import { Decoration } from '@atlaskit/editor-prosemirror/view';
|
|
4
4
|
import { TableMap } from '@atlaskit/editor-tables/table-map';
|
|
5
|
-
import {
|
|
5
|
+
import { isExtendedEnabled } from '../isExtendedEnabled';
|
|
6
6
|
import { addedCellOverlayStyle, deletedRowStyle, deletedCellOverlayStyle } from './colorSchemes/standard';
|
|
7
7
|
import { deletedTraditionalRowStyle, deletedTraditionalCellOverlayStyle, traditionalAddedCellOverlayStyle } from './colorSchemes/traditional';
|
|
8
8
|
import { buildDiffDecorationSpec } from './decorationKeys';
|
|
@@ -13,7 +13,8 @@ import { findSafeInsertPos } from './utils/findSafeInsertPos';
|
|
|
13
13
|
const extractChangedRows = ({
|
|
14
14
|
change,
|
|
15
15
|
originalDoc,
|
|
16
|
-
newDoc
|
|
16
|
+
newDoc,
|
|
17
|
+
diffType
|
|
17
18
|
}) => {
|
|
18
19
|
const changedRows = [];
|
|
19
20
|
|
|
@@ -50,7 +51,7 @@ const extractChangedRows = ({
|
|
|
50
51
|
|
|
51
52
|
// Check if this row overlaps with the deletion range
|
|
52
53
|
const rowOverlapsChange = rowStart >= changeStartInTable && rowStart < changeEndInTable || rowEnd > changeStartInTable && rowEnd <= changeEndInTable || rowStart < changeStartInTable && rowEnd > changeEndInTable;
|
|
53
|
-
if (rowOverlapsChange && rowNode.type.name === 'tableRow' && (
|
|
54
|
+
if (rowOverlapsChange && rowNode.type.name === 'tableRow' && (isExtendedEnabled(diffType) || !isEmptyRow(rowNode))) {
|
|
54
55
|
const startOfRow = newTableMap.mapByRow.slice().reverse().find(row => row[0] + tableNew.pos < change.fromB && change.fromB < row[row.length - 1] + tableNew.pos);
|
|
55
56
|
changedRows.push({
|
|
56
57
|
rowIndex,
|
|
@@ -101,9 +102,9 @@ const isEmptyRow = rowNode => {
|
|
|
101
102
|
/**
|
|
102
103
|
* Creates a DOM representation of a deleted table row
|
|
103
104
|
*/
|
|
104
|
-
const createChangedRowDOM = (rowNode, nodeViewSerializer, colorScheme, isInserted) => {
|
|
105
|
+
const createChangedRowDOM = (rowNode, nodeViewSerializer, colorScheme, isInserted, diffType) => {
|
|
105
106
|
const tr = document.createElement('tr');
|
|
106
|
-
if (
|
|
107
|
+
if (isExtendedEnabled(diffType)) {
|
|
107
108
|
if (!isInserted) {
|
|
108
109
|
tr.setAttribute('style', colorScheme === 'traditional' ? deletedTraditionalRowStyle : deletedRowStyle);
|
|
109
110
|
}
|
|
@@ -117,7 +118,7 @@ const createChangedRowDOM = (rowNode, nodeViewSerializer, colorScheme, isInserte
|
|
|
117
118
|
if (cellNode.type.name === 'tableCell' || cellNode.type.name === 'tableHeader') {
|
|
118
119
|
const nodeView = nodeViewSerializer.tryCreateNodeView(cellNode);
|
|
119
120
|
if (nodeView) {
|
|
120
|
-
if (nodeView instanceof HTMLElement &&
|
|
121
|
+
if (nodeView instanceof HTMLElement && isExtendedEnabled(diffType)) {
|
|
121
122
|
const overlay = document.createElement('span');
|
|
122
123
|
const overlayStyle = colorScheme === 'traditional' ? isInserted ? traditionalAddedCellOverlayStyle : deletedTraditionalCellOverlayStyle : isInserted ? addedCellOverlayStyle : deletedCellOverlayStyle;
|
|
123
124
|
overlay.setAttribute('style', overlayStyle);
|
|
@@ -142,7 +143,8 @@ const createChangedRowDOM = (rowNode, nodeViewSerializer, colorScheme, isInserte
|
|
|
142
143
|
const expandDiffForChangedRows = ({
|
|
143
144
|
changes,
|
|
144
145
|
originalDoc,
|
|
145
|
-
newDoc
|
|
146
|
+
newDoc,
|
|
147
|
+
diffType
|
|
146
148
|
}) => {
|
|
147
149
|
const rowInfo = [];
|
|
148
150
|
for (const change of changes) {
|
|
@@ -150,7 +152,8 @@ const expandDiffForChangedRows = ({
|
|
|
150
152
|
const changedRows = extractChangedRows({
|
|
151
153
|
change,
|
|
152
154
|
originalDoc,
|
|
153
|
-
newDoc
|
|
155
|
+
newDoc,
|
|
156
|
+
diffType
|
|
154
157
|
});
|
|
155
158
|
if (changedRows.length > 0) {
|
|
156
159
|
rowInfo.push(...changedRows);
|
|
@@ -168,16 +171,18 @@ export const createChangedRowDecorationWidgets = ({
|
|
|
168
171
|
newDoc,
|
|
169
172
|
nodeViewSerializer,
|
|
170
173
|
colorScheme,
|
|
171
|
-
isInserted = false
|
|
174
|
+
isInserted = false,
|
|
175
|
+
diffType
|
|
172
176
|
}) => {
|
|
173
177
|
// First, expand the changes to include complete deleted rows
|
|
174
178
|
const changedRows = expandDiffForChangedRows({
|
|
175
179
|
changes: changes.filter(change => change.deleted.length > 0),
|
|
176
180
|
originalDoc,
|
|
177
|
-
newDoc
|
|
181
|
+
newDoc,
|
|
182
|
+
diffType
|
|
178
183
|
});
|
|
179
184
|
return changedRows.map(changedRow => {
|
|
180
|
-
const rowDOM = createChangedRowDOM(changedRow.rowNode, nodeViewSerializer, colorScheme, isInserted);
|
|
185
|
+
const rowDOM = createChangedRowDOM(changedRow.rowNode, nodeViewSerializer, colorScheme, isInserted, diffType);
|
|
181
186
|
|
|
182
187
|
// Find safe insertion position for the deleted row
|
|
183
188
|
const safeInsertPos = findSafeInsertPos(newDoc, changedRow.fromB - 1,
|
|
@@ -188,7 +193,8 @@ export const createChangedRowDecorationWidgets = ({
|
|
|
188
193
|
...buildDiffDecorationSpec({
|
|
189
194
|
decorationType: 'widget',
|
|
190
195
|
diffId,
|
|
191
|
-
isActive: false
|
|
196
|
+
isActive: false,
|
|
197
|
+
diffType
|
|
192
198
|
})
|
|
193
199
|
});
|
|
194
200
|
});
|