@x-oasis/diff-match-patch 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.
@@ -0,0 +1,251 @@
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: 1400px;
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;
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
+ }
68
+
69
+ .file-content {
70
+ padding: 15px;
71
+ font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace;
72
+ font-size: 13px;
73
+ line-height: 1.8;
74
+ white-space: pre-wrap;
75
+ word-wrap: break-word;
76
+ max-height: 400px;
77
+ overflow-y: auto;
78
+ background: #fff;
79
+ resize: vertical;
80
+ min-height: 200px;
81
+ width: 100%;
82
+ border: none;
83
+ outline: none;
84
+ }
85
+
86
+ .file-content:focus {
87
+ outline: none;
88
+ }
89
+
90
+ .controls {
91
+ display: grid;
92
+ grid-template-columns: 1fr 1fr auto;
93
+ gap: 15px;
94
+ margin-bottom: 20px;
95
+ align-items: end;
96
+ }
97
+
98
+ .control-group {
99
+ display: flex;
100
+ flex-direction: column;
101
+ }
102
+
103
+ .control-group label {
104
+ font-size: 12px;
105
+ font-weight: 600;
106
+ color: #555;
107
+ margin-bottom: 5px;
108
+ }
109
+
110
+ .control-group input {
111
+ padding: 10px;
112
+ border: 1px solid #ddd;
113
+ border-radius: 4px;
114
+ font-size: 14px;
115
+ }
116
+
117
+ .control-group input:focus {
118
+ outline: none;
119
+ border-color: #4a90e2;
120
+ box-shadow: 0 0 0 3px rgba(74, 144, 226, 0.1);
121
+ }
122
+
123
+ button {
124
+ padding: 10px 20px;
125
+ background: #4a90e2;
126
+ color: white;
127
+ border: none;
128
+ border-radius: 4px;
129
+ font-size: 14px;
130
+ font-weight: 600;
131
+ cursor: pointer;
132
+ transition: background 0.2s;
133
+ }
134
+
135
+ button:hover {
136
+ background: #357abd;
137
+ }
138
+
139
+ button:active {
140
+ transform: translateY(1px);
141
+ }
142
+
143
+ .result-panel {
144
+ border: 2px solid #4a90e2;
145
+ border-radius: 4px;
146
+ padding: 15px;
147
+ background: #f0f7ff;
148
+ margin-top: 20px;
149
+ }
150
+
151
+ .result-title {
152
+ font-weight: 600;
153
+ color: #4a90e2;
154
+ margin-bottom: 10px;
155
+ }
156
+
157
+ .result-content {
158
+ font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace;
159
+ font-size: 13px;
160
+ line-height: 1.8;
161
+ white-space: pre-wrap;
162
+ word-wrap: break-word;
163
+ background: white;
164
+ padding: 15px;
165
+ border-radius: 4px;
166
+ border: 1px solid #ddd;
167
+ max-height: 300px;
168
+ overflow-y: auto;
169
+ }
170
+
171
+ .highlight {
172
+ background: #fff3cd;
173
+ padding: 2px 4px;
174
+ border-radius: 2px;
175
+ font-weight: 600;
176
+ }
177
+
178
+ .info-box {
179
+ background: #e7f3ff;
180
+ border-left: 4px solid #4a90e2;
181
+ padding: 12px;
182
+ margin-bottom: 20px;
183
+ border-radius: 4px;
184
+ }
185
+
186
+ .info-box strong {
187
+ color: #4a90e2;
188
+ }
189
+
190
+ .example-offsets {
191
+ display: flex;
192
+ gap: 10px;
193
+ flex-wrap: wrap;
194
+ margin-top: 10px;
195
+ }
196
+
197
+ .example-btn {
198
+ padding: 6px 12px;
199
+ background: #e7f3ff;
200
+ color: #4a90e2;
201
+ border: 1px solid #4a90e2;
202
+ border-radius: 4px;
203
+ font-size: 12px;
204
+ cursor: pointer;
205
+ transition: all 0.2s;
206
+ }
207
+
208
+ .example-btn:hover {
209
+ background: #4a90e2;
210
+ color: white;
211
+ }
212
+
213
+ .diff-container {
214
+ margin-top: 20px;
215
+ }
216
+
217
+ .offset-info {
218
+ margin-top: 10px;
219
+ padding: 10px;
220
+ background: #f6f8fa;
221
+ border-radius: 4px;
222
+ font-size: 12px;
223
+ border: 1px solid #d1d9e0;
224
+ }
225
+
226
+ .offset-info strong {
227
+ color: #0969da;
228
+ }
229
+
230
+ .offset-tag {
231
+ margin-left: 10px;
232
+ padding: 2px 6px;
233
+ background: #fff;
234
+ border-radius: 3px;
235
+ font-family: monospace;
236
+ }
237
+
238
+ .diff-insert {
239
+ background: #d4edda;
240
+ color: #155724;
241
+ }
242
+
243
+ .diff-delete {
244
+ background: #f8d7da;
245
+ color: #721c24;
246
+ }
247
+
248
+ .diff-equal {
249
+ color: #333;
250
+ background: #fff;
251
+ }
@@ -0,0 +1,10 @@
1
+ import React from 'react';
2
+ import ReactDOM from 'react-dom/client';
3
+ import App from './App';
4
+ import './index.css';
5
+
6
+ ReactDOM.createRoot(document.getElementById('root')!).render(
7
+ <React.StrictMode>
8
+ <App />
9
+ </React.StrictMode>
10
+ );
@@ -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/diff-match-patch": ["../src/index.ts"]
21
+ }
22
+ },
23
+ "include": ["src"],
24
+ "references": [{ "path": "./tsconfig.node.json" }]
25
+ }
@@ -0,0 +1,10 @@
1
+ {
2
+ "compilerOptions": {
3
+ "composite": true,
4
+ "skipLibCheck": true,
5
+ "module": "ESNext",
6
+ "moduleResolution": "bundler",
7
+ "allowSyntheticDefaultImports": true
8
+ },
9
+ "include": ["vite.config.ts"]
10
+ }
@@ -0,0 +1,16 @@
1
+ import { defineConfig } from 'vite';
2
+ import react from '@vitejs/plugin-react';
3
+ import path from 'path';
4
+
5
+ export default defineConfig({
6
+ plugins: [react()],
7
+ resolve: {
8
+ alias: {
9
+ '@x-oasis/diff-match-patch': path.resolve(__dirname, '../src/index.ts'),
10
+ },
11
+ },
12
+ server: {
13
+ port: 3000,
14
+ open: true,
15
+ },
16
+ });
package/package.json ADDED
@@ -0,0 +1,25 @@
1
+ {
2
+ "name": "@x-oasis/diff-match-patch",
3
+ "version": "0.1.0",
4
+ "description": "Restore file content to original version based on offset range",
5
+ "main": "dist/index.js",
6
+ "typings": "dist/index.d.ts",
7
+ "module": "dist/diff-match-patch.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,294 @@
1
+ import { diff_match_patch } from 'diff-match-patch';
2
+
3
+ const DIFF_DELETE = -1;
4
+ const DIFF_INSERT = 1;
5
+ const DIFF_EQUAL = 0;
6
+
7
+ export interface RestoreRangeOptions {
8
+ startOffset: number;
9
+ endOffset: number;
10
+ }
11
+
12
+ /**
13
+ * 文件恢复管理器
14
+ * 用于将最新文件中的指定 range 恢复到原始版本
15
+ */
16
+ export class FileRestoreManager {
17
+ private originalContent: string;
18
+ private dmp: diff_match_patch;
19
+
20
+ constructor(originalContent: string) {
21
+ this.originalContent = originalContent;
22
+ this.dmp = new diff_match_patch();
23
+ }
24
+
25
+ /**
26
+ * 将最新文件中指定 offset range 的内容恢复到原始版本
27
+ * @param currentContent 最新文件内容
28
+ * @param options 包含 startOffset 和 endOffset 的选项
29
+ * @returns 恢复后的文件内容
30
+ */
31
+ restoreRange(currentContent: string, options: RestoreRangeOptions): string {
32
+ const { startOffset, endOffset } = options;
33
+
34
+ if (startOffset < 0 || endOffset < 0 || startOffset > endOffset) {
35
+ throw new Error('Invalid offset range');
36
+ }
37
+
38
+ if (
39
+ startOffset > currentContent.length ||
40
+ endOffset > currentContent.length
41
+ ) {
42
+ throw new Error('Offset range exceeds current content length');
43
+ }
44
+
45
+ // 计算差异
46
+ const diffs = this.dmp.diff_main(this.originalContent, currentContent);
47
+ this.dmp.diff_cleanupSemantic(diffs);
48
+
49
+ // 找到最新文件中 startOffset 和 endOffset 对应的原始文件中的位置
50
+ const originalRange = this.mapCurrentRangeToOriginal(
51
+ diffs,
52
+ startOffset,
53
+ endOffset
54
+ );
55
+
56
+ // 从原始文件中提取对应范围的内容
57
+ const originalRangeContent = this.originalContent.substring(
58
+ originalRange.start,
59
+ originalRange.end
60
+ );
61
+
62
+ // 获取当前 range 的内容(用于调试)
63
+ const currentRangeContent = currentContent.substring(
64
+ startOffset,
65
+ endOffset
66
+ );
67
+
68
+ // 调试信息
69
+ console.log('[restoreRange] Debug Info:');
70
+ console.log(` startOffset: ${startOffset}, endOffset: ${endOffset}`);
71
+ console.log(
72
+ ` Current range content: ${JSON.stringify(currentRangeContent)}`
73
+ );
74
+ console.log(
75
+ ` Original range mapping: ${originalRange.start}-${originalRange.end}`
76
+ );
77
+ console.log(` Will restore to: ${JSON.stringify(originalRangeContent)}`);
78
+ console.log(
79
+ ` Content will change: ${currentRangeContent !== originalRangeContent}`
80
+ );
81
+
82
+ // 替换最新文件中指定 range 的内容
83
+ const restoredContent =
84
+ currentContent.substring(0, startOffset) +
85
+ originalRangeContent +
86
+ currentContent.substring(endOffset);
87
+
88
+ return restoredContent;
89
+ }
90
+
91
+ /**
92
+ * 将最新文件中的 offset range 映射到原始文件中的 offset range
93
+ */
94
+ private mapCurrentRangeToOriginal(
95
+ diffs: Array<[number, string]>,
96
+ currentStart: number,
97
+ currentEnd: number
98
+ ): { start: number; end: number } {
99
+ let currentOffset = 0; // 当前在最新文件中的 offset
100
+ let originalOffset = 0; // 当前在原始文件中的 offset
101
+ let originalStart: number | null = null;
102
+ let originalEnd: number | null = null;
103
+
104
+ for (let i = 0; i < diffs.length; i++) {
105
+ const [operation, text] = diffs[i];
106
+ const textLength = text.length;
107
+ const nextDiff = i < diffs.length - 1 ? diffs[i + 1] : null;
108
+
109
+ if (operation === DIFF_EQUAL) {
110
+ // 相等部分:两个文件的 offset 同步增加
111
+ const rangeStart = currentOffset;
112
+ const rangeEnd = currentOffset + textLength;
113
+
114
+ // 检查 currentStart 是否在这个 EQUAL 区间内
115
+ if (
116
+ originalStart === null &&
117
+ currentStart >= rangeStart &&
118
+ currentStart < rangeEnd
119
+ ) {
120
+ const offsetInRange = currentStart - rangeStart;
121
+ originalStart = originalOffset + offsetInRange;
122
+ }
123
+ // 检查 currentEnd 是否在这个 EQUAL 区间内
124
+ if (
125
+ originalEnd === null &&
126
+ currentEnd > rangeStart &&
127
+ currentEnd <= rangeEnd
128
+ ) {
129
+ const offsetInRange = currentEnd - rangeStart;
130
+ originalEnd = originalOffset + offsetInRange;
131
+
132
+ // 特殊处理:如果 currentEnd 正好在 EQUAL 的结束位置,且下一个 diff 是 DELETE
133
+ // 需要包含被删除的内容,以便正确恢复
134
+ //
135
+ // 示例:
136
+ // 原始文件: "...禁用按钮</button>..."
137
+ // 最新文件: "...禁用</button>..."
138
+ // diff: [EQUAL: "...禁用"], [DELETE: "按钮"], [EQUAL: "</button>..."]
139
+ //
140
+ // 如果用户选择最新文件的 offset 1512-1514("禁用"),
141
+ // 应该恢复为原始文件的 offset 1512-1516("禁用按钮")
142
+ //
143
+ // 如果不包含 DELETE 的内容,只会恢复到 "禁用",而不是 "禁用按钮"
144
+ if (
145
+ currentEnd === rangeEnd &&
146
+ nextDiff &&
147
+ nextDiff[0] === DIFF_DELETE
148
+ ) {
149
+ originalEnd = originalOffset + textLength + nextDiff[1].length;
150
+ }
151
+ }
152
+
153
+ currentOffset += textLength;
154
+ originalOffset += textLength;
155
+ } else if (operation === DIFF_INSERT) {
156
+ // 插入部分:只在新文件中存在
157
+ const rangeStart = currentOffset;
158
+ const rangeEnd = currentOffset + textLength;
159
+
160
+ // 如果 currentStart 在插入内容中,映射到插入前的原始位置
161
+ if (
162
+ originalStart === null &&
163
+ currentStart >= rangeStart &&
164
+ currentStart < rangeEnd
165
+ ) {
166
+ originalStart = originalOffset;
167
+ }
168
+ // 如果 currentEnd 在插入内容中,映射到插入前的原始位置
169
+ if (
170
+ originalEnd === null &&
171
+ currentEnd > rangeStart &&
172
+ currentEnd <= rangeEnd
173
+ ) {
174
+ originalEnd = originalOffset;
175
+ }
176
+
177
+ currentOffset += textLength;
178
+ // INSERT 不增加 originalOffset
179
+ } else if (operation === DIFF_DELETE) {
180
+ // 删除部分:只在原始文件中存在
181
+ // 如果 currentStart 正好在删除位置之前,需要包含删除的内容
182
+ if (originalStart === null && currentStart === currentOffset) {
183
+ // currentStart 正好在删除位置,映射到删除开始的位置
184
+ originalStart = originalOffset;
185
+ } else if (originalStart === null && currentStart < currentOffset) {
186
+ // currentStart 在删除位置之前,映射到删除开始的位置
187
+ originalStart = originalOffset;
188
+ }
189
+
190
+ // 如果 currentEnd 正好在删除位置之前,需要包含删除的内容
191
+ if (originalEnd === null && currentEnd === currentOffset) {
192
+ // currentEnd 正好在删除位置,需要包含整个删除的内容
193
+ originalEnd = originalOffset + textLength;
194
+ } else if (originalEnd === null && currentEnd < currentOffset) {
195
+ // currentEnd 在删除位置之前,映射到删除开始的位置
196
+ originalEnd = originalOffset;
197
+ }
198
+
199
+ originalOffset += textLength;
200
+ // DELETE 不增加 currentOffset
201
+ }
202
+
203
+ // 如果两个位置都找到了,可以提前退出
204
+ if (originalStart !== null && originalEnd !== null) {
205
+ break;
206
+ }
207
+ }
208
+
209
+ // 处理边界情况:如果 range 在所有 diff 之后
210
+ if (originalStart === null) {
211
+ originalStart = originalOffset;
212
+ }
213
+ if (originalEnd === null) {
214
+ originalEnd = originalOffset;
215
+ }
216
+
217
+ return { start: originalStart, end: originalEnd };
218
+ }
219
+
220
+ /**
221
+ * 获取原始文件内容
222
+ */
223
+ getOriginalContent(): string {
224
+ return this.originalContent;
225
+ }
226
+
227
+ /**
228
+ * 更新原始文件内容
229
+ */
230
+ updateOriginalContent(newOriginalContent: string): void {
231
+ this.originalContent = newOriginalContent;
232
+ }
233
+
234
+ /**
235
+ * 调试方法:分析指定 range 的恢复情况
236
+ */
237
+ debugRestoreRange(
238
+ currentContent: string,
239
+ options: RestoreRangeOptions
240
+ ): {
241
+ hasChanges: boolean;
242
+ originalRange: { start: number; end: number };
243
+ currentRange: { start: number; end: number };
244
+ originalContent: string;
245
+ currentContent: string;
246
+ willChange: boolean;
247
+ } {
248
+ const { startOffset, endOffset } = options;
249
+
250
+ // 计算差异
251
+ const diffs = this.dmp.diff_main(this.originalContent, currentContent);
252
+ this.dmp.diff_cleanupSemantic(diffs);
253
+
254
+ // 找到映射
255
+ const originalRange = this.mapCurrentRangeToOriginal(
256
+ diffs,
257
+ startOffset,
258
+ endOffset
259
+ );
260
+
261
+ const originalRangeContent = this.originalContent.substring(
262
+ originalRange.start,
263
+ originalRange.end
264
+ );
265
+ const currentRangeContent = currentContent.substring(
266
+ startOffset,
267
+ endOffset
268
+ );
269
+
270
+ return {
271
+ hasChanges: this.originalContent !== currentContent,
272
+ originalRange,
273
+ currentRange: { start: startOffset, end: endOffset },
274
+ originalContent: originalRangeContent,
275
+ currentContent: currentRangeContent,
276
+ willChange: originalRangeContent !== currentRangeContent,
277
+ };
278
+ }
279
+ }
280
+
281
+ /**
282
+ * 便捷函数:直接恢复指定 range 的内容
283
+ */
284
+ export function restoreRange(
285
+ originalContent: string,
286
+ currentContent: string,
287
+ startOffset: number,
288
+ endOffset: number
289
+ ): string {
290
+ const manager = new FileRestoreManager(originalContent);
291
+ return manager.restoreRange(currentContent, { startOffset, endOffset });
292
+ }
293
+
294
+ export default FileRestoreManager;
package/src/types.d.ts ADDED
@@ -0,0 +1,6 @@
1
+ // declare module 'diff-match-patch' {
2
+ // export class diff_match_patch {
3
+ // diff_main(text1: string, text2: string): Array<[number, string]>;
4
+ // diff_cleanupSemantic(diffs: Array<[number, string]>): void;
5
+ // }
6
+ // }