@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,626 @@
1
+ import React, { useState, useRef, useEffect } from 'react';
2
+ import { FileRestoreManager } from '@x-oasis/diff-match-patch';
3
+ import { DiffView, DiffModeEnum } from '@git-diff-view/react';
4
+ import { generateDiffFile, DiffFile } from '@git-diff-view/file';
5
+ import '@git-diff-view/react/styles/diff-view.css';
6
+ import './index.css';
7
+
8
+ // 默认空内容,由用户填写
9
+ const ORIGINAL_FILE = '';
10
+ const CURRENT_FILE = '';
11
+
12
+ const App: React.FC = () => {
13
+ const [originalContent, setOriginalContent] = useState(ORIGINAL_FILE);
14
+ const [currentContent, setCurrentContent] = useState(CURRENT_FILE);
15
+ const [startOffset, setStartOffset] = useState<number>(0);
16
+ const [endOffset, setEndOffset] = useState<number>(0);
17
+ const [restoredContent, setRestoredContent] = useState<string>('');
18
+ const [debugInfo, setDebugInfo] = useState<any>(null);
19
+ const originalTextareaRef = useRef<HTMLTextAreaElement>(null);
20
+ const currentTextareaRef = useRef<HTMLTextAreaElement>(null);
21
+ const diffContainerRef = useRef<HTMLDivElement>(null);
22
+ const [diffFile, setDiffFile] = useState<DiffFile | null>(null);
23
+ const [diffViewMode, setDiffViewMode] = useState<DiffModeEnum>(
24
+ DiffModeEnum.Split
25
+ );
26
+
27
+ // 使用 file diff mode
28
+ useEffect(() => {
29
+ // 确保内容不为空且是字符串类型
30
+ const oldContent =
31
+ typeof originalContent === 'string'
32
+ ? originalContent
33
+ : String(originalContent || '');
34
+ const newContent =
35
+ typeof currentContent === 'string'
36
+ ? currentContent
37
+ : String(currentContent || '');
38
+
39
+ if (oldContent === newContent) {
40
+ setDiffFile(null);
41
+ return;
42
+ }
43
+
44
+ // 验证内容不为空
45
+ if (!oldContent && !newContent) {
46
+ setDiffFile(null);
47
+ return;
48
+ }
49
+
50
+ try {
51
+ const file = generateDiffFile(
52
+ 'code.vue',
53
+ oldContent,
54
+ 'codev2.vue',
55
+ newContent,
56
+ 'vue',
57
+ 'vue'
58
+ );
59
+
60
+ // 初始化并构建 diff lines
61
+ file.init();
62
+ file.buildSplitDiffLines();
63
+ file.buildUnifiedDiffLines();
64
+
65
+ // 确保所有 hunks 都展开
66
+ file.onAllExpand();
67
+
68
+ setDiffFile(file);
69
+ } catch (error) {
70
+ console.error('Error generating diff file:', error);
71
+ console.error('Error details:', {
72
+ oldContent: oldContent?.substring(0, 100),
73
+ newContent: newContent?.substring(0, 100),
74
+ oldContentType: typeof oldContent,
75
+ newContentType: typeof newContent,
76
+ });
77
+ setDiffFile(null);
78
+ }
79
+ }, [originalContent, currentContent]);
80
+
81
+ const handleRestore = () => {
82
+ try {
83
+ const manager = new FileRestoreManager(originalContent);
84
+ const debug = manager.debugRestoreRange(currentContent, {
85
+ startOffset,
86
+ endOffset,
87
+ });
88
+ setDebugInfo(debug);
89
+
90
+ const restored = manager.restoreRange(currentContent, {
91
+ startOffset,
92
+ endOffset,
93
+ });
94
+ setRestoredContent(restored);
95
+ } catch (error: any) {
96
+ alert(`错误: ${error.message}`);
97
+ }
98
+ };
99
+
100
+ // 切换原始文件和修改后的文件内容
101
+ const handleSwapFiles = () => {
102
+ const temp = originalContent;
103
+ setOriginalContent(currentContent);
104
+ setCurrentContent(temp);
105
+ // 重置 offset 和恢复结果
106
+ setStartOffset(0);
107
+ setEndOffset(0);
108
+ setRestoredContent('');
109
+ setDebugInfo(null);
110
+ };
111
+
112
+ // 计算指定行号在新文件中的offset范围
113
+ const getOffsetRangeFromLineNumber = (
114
+ lineNumber: number
115
+ ): { start: number; end: number } | null => {
116
+ if (!currentContent || lineNumber < 1) return null;
117
+
118
+ const lines = currentContent.split('\n');
119
+ if (lineNumber > lines.length) return null;
120
+
121
+ // 计算该行之前所有字符的offset
122
+ let startOffset = 0;
123
+ for (let i = 0; i < lineNumber - 1; i++) {
124
+ startOffset += lines[i].length + 1; // +1 for newline
125
+ }
126
+
127
+ // 该行的结束offset
128
+ const endOffset = startOffset + lines[lineNumber - 1].length;
129
+
130
+ return { start: startOffset, end: endOffset };
131
+ };
132
+
133
+ // 检查元素是否在修改的行内(通过CSS变量判断)
134
+ const isModifiedLine = (element: HTMLElement): boolean => {
135
+ let current: HTMLElement | null = element;
136
+ while (current && current !== diffContainerRef.current) {
137
+ // 检查内联样式
138
+ const styleAttr = current.getAttribute('style') || '';
139
+
140
+ // 如果使用了 --diff-plain-content--,说明是未更改的行,返回 false
141
+ if (styleAttr.includes('--diff-plain-content--')) {
142
+ return false;
143
+ }
144
+
145
+ // 如果使用了以下任一变量,说明是修改的行(新增、删除、修改)
146
+ if (
147
+ styleAttr.includes('--diff-add-content--') ||
148
+ styleAttr.includes('--diff-delete-content--') ||
149
+ styleAttr.includes('--diff-modify-content--')
150
+ ) {
151
+ return true;
152
+ }
153
+
154
+ // 检查元素的类名,然后查找对应的CSS规则
155
+ const className = current.className;
156
+ if (className && typeof className === 'string') {
157
+ const classes = className.split(/\s+/);
158
+ for (const cls of classes) {
159
+ if (!cls) continue;
160
+
161
+ // 检查样式表中是否有这个类的规则使用了相关CSS变量
162
+ try {
163
+ for (let i = 0; i < document.styleSheets.length; i++) {
164
+ const sheet = document.styleSheets[i];
165
+ if (!sheet.cssRules) continue;
166
+
167
+ for (let j = 0; j < sheet.cssRules.length; j++) {
168
+ const rule = sheet.cssRules[j] as CSSStyleRule;
169
+ if (
170
+ rule.selectorText &&
171
+ rule.selectorText.includes(`.${cls}`)
172
+ ) {
173
+ const bgColor =
174
+ rule.style.getPropertyValue('background-color');
175
+ // 检查是否是修改的行
176
+ if (
177
+ bgColor &&
178
+ (bgColor.includes('var(--diff-add-content--') ||
179
+ bgColor.includes('var(--diff-delete-content--') ||
180
+ bgColor.includes('var(--diff-modify-content--'))
181
+ ) {
182
+ return true;
183
+ }
184
+ // 检查是否是未更改的行
185
+ if (
186
+ bgColor &&
187
+ bgColor.includes('var(--diff-plain-content--')
188
+ ) {
189
+ return false;
190
+ }
191
+ }
192
+ }
193
+ }
194
+ } catch (e) {
195
+ // 跨域样式表可能无法访问,忽略错误
196
+ }
197
+ }
198
+ }
199
+
200
+ // 向上查找父元素
201
+ current = current.parentElement;
202
+ }
203
+
204
+ // 默认返回 false(未更改的行)
205
+ return false;
206
+ };
207
+
208
+ // 从元素中提取新文件的行号
209
+ const extractNewLineNumber = (element: HTMLElement): number | null => {
210
+ let current: HTMLElement | null = element;
211
+
212
+ // 先尝试从当前元素及其所有父元素中查找
213
+ while (current && current !== diffContainerRef.current) {
214
+ // 检查各种可能的行号属性
215
+ const lineNum =
216
+ current.getAttribute('data-line-number') ||
217
+ current.getAttribute('data-new-line-number') ||
218
+ current.getAttribute('data-new-line') ||
219
+ current.getAttribute('data-line') ||
220
+ (current as any).dataset?.newLineNumber ||
221
+ (current as any).dataset?.lineNumber ||
222
+ (current as any).dataset?.line;
223
+
224
+ if (lineNum) {
225
+ const num = parseInt(lineNum, 10);
226
+ if (!isNaN(num)) {
227
+ return num;
228
+ }
229
+ }
230
+
231
+ // 检查类名中是否包含行号信息
232
+ const className = current.className || '';
233
+ if (typeof className === 'string') {
234
+ const match =
235
+ className.match(/line-(\d+)/) ||
236
+ className.match(/new-line-(\d+)/) ||
237
+ className.match(/line-number-(\d+)/) ||
238
+ className.match(/lineNumber-(\d+)/);
239
+ if (match) {
240
+ const num = parseInt(match[1], 10);
241
+ if (!isNaN(num)) {
242
+ return num;
243
+ }
244
+ }
245
+ }
246
+
247
+ // 检查文本内容是否包含行号(行号通常显示在行号列中)
248
+ const textContent = current.textContent || '';
249
+ const lineNumMatch = textContent.match(/^\s*(\d+)\s*$/);
250
+ if (lineNumMatch) {
251
+ const num = parseInt(lineNumMatch[1], 10);
252
+ if (!isNaN(num) && num > 0) {
253
+ return num;
254
+ }
255
+ }
256
+
257
+ current = current.parentElement;
258
+ }
259
+
260
+ // 如果向上查找失败,尝试在整个 diff 容器中查找包含行号的元素
261
+ // 查找所有可能的行号元素
262
+ if (diffContainerRef.current) {
263
+ const allElements = diffContainerRef.current.querySelectorAll(
264
+ '[data-line-number], [data-new-line-number], [data-new-line], [data-line]'
265
+ );
266
+
267
+ // 查找最接近点击位置的元素
268
+ for (const el of Array.from(allElements)) {
269
+ const rect = el.getBoundingClientRect();
270
+ const targetRect = element.getBoundingClientRect();
271
+
272
+ // 检查是否在同一行附近
273
+ if (Math.abs(rect.top - targetRect.top) < 50) {
274
+ const lineNum =
275
+ el.getAttribute('data-line-number') ||
276
+ el.getAttribute('data-new-line-number') ||
277
+ el.getAttribute('data-new-line') ||
278
+ el.getAttribute('data-line');
279
+
280
+ if (lineNum) {
281
+ const num = parseInt(lineNum, 10);
282
+ if (!isNaN(num)) {
283
+ return num;
284
+ }
285
+ }
286
+ }
287
+ }
288
+ }
289
+
290
+ return null;
291
+ };
292
+
293
+ // 处理diff视图中的点击事件
294
+ const handleDiffClick = (e: React.MouseEvent<HTMLDivElement>) => {
295
+ if (!diffFile || !currentContent) return;
296
+
297
+ const target = e.target as HTMLElement;
298
+
299
+ // 首先检查是否点击在修改的行内
300
+ if (!isModifiedLine(target)) {
301
+ // 如果不在修改的行内,不处理
302
+ return;
303
+ }
304
+
305
+ // 提取行号
306
+ const newLineNumber = extractNewLineNumber(target);
307
+
308
+ if (!newLineNumber || isNaN(newLineNumber)) {
309
+ // 如果无法提取行号,尝试从diffFile中查找所有修改的行
310
+ // 由于我们不知道具体是哪一行,我们可以尝试找到点击位置对应的行
311
+ try {
312
+ const splitLines = (diffFile as any).splitDiffLines;
313
+ if (splitLines && Array.isArray(splitLines)) {
314
+ // 查找所有修改的行(新增或修改)
315
+ const modifiedLines: Array<{ line: any; lineNum: number }> = [];
316
+
317
+ for (const line of splitLines) {
318
+ if (line.type === 'add' || line.type === 'modify') {
319
+ const lineNum = line.newLineNumber || line.lineNumber;
320
+ if (lineNum) {
321
+ modifiedLines.push({ line, lineNum });
322
+ }
323
+ }
324
+ }
325
+
326
+ // 如果有修改的行,使用第一个(或者可以尝试根据点击位置判断)
327
+ if (modifiedLines.length > 0) {
328
+ // 尝试找到最接近点击位置的行
329
+ // 这里简化处理,使用第一个修改的行
330
+ const firstModified = modifiedLines[0];
331
+ const offsetRange = getOffsetRangeFromLineNumber(
332
+ firstModified.lineNum
333
+ );
334
+ if (offsetRange) {
335
+ setStartOffset(offsetRange.start);
336
+ setEndOffset(offsetRange.end);
337
+ return;
338
+ }
339
+ }
340
+ }
341
+ } catch (error) {
342
+ console.error('Error getting offset from diffFile:', error);
343
+ }
344
+ return;
345
+ }
346
+
347
+ // 验证该行是否确实是修改的行(通过diffFile验证)
348
+ try {
349
+ const splitLines = (diffFile as any).splitDiffLines;
350
+ if (splitLines && Array.isArray(splitLines)) {
351
+ let isModified = false;
352
+
353
+ for (const line of splitLines) {
354
+ // 检查是否是修改的行(新增或修改)
355
+ if (line.type === 'add' || line.type === 'modify') {
356
+ const lineNum = line.newLineNumber || line.lineNumber;
357
+ if (lineNum === newLineNumber) {
358
+ isModified = true;
359
+ break;
360
+ }
361
+ }
362
+ }
363
+
364
+ // 只有确认是修改的行,才设置offset
365
+ if (isModified) {
366
+ const offsetRange = getOffsetRangeFromLineNumber(newLineNumber);
367
+ if (offsetRange) {
368
+ setStartOffset(offsetRange.start);
369
+ setEndOffset(offsetRange.end);
370
+ }
371
+ }
372
+ } else {
373
+ // 如果无法访问splitDiffLines,直接使用行号计算(但只对修改的行有效)
374
+ const offsetRange = getOffsetRangeFromLineNumber(newLineNumber);
375
+ if (offsetRange) {
376
+ setStartOffset(offsetRange.start);
377
+ setEndOffset(offsetRange.end);
378
+ }
379
+ }
380
+ } catch (error) {
381
+ console.error('Error getting offset from line number:', error);
382
+ }
383
+ };
384
+
385
+ return (
386
+ <div className="container">
387
+ <h1>Diff Match Patch - Restore Range Example</h1>
388
+ <p className="subtitle">
389
+ 将最新文件中指定 offset range 的内容恢复到原始版本
390
+ </p>
391
+
392
+ <div className="info-box">
393
+ <strong>使用说明:</strong>
394
+ <ul style={{ marginTop: '8px', marginLeft: '20px' }}>
395
+ <li>左侧显示原始文件内容,右侧显示修改后的文件内容</li>
396
+ <li>绿色背景表示新增内容,红色背景表示删除内容</li>
397
+ <li>
398
+ 点击diff视图中的修改行,会自动填充对应的 startOffset 和 endOffset
399
+ </li>
400
+ <li>
401
+ 也可以手动输入 startOffset 和 endOffset,点击"恢复"按钮执行恢复操作
402
+ </li>
403
+ </ul>
404
+ </div>
405
+
406
+ <div className="section">
407
+ <div
408
+ className="section-title"
409
+ style={{
410
+ display: 'flex',
411
+ justifyContent: 'space-between',
412
+ alignItems: 'center',
413
+ }}
414
+ >
415
+ <span>文件对比</span>
416
+ {diffFile && (
417
+ <div style={{ display: 'flex', gap: '10px', fontSize: '14px' }}>
418
+ <button
419
+ onClick={() => setDiffViewMode(DiffModeEnum.Split)}
420
+ style={{
421
+ padding: '4px 12px',
422
+ border: '1px solid #ddd',
423
+ borderRadius: '4px',
424
+ background:
425
+ diffViewMode === DiffModeEnum.Split ? '#0366d6' : '#fff',
426
+ color: diffViewMode === DiffModeEnum.Split ? '#fff' : '#333',
427
+ cursor: 'pointer',
428
+ }}
429
+ >
430
+ 并排视图
431
+ </button>
432
+ <button
433
+ onClick={() => setDiffViewMode(DiffModeEnum.Unified)}
434
+ style={{
435
+ padding: '4px 12px',
436
+ border: '1px solid #ddd',
437
+ borderRadius: '4px',
438
+ background:
439
+ diffViewMode === DiffModeEnum.Unified ? '#0366d6' : '#fff',
440
+ color:
441
+ diffViewMode === DiffModeEnum.Unified ? '#fff' : '#333',
442
+ cursor: 'pointer',
443
+ }}
444
+ >
445
+ 统一视图
446
+ </button>
447
+ </div>
448
+ )}
449
+ </div>
450
+
451
+ {/* 提示信息 */}
452
+ {diffFile && (
453
+ <div
454
+ style={{
455
+ marginBottom: '10px',
456
+ padding: '8px 12px',
457
+ backgroundColor: '#e3f2fd',
458
+ border: '1px solid #90caf9',
459
+ borderRadius: '4px',
460
+ fontSize: '13px',
461
+ color: '#1565c0',
462
+ }}
463
+ >
464
+ 💡 提示:可以通过点击 diff line 查看变更行的 offset
465
+ </div>
466
+ )}
467
+
468
+ {/* 使用 @git-diff-view/react 显示差异 */}
469
+ <div
470
+ ref={diffContainerRef}
471
+ className="diff-container"
472
+ onClick={handleDiffClick}
473
+ style={{
474
+ marginBottom: '20px',
475
+ border: '1px solid #ddd',
476
+ borderRadius: '4px',
477
+ overflow: 'hidden',
478
+ cursor: 'pointer',
479
+ }}
480
+ >
481
+ {diffFile ? (
482
+ <DiffView
483
+ diffFile={diffFile}
484
+ diffViewMode={diffViewMode}
485
+ diffViewHighlight={true}
486
+ />
487
+ ) : originalContent === currentContent ? (
488
+ <div
489
+ style={{ padding: '20px', textAlign: 'center', color: '#666' }}
490
+ >
491
+ 没有差异(两个文件内容相同)
492
+ </div>
493
+ ) : (
494
+ <div
495
+ style={{ padding: '20px', textAlign: 'center', color: '#666' }}
496
+ >
497
+ 正在生成差异...
498
+ </div>
499
+ )}
500
+ </div>
501
+
502
+ {/* 可编辑的文件内容区域 */}
503
+ <div className="file-comparison" style={{ position: 'relative' }}>
504
+ <div className="file-panel">
505
+ <div className="file-header">原始文件 (code.vue) - 可编辑</div>
506
+ <textarea
507
+ ref={originalTextareaRef}
508
+ className="file-content"
509
+ value={originalContent}
510
+ onChange={(e) => setOriginalContent(e.target.value)}
511
+ spellCheck={false}
512
+ />
513
+ </div>
514
+
515
+ {/* 切换按钮 */}
516
+ <div
517
+ style={{
518
+ position: 'absolute',
519
+ left: '50%',
520
+ top: '50%',
521
+ transform: 'translate(-50%, -50%)',
522
+ zIndex: 10,
523
+ }}
524
+ >
525
+ <button
526
+ onClick={handleSwapFiles}
527
+ style={{
528
+ padding: '8px 16px',
529
+ backgroundColor: '#0366d6',
530
+ color: '#fff',
531
+ border: 'none',
532
+ borderRadius: '4px',
533
+ cursor: 'pointer',
534
+ fontSize: '14px',
535
+ fontWeight: '500',
536
+ boxShadow: '0 2px 4px rgba(0,0,0,0.1)',
537
+ transition: 'background-color 0.2s',
538
+ }}
539
+ onMouseEnter={(e) => {
540
+ e.currentTarget.style.backgroundColor = '#0256c2';
541
+ }}
542
+ onMouseLeave={(e) => {
543
+ e.currentTarget.style.backgroundColor = '#0366d6';
544
+ }}
545
+ title="切换原始文件和修改后的文件内容"
546
+ >
547
+ ⇄ 切换
548
+ </button>
549
+ </div>
550
+
551
+ <div className="file-panel">
552
+ <div className="file-header">
553
+ 修改后的文件 (codev2.vue) - 可编辑
554
+ </div>
555
+ <textarea
556
+ ref={currentTextareaRef}
557
+ className="file-content"
558
+ value={currentContent}
559
+ onChange={(e) => setCurrentContent(e.target.value)}
560
+ spellCheck={false}
561
+ />
562
+ </div>
563
+ </div>
564
+ </div>
565
+
566
+ <div className="section">
567
+ <div className="section-title">恢复操作</div>
568
+ <div className="controls">
569
+ <div className="control-group">
570
+ <label>Start Offset</label>
571
+ <input
572
+ type="number"
573
+ value={startOffset}
574
+ onChange={(e) => setStartOffset(Number(e.target.value))}
575
+ min="0"
576
+ />
577
+ </div>
578
+ <div className="control-group">
579
+ <label>End Offset</label>
580
+ <input
581
+ type="number"
582
+ value={endOffset}
583
+ onChange={(e) => setEndOffset(Number(e.target.value))}
584
+ min="0"
585
+ />
586
+ </div>
587
+ <button onClick={handleRestore}>恢复</button>
588
+ </div>
589
+
590
+ {debugInfo && (
591
+ <div className="offset-info" style={{ marginTop: '15px' }}>
592
+ <strong>调试信息:</strong>
593
+ <div style={{ marginTop: '8px', fontSize: '12px' }}>
594
+ <div>
595
+ 当前范围: {debugInfo.currentRange.start} -{' '}
596
+ {debugInfo.currentRange.end}
597
+ </div>
598
+ <div>
599
+ 原始范围映射: {debugInfo.originalRange.start} -{' '}
600
+ {debugInfo.originalRange.end}
601
+ </div>
602
+ <div>
603
+ 当前内容:{' '}
604
+ <code>{JSON.stringify(debugInfo.currentContent)}</code>
605
+ </div>
606
+ <div>
607
+ 原始内容:{' '}
608
+ <code>{JSON.stringify(debugInfo.originalContent)}</code>
609
+ </div>
610
+ <div>内容将改变: {debugInfo.willChange ? '是' : '否'}</div>
611
+ </div>
612
+ </div>
613
+ )}
614
+ </div>
615
+
616
+ {restoredContent && (
617
+ <div className="result-panel">
618
+ <div className="result-title">恢复后的文件内容</div>
619
+ <div className="result-content">{restoredContent}</div>
620
+ </div>
621
+ )}
622
+ </div>
623
+ );
624
+ };
625
+
626
+ export default App;
@@ -0,0 +1,15 @@
1
+ declare module 'diff-match-patch' {
2
+ export class diff_match_patch {
3
+ diff_main(
4
+ text1: string,
5
+ text2: string,
6
+ checkLines?: boolean
7
+ ): Array<[number, string]>;
8
+ diff_cleanupSemantic(diffs: Array<[number, string]>): void;
9
+ diff_linesToChars(text1: string, text2: string): [string, string, string[]];
10
+ diff_charsToLines(
11
+ diffs: Array<[number, string]>,
12
+ lineArray: string[]
13
+ ): void;
14
+ }
15
+ }