@x-oasis/map-diff-range 0.1.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/.turbo/turbo-build.log +18 -0
- package/CHANGELOG.md +14 -0
- package/README.md +275 -0
- package/dist/index.d.ts +27 -0
- package/dist/index.js +8 -0
- package/dist/map-diff-range.cjs.development.js +220 -0
- package/dist/map-diff-range.cjs.development.js.map +1 -0
- package/dist/map-diff-range.cjs.production.min.js +2 -0
- package/dist/map-diff-range.cjs.production.min.js.map +1 -0
- package/dist/map-diff-range.esm.js +213 -0
- package/dist/map-diff-range.esm.js.map +1 -0
- package/examples/.turbo/turbo-build.log +12 -0
- package/examples/README.md +111 -0
- package/examples/index.html +13 -0
- package/examples/package.json +28 -0
- package/examples/src/App.tsx +257 -0
- package/examples/src/index.css +253 -0
- package/examples/src/main.tsx +10 -0
- package/examples/tsconfig.json +25 -0
- package/examples/tsconfig.node.json +10 -0
- package/examples/vite.config.ts +30 -0
- package/package.json +25 -0
- package/src/index.ts +352 -0
- package/test/index.test.ts +234 -0
- package/tsconfig.build.json +11 -0
- package/tsconfig.json +6 -0
- package/vitest.config.ts +21 -0
|
@@ -0,0 +1,253 @@
|
|
|
1
|
+
* {
|
|
2
|
+
margin: 0;
|
|
3
|
+
padding: 0;
|
|
4
|
+
box-sizing: border-box;
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
body {
|
|
8
|
+
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
|
|
9
|
+
background: #f5f5f5;
|
|
10
|
+
padding: 20px;
|
|
11
|
+
line-height: 1.6;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
.container {
|
|
15
|
+
max-width: 1600px;
|
|
16
|
+
margin: 0 auto;
|
|
17
|
+
background: white;
|
|
18
|
+
border-radius: 8px;
|
|
19
|
+
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
|
|
20
|
+
padding: 30px;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
h1 {
|
|
24
|
+
color: #333;
|
|
25
|
+
margin-bottom: 10px;
|
|
26
|
+
font-size: 28px;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
.subtitle {
|
|
30
|
+
color: #666;
|
|
31
|
+
margin-bottom: 30px;
|
|
32
|
+
font-size: 14px;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
.section {
|
|
36
|
+
margin-bottom: 30px;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
.section-title {
|
|
40
|
+
font-size: 18px;
|
|
41
|
+
font-weight: 600;
|
|
42
|
+
color: #333;
|
|
43
|
+
margin-bottom: 15px;
|
|
44
|
+
padding-bottom: 10px;
|
|
45
|
+
border-bottom: 2px solid #e0e0e0;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
.file-comparison {
|
|
49
|
+
display: grid;
|
|
50
|
+
grid-template-columns: 1fr 1fr 1fr;
|
|
51
|
+
gap: 20px;
|
|
52
|
+
margin-bottom: 20px;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
.file-panel {
|
|
56
|
+
border: 1px solid #ddd;
|
|
57
|
+
border-radius: 4px;
|
|
58
|
+
overflow: hidden;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
.file-header {
|
|
62
|
+
background: #f8f8f8;
|
|
63
|
+
padding: 10px 15px;
|
|
64
|
+
font-weight: 600;
|
|
65
|
+
color: #555;
|
|
66
|
+
border-bottom: 1px solid #ddd;
|
|
67
|
+
font-size: 13px;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
.file-content {
|
|
71
|
+
padding: 15px;
|
|
72
|
+
font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace;
|
|
73
|
+
font-size: 13px;
|
|
74
|
+
line-height: 1.8;
|
|
75
|
+
white-space: pre-wrap;
|
|
76
|
+
word-wrap: break-word;
|
|
77
|
+
max-height: 400px;
|
|
78
|
+
overflow-y: auto;
|
|
79
|
+
background: #fff;
|
|
80
|
+
resize: vertical;
|
|
81
|
+
min-height: 200px;
|
|
82
|
+
width: 100%;
|
|
83
|
+
border: none;
|
|
84
|
+
outline: none;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
.file-content:focus {
|
|
88
|
+
outline: none;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
.controls {
|
|
92
|
+
display: grid;
|
|
93
|
+
grid-template-columns: 1fr 1fr auto;
|
|
94
|
+
gap: 15px;
|
|
95
|
+
margin-bottom: 20px;
|
|
96
|
+
align-items: end;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
.control-group {
|
|
100
|
+
display: flex;
|
|
101
|
+
flex-direction: column;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
.control-group label {
|
|
105
|
+
font-size: 12px;
|
|
106
|
+
font-weight: 600;
|
|
107
|
+
color: #555;
|
|
108
|
+
margin-bottom: 5px;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
.control-group input {
|
|
112
|
+
padding: 10px;
|
|
113
|
+
border: 1px solid #ddd;
|
|
114
|
+
border-radius: 4px;
|
|
115
|
+
font-size: 14px;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
.control-group input:focus {
|
|
119
|
+
outline: none;
|
|
120
|
+
border-color: #4a90e2;
|
|
121
|
+
box-shadow: 0 0 0 3px rgba(74, 144, 226, 0.1);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
button {
|
|
125
|
+
padding: 10px 20px;
|
|
126
|
+
background: #4a90e2;
|
|
127
|
+
color: white;
|
|
128
|
+
border: none;
|
|
129
|
+
border-radius: 4px;
|
|
130
|
+
font-size: 14px;
|
|
131
|
+
font-weight: 600;
|
|
132
|
+
cursor: pointer;
|
|
133
|
+
transition: background 0.2s;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
button:hover {
|
|
137
|
+
background: #357abd;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
button:active {
|
|
141
|
+
transform: translateY(1px);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
.result-panel {
|
|
145
|
+
border: 2px solid #4a90e2;
|
|
146
|
+
border-radius: 4px;
|
|
147
|
+
padding: 15px;
|
|
148
|
+
background: #f0f7ff;
|
|
149
|
+
margin-top: 20px;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
.result-title {
|
|
153
|
+
font-weight: 600;
|
|
154
|
+
color: #4a90e2;
|
|
155
|
+
margin-bottom: 10px;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
.result-content {
|
|
159
|
+
font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace;
|
|
160
|
+
font-size: 13px;
|
|
161
|
+
line-height: 1.8;
|
|
162
|
+
white-space: pre-wrap;
|
|
163
|
+
word-wrap: break-word;
|
|
164
|
+
background: white;
|
|
165
|
+
padding: 15px;
|
|
166
|
+
border-radius: 4px;
|
|
167
|
+
border: 1px solid #ddd;
|
|
168
|
+
max-height: 400px;
|
|
169
|
+
overflow-y: auto;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
.info-box {
|
|
173
|
+
background: #e7f3ff;
|
|
174
|
+
border-left: 4px solid #4a90e2;
|
|
175
|
+
padding: 12px;
|
|
176
|
+
margin-bottom: 20px;
|
|
177
|
+
border-radius: 4px;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
.info-box strong {
|
|
181
|
+
color: #4a90e2;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
.analysis-panel {
|
|
185
|
+
margin-top: 20px;
|
|
186
|
+
padding: 15px;
|
|
187
|
+
background: #f9f9f9;
|
|
188
|
+
border-radius: 4px;
|
|
189
|
+
border: 1px solid #ddd;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
.analysis-item {
|
|
193
|
+
margin-bottom: 15px;
|
|
194
|
+
padding: 10px;
|
|
195
|
+
background: white;
|
|
196
|
+
border-radius: 4px;
|
|
197
|
+
border: 1px solid #e0e0e0;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
.analysis-item strong {
|
|
201
|
+
color: #4a90e2;
|
|
202
|
+
display: block;
|
|
203
|
+
margin-bottom: 5px;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
.analysis-value {
|
|
207
|
+
font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace;
|
|
208
|
+
font-size: 12px;
|
|
209
|
+
color: #333;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
.diff-entry {
|
|
213
|
+
margin: 5px 0;
|
|
214
|
+
padding: 5px;
|
|
215
|
+
border-radius: 3px;
|
|
216
|
+
font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace;
|
|
217
|
+
font-size: 12px;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
.diff-entry.insert {
|
|
221
|
+
background: #d4edda;
|
|
222
|
+
color: #155724;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
.diff-entry.delete {
|
|
226
|
+
background: #f8d7da;
|
|
227
|
+
color: #721c24;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
.diff-entry.equal {
|
|
231
|
+
background: #f8f9fa;
|
|
232
|
+
color: #333;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
.range-info {
|
|
236
|
+
display: inline-block;
|
|
237
|
+
margin: 0 5px;
|
|
238
|
+
padding: 2px 6px;
|
|
239
|
+
background: #e7f3ff;
|
|
240
|
+
border-radius: 3px;
|
|
241
|
+
font-family: monospace;
|
|
242
|
+
font-size: 12px;
|
|
243
|
+
color: #4a90e2;
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
.error-message {
|
|
247
|
+
padding: 10px;
|
|
248
|
+
background: #f8d7da;
|
|
249
|
+
color: #721c24;
|
|
250
|
+
border-radius: 4px;
|
|
251
|
+
margin-top: 10px;
|
|
252
|
+
border: 1px solid #f5c6cb;
|
|
253
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
{
|
|
2
|
+
"compilerOptions": {
|
|
3
|
+
"target": "ES2020",
|
|
4
|
+
"useDefineForClassFields": true,
|
|
5
|
+
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
|
6
|
+
"module": "ESNext",
|
|
7
|
+
"skipLibCheck": true,
|
|
8
|
+
"moduleResolution": "bundler",
|
|
9
|
+
"allowImportingTsExtensions": true,
|
|
10
|
+
"resolveJsonModule": true,
|
|
11
|
+
"isolatedModules": true,
|
|
12
|
+
"noEmit": true,
|
|
13
|
+
"jsx": "react-jsx",
|
|
14
|
+
"strict": true,
|
|
15
|
+
"noUnusedLocals": true,
|
|
16
|
+
"noUnusedParameters": true,
|
|
17
|
+
"noFallthroughCasesInSwitch": true,
|
|
18
|
+
"baseUrl": ".",
|
|
19
|
+
"paths": {
|
|
20
|
+
"@x-oasis/map-diff-range": ["../src/index.ts"]
|
|
21
|
+
}
|
|
22
|
+
},
|
|
23
|
+
"include": ["src"],
|
|
24
|
+
"references": [{ "path": "./tsconfig.node.json" }]
|
|
25
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { defineConfig } from 'vite';
|
|
2
|
+
import react from '@vitejs/plugin-react';
|
|
3
|
+
import path from 'path';
|
|
4
|
+
|
|
5
|
+
// 如果设置了 GITHUB_PAGES 环境变量,使用仓库名和子路径作为 base 路径
|
|
6
|
+
// 格式: owner/repo-name,我们只需要 repo-name
|
|
7
|
+
// 子路径从 GITHUB_PAGES_PATH 环境变量获取,例如: map-diff-range
|
|
8
|
+
const getBasePath = () => {
|
|
9
|
+
if (process.env.GITHUB_PAGES === 'true') {
|
|
10
|
+
const repo = process.env.GITHUB_REPOSITORY || 'red-armor/x-oasis';
|
|
11
|
+
const repoName = repo.split('/')[1];
|
|
12
|
+
const subPath = process.env.GITHUB_PAGES_PATH || 'map-diff-range';
|
|
13
|
+
return `/${repoName}/${subPath}/`;
|
|
14
|
+
}
|
|
15
|
+
return '/';
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
export default defineConfig({
|
|
19
|
+
base: getBasePath(),
|
|
20
|
+
plugins: [react()],
|
|
21
|
+
resolve: {
|
|
22
|
+
alias: {
|
|
23
|
+
'@x-oasis/map-diff-range': path.resolve(__dirname, '../src/index.ts'),
|
|
24
|
+
},
|
|
25
|
+
},
|
|
26
|
+
server: {
|
|
27
|
+
port: 3000,
|
|
28
|
+
open: true,
|
|
29
|
+
},
|
|
30
|
+
});
|
package/package.json
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@x-oasis/map-diff-range",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Range mapping and change analysis tool based on diff-match-patch",
|
|
5
|
+
"main": "dist/index.js",
|
|
6
|
+
"typings": "dist/index.d.ts",
|
|
7
|
+
"module": "dist/map-diff-range.esm.js",
|
|
8
|
+
"publishConfig": {
|
|
9
|
+
"access": "public"
|
|
10
|
+
},
|
|
11
|
+
"author": "",
|
|
12
|
+
"license": "ISC",
|
|
13
|
+
"devDependencies": {
|
|
14
|
+
"tsdx": "^0.14.1"
|
|
15
|
+
},
|
|
16
|
+
"dependencies": {
|
|
17
|
+
"diff-match-patch": "^1.0.5"
|
|
18
|
+
},
|
|
19
|
+
"scripts": {
|
|
20
|
+
"build": "tsdx build --tsconfig tsconfig.build.json",
|
|
21
|
+
"clean": "rimraf ./dist",
|
|
22
|
+
"test": "vitest",
|
|
23
|
+
"compile": "tsc -p tsconfig.build.json"
|
|
24
|
+
}
|
|
25
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,352 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 基于 diff-match-patch 的 range 映射与变更分析工具
|
|
3
|
+
* 用于在 originalContent / currentContent / finalContent 之间映射 range,并分析片段级变更
|
|
4
|
+
*
|
|
5
|
+
* 参考: https://github.com/red-armor/x-oasis/blob/main/packages/diff/diff-match-patch/src/index.ts#L123
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { diff_match_patch } from 'diff-match-patch';
|
|
9
|
+
|
|
10
|
+
/** diff 操作常量,与 diff-match-patch 一致 */
|
|
11
|
+
const DIFF_DELETE = -1;
|
|
12
|
+
const DIFF_INSERT = 1;
|
|
13
|
+
const DIFF_EQUAL = 0;
|
|
14
|
+
|
|
15
|
+
/** 单端 range,start 含头,end 含尾(substring 语义) */
|
|
16
|
+
export interface Range {
|
|
17
|
+
start: number;
|
|
18
|
+
end: number;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** 片段级变更分析结果 */
|
|
22
|
+
export interface FragmentChangeAnalysis {
|
|
23
|
+
/** 原始片段内容 */
|
|
24
|
+
originalFragment: string;
|
|
25
|
+
/** 最终片段内容 */
|
|
26
|
+
finalFragment: string;
|
|
27
|
+
/** 是否完全相同 */
|
|
28
|
+
equal: boolean;
|
|
29
|
+
/** 仅删除(无新增) */
|
|
30
|
+
onlyDeletion: boolean;
|
|
31
|
+
/** 仅新增(无删除) */
|
|
32
|
+
onlyInsertion: boolean;
|
|
33
|
+
/** 既有删除又有新增(替换) */
|
|
34
|
+
replacement: boolean;
|
|
35
|
+
/** 语义化描述(简短) */
|
|
36
|
+
summary: string;
|
|
37
|
+
/** 详细 diff 条目,便于上层做展示或进一步处理 */
|
|
38
|
+
diffs: Array<[number, string]>;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* 将「新内容」中的 offset range 映射到「旧内容」中的 offset range
|
|
43
|
+
* 等价于 x-oasis 的 mapCurrentRangeToOriginal:diffs = diff_main(older, newer),给定 newer 上的 [start,end],返回 older 上的 [start,end]
|
|
44
|
+
*
|
|
45
|
+
* @param olderContent 旧内容(diff 的 text1)
|
|
46
|
+
* @param newerContent 新内容(diff 的 text2)
|
|
47
|
+
* @param startOffset 新内容中的 range 起始 offset
|
|
48
|
+
* @param endOffset 新内容中的 range 结束 offset(不含尾,即 [startOffset, endOffset) 或含尾由调用方约定,此处按 substring 含头含尾)
|
|
49
|
+
* @returns 旧内容中对应的 range;若越界或无效则返回 { start: 0, end: 0 }
|
|
50
|
+
*/
|
|
51
|
+
export function mapNewerRangeToOlder(
|
|
52
|
+
olderContent: string,
|
|
53
|
+
newerContent: string,
|
|
54
|
+
startOffset: number,
|
|
55
|
+
endOffset: number
|
|
56
|
+
): Range {
|
|
57
|
+
const dmp = new diff_match_patch();
|
|
58
|
+
const diffs = dmp.diff_main(olderContent, newerContent);
|
|
59
|
+
dmp.diff_cleanupSemantic(diffs);
|
|
60
|
+
|
|
61
|
+
let newerOffset = 0;
|
|
62
|
+
let olderOffset = 0;
|
|
63
|
+
let olderStart: number | null = null;
|
|
64
|
+
let olderEnd: number | null = null;
|
|
65
|
+
|
|
66
|
+
for (let i = 0; i < diffs.length; i++) {
|
|
67
|
+
const [op, text] = diffs[i];
|
|
68
|
+
const len = text.length;
|
|
69
|
+
const nextDiff = i < diffs.length - 1 ? diffs[i + 1] : null;
|
|
70
|
+
|
|
71
|
+
if (op === DIFF_EQUAL) {
|
|
72
|
+
const rangeStart = newerOffset;
|
|
73
|
+
const rangeEnd = newerOffset + len;
|
|
74
|
+
|
|
75
|
+
if (
|
|
76
|
+
olderStart === null &&
|
|
77
|
+
startOffset >= rangeStart &&
|
|
78
|
+
startOffset < rangeEnd
|
|
79
|
+
) {
|
|
80
|
+
olderStart = olderOffset + (startOffset - rangeStart);
|
|
81
|
+
}
|
|
82
|
+
if (
|
|
83
|
+
olderEnd === null &&
|
|
84
|
+
endOffset > rangeStart &&
|
|
85
|
+
endOffset <= rangeEnd
|
|
86
|
+
) {
|
|
87
|
+
const offsetInRange = endOffset - rangeStart;
|
|
88
|
+
olderEnd = olderOffset + offsetInRange;
|
|
89
|
+
if (endOffset === rangeEnd && nextDiff && nextDiff[0] === DIFF_DELETE) {
|
|
90
|
+
olderEnd = olderOffset + len + nextDiff[1].length;
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
newerOffset += len;
|
|
95
|
+
olderOffset += len;
|
|
96
|
+
} else if (op === DIFF_INSERT) {
|
|
97
|
+
const rangeStart = newerOffset;
|
|
98
|
+
const rangeEnd = newerOffset + len;
|
|
99
|
+
|
|
100
|
+
if (
|
|
101
|
+
olderStart === null &&
|
|
102
|
+
startOffset >= rangeStart &&
|
|
103
|
+
startOffset < rangeEnd
|
|
104
|
+
) {
|
|
105
|
+
olderStart = olderOffset;
|
|
106
|
+
}
|
|
107
|
+
if (
|
|
108
|
+
olderEnd === null &&
|
|
109
|
+
endOffset > rangeStart &&
|
|
110
|
+
endOffset <= rangeEnd
|
|
111
|
+
) {
|
|
112
|
+
olderEnd = olderOffset;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
newerOffset += len;
|
|
116
|
+
} else if (op === DIFF_DELETE) {
|
|
117
|
+
if (olderStart === null && startOffset <= newerOffset) {
|
|
118
|
+
olderStart = olderOffset;
|
|
119
|
+
}
|
|
120
|
+
if (olderEnd === null && endOffset <= newerOffset) {
|
|
121
|
+
olderEnd = endOffset === newerOffset ? olderOffset + len : olderOffset;
|
|
122
|
+
} else if (olderEnd === null && endOffset === newerOffset) {
|
|
123
|
+
olderEnd = olderOffset + len;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
olderOffset += len;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
if (olderStart !== null && olderEnd !== null) break;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
if (olderStart === null) olderStart = olderOffset;
|
|
133
|
+
if (olderEnd === null) olderEnd = olderOffset;
|
|
134
|
+
|
|
135
|
+
return { start: olderStart, end: olderEnd };
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* 将「旧内容」中的 offset range 映射到「新内容」中的 offset range
|
|
140
|
+
* 与 mapNewerRangeToOlder 对称:diffs = diff_main(older, newer),给定 older 上的 [start,end],返回 newer 上的 [start,end]
|
|
141
|
+
*
|
|
142
|
+
* @param olderContent 旧内容(diff 的 text1)
|
|
143
|
+
* @param newerContent 新内容(diff 的 text2)
|
|
144
|
+
* @param startOffset 旧内容中的 range 起始 offset
|
|
145
|
+
* @param endOffset 旧内容中的 range 结束 offset
|
|
146
|
+
* @returns 新内容中对应的 range
|
|
147
|
+
*/
|
|
148
|
+
export function mapOlderRangeToNewer(
|
|
149
|
+
olderContent: string,
|
|
150
|
+
newerContent: string,
|
|
151
|
+
startOffset: number,
|
|
152
|
+
endOffset: number
|
|
153
|
+
): Range {
|
|
154
|
+
const dmp = new diff_match_patch();
|
|
155
|
+
const diffs = dmp.diff_main(olderContent, newerContent);
|
|
156
|
+
dmp.diff_cleanupSemantic(diffs);
|
|
157
|
+
|
|
158
|
+
let olderOffset = 0;
|
|
159
|
+
let newerOffset = 0;
|
|
160
|
+
let newerStart: number | null = null;
|
|
161
|
+
let newerEnd: number | null = null;
|
|
162
|
+
|
|
163
|
+
for (let i = 0; i < diffs.length; i++) {
|
|
164
|
+
const [op, text] = diffs[i];
|
|
165
|
+
const len = text.length;
|
|
166
|
+
const nextDiff = i < diffs.length - 1 ? diffs[i + 1] : null;
|
|
167
|
+
|
|
168
|
+
if (op === DIFF_EQUAL) {
|
|
169
|
+
const oldRangeStart = olderOffset;
|
|
170
|
+
const oldRangeEnd = olderOffset + len;
|
|
171
|
+
|
|
172
|
+
if (
|
|
173
|
+
newerStart === null &&
|
|
174
|
+
startOffset >= oldRangeStart &&
|
|
175
|
+
startOffset < oldRangeEnd
|
|
176
|
+
) {
|
|
177
|
+
newerStart = newerOffset + (startOffset - oldRangeStart);
|
|
178
|
+
}
|
|
179
|
+
if (
|
|
180
|
+
newerEnd === null &&
|
|
181
|
+
endOffset > oldRangeStart &&
|
|
182
|
+
endOffset <= oldRangeEnd
|
|
183
|
+
) {
|
|
184
|
+
const offsetInRange = endOffset - oldRangeStart;
|
|
185
|
+
newerEnd = newerOffset + offsetInRange;
|
|
186
|
+
if (
|
|
187
|
+
endOffset === oldRangeEnd &&
|
|
188
|
+
nextDiff &&
|
|
189
|
+
nextDiff[0] === DIFF_INSERT
|
|
190
|
+
) {
|
|
191
|
+
newerEnd = newerOffset + len + nextDiff[1].length;
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
olderOffset += len;
|
|
196
|
+
newerOffset += len;
|
|
197
|
+
} else if (op === DIFF_INSERT) {
|
|
198
|
+
newerOffset += len;
|
|
199
|
+
} else if (op === DIFF_DELETE) {
|
|
200
|
+
const oldRangeStart = olderOffset;
|
|
201
|
+
const oldRangeEnd = olderOffset + len;
|
|
202
|
+
|
|
203
|
+
if (
|
|
204
|
+
newerStart === null &&
|
|
205
|
+
startOffset >= oldRangeStart &&
|
|
206
|
+
startOffset < oldRangeEnd
|
|
207
|
+
) {
|
|
208
|
+
newerStart = newerOffset;
|
|
209
|
+
}
|
|
210
|
+
if (
|
|
211
|
+
newerEnd === null &&
|
|
212
|
+
endOffset > oldRangeStart &&
|
|
213
|
+
endOffset <= oldRangeEnd
|
|
214
|
+
) {
|
|
215
|
+
newerEnd = newerOffset;
|
|
216
|
+
} else if (newerEnd === null && endOffset <= olderOffset) {
|
|
217
|
+
newerEnd = newerOffset;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
olderOffset += len;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
if (newerStart !== null && newerEnd !== null) break;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
if (newerStart === null) newerStart = newerOffset;
|
|
227
|
+
if (newerEnd === null) newerEnd = newerOffset;
|
|
228
|
+
|
|
229
|
+
return { start: newerStart, end: newerEnd };
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/**
|
|
233
|
+
* 对比两段片段内容,分析发生的变更类型并生成简短描述
|
|
234
|
+
*
|
|
235
|
+
* @param originalFragment 原始片段
|
|
236
|
+
* @param finalFragment 变更后片段
|
|
237
|
+
* @returns 变更分析结果
|
|
238
|
+
*/
|
|
239
|
+
export function analyzeFragmentChange(
|
|
240
|
+
originalFragment: string,
|
|
241
|
+
finalFragment: string
|
|
242
|
+
): FragmentChangeAnalysis {
|
|
243
|
+
const dmp = new diff_match_patch();
|
|
244
|
+
const diffs = dmp.diff_main(originalFragment, finalFragment);
|
|
245
|
+
dmp.diff_cleanupSemantic(diffs);
|
|
246
|
+
|
|
247
|
+
const hasDelete = diffs.some(([op]) => op === DIFF_DELETE);
|
|
248
|
+
const hasInsert = diffs.some(([op]) => op === DIFF_INSERT);
|
|
249
|
+
const equal = !hasDelete && !hasInsert;
|
|
250
|
+
const onlyDeletion = hasDelete && !hasInsert;
|
|
251
|
+
const onlyInsertion = !hasDelete && hasInsert;
|
|
252
|
+
const replacement = hasDelete && hasInsert;
|
|
253
|
+
|
|
254
|
+
let summary: string;
|
|
255
|
+
if (equal) {
|
|
256
|
+
summary = '无变更';
|
|
257
|
+
} else if (onlyDeletion) {
|
|
258
|
+
const deleted = diffs
|
|
259
|
+
.filter(([op]) => op === DIFF_DELETE)
|
|
260
|
+
.map(([, text]) => text)
|
|
261
|
+
.join('');
|
|
262
|
+
summary = `删除: ${formatSnippet(deleted)}`;
|
|
263
|
+
} else if (onlyInsertion) {
|
|
264
|
+
const inserted = diffs
|
|
265
|
+
.filter(([op]) => op === DIFF_INSERT)
|
|
266
|
+
.map(([, text]) => text)
|
|
267
|
+
.join('');
|
|
268
|
+
summary = `新增: ${formatSnippet(inserted)}`;
|
|
269
|
+
} else {
|
|
270
|
+
const deleted = diffs
|
|
271
|
+
.filter(([op]) => op === DIFF_DELETE)
|
|
272
|
+
.map(([, text]) => text)
|
|
273
|
+
.join('');
|
|
274
|
+
const inserted = diffs
|
|
275
|
+
.filter(([op]) => op === DIFF_INSERT)
|
|
276
|
+
.map(([, text]) => text)
|
|
277
|
+
.join('');
|
|
278
|
+
summary = `替换: ${formatSnippet(deleted)} → ${formatSnippet(inserted)}`;
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
return {
|
|
282
|
+
originalFragment,
|
|
283
|
+
finalFragment,
|
|
284
|
+
equal,
|
|
285
|
+
onlyDeletion,
|
|
286
|
+
onlyInsertion,
|
|
287
|
+
replacement,
|
|
288
|
+
summary,
|
|
289
|
+
diffs,
|
|
290
|
+
};
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
/** 片段截断展示,避免过长 */
|
|
294
|
+
function formatSnippet(s: string, maxLen = 40): string {
|
|
295
|
+
const t = s.replace(/\s+/g, ' ').trim();
|
|
296
|
+
if (t.length <= maxLen) return t;
|
|
297
|
+
return `${t.slice(0, maxLen)}…`;
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
/**
|
|
301
|
+
* 根据当前内容和 range,找到下一个内容对应的 range
|
|
302
|
+
* 用于将 currentContent 中的 range 映射到 nextContent 中对应的 range
|
|
303
|
+
*
|
|
304
|
+
* @param options.currentContent 当前文件内容
|
|
305
|
+
* @param options.nextContent 下一个文件内容
|
|
306
|
+
* @param options.currentRange 当前文件中需要映射的 range
|
|
307
|
+
* @returns 映射得到的 nextRange、对应片段、以及片段级变更分析;若无法解析则返回 undefined
|
|
308
|
+
*/
|
|
309
|
+
export function resolveGroupChangeFragments(options: {
|
|
310
|
+
currentContent: string;
|
|
311
|
+
nextContent: string;
|
|
312
|
+
currentRange: Range;
|
|
313
|
+
}):
|
|
314
|
+
| {
|
|
315
|
+
nextRange: Range;
|
|
316
|
+
currentFragment: string;
|
|
317
|
+
nextFragment: string;
|
|
318
|
+
changeAnalysis: FragmentChangeAnalysis;
|
|
319
|
+
}
|
|
320
|
+
| undefined {
|
|
321
|
+
const { currentContent, nextContent, currentRange } = options;
|
|
322
|
+
|
|
323
|
+
const { start: startOffset, end: endOffset } = currentRange;
|
|
324
|
+
|
|
325
|
+
if (
|
|
326
|
+
startOffset < 0 ||
|
|
327
|
+
endOffset < 0 ||
|
|
328
|
+
startOffset > endOffset ||
|
|
329
|
+
endOffset > currentContent.length
|
|
330
|
+
) {
|
|
331
|
+
return undefined;
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
const nextRange = mapOlderRangeToNewer(
|
|
335
|
+
currentContent,
|
|
336
|
+
nextContent,
|
|
337
|
+
startOffset,
|
|
338
|
+
endOffset
|
|
339
|
+
);
|
|
340
|
+
|
|
341
|
+
const currentFragment = currentContent.substring(startOffset, endOffset);
|
|
342
|
+
const nextFragment = nextContent.substring(nextRange.start, nextRange.end);
|
|
343
|
+
|
|
344
|
+
const changeAnalysis = analyzeFragmentChange(currentFragment, nextFragment);
|
|
345
|
+
|
|
346
|
+
return {
|
|
347
|
+
nextRange,
|
|
348
|
+
currentFragment,
|
|
349
|
+
nextFragment,
|
|
350
|
+
changeAnalysis,
|
|
351
|
+
};
|
|
352
|
+
}
|