CodeReview 코드 리뷰
diff를 기반으로 코드 변화를 보여 주고, 줄 단위 댓글로 코드 리뷰를 진행할 수 있는 컴포넌트예요.
출처: 문서
본문
기본 사용법
:::demo
<template>
<d-button @click="onChange">{{ outputFormat === 'line-by-line' ? '双栏' : '单栏' }}</d-button>
<d-fullscreen v-model="isFullscreen" :z-index="100">
<d-code-review
:diff="diff"
:expand-loader="codeLoader"
:expand-threshold="8"
:output-format="outputFormat"
:show-blob="showBlob"
@add-comment="onAddComment"
@after-view-init="afterViewInit"
@content-refresh="onContentRefresh"
>
<template #headOperate>
<i :class="isFullscreen ? 'icon-frame-contract' : 'icon icon-frame-expand'" @click="isFullscreen = !isFullscreen"></i>
</template>
<template #blob>
<div class="blob-box">
<span>文件内容过长,已收起展示。</span>
<span class="blob-box-expand" @click="showBlob = false">仍要展开</span>
</div>
</template>
</d-code-review>
</d-fullscreen>
</template>
<script>
import { defineComponent, ref, h } from 'vue';
export default defineComponent({
setup() {
const diff = `--- a/src/diff2html.js
+++ b/src/diff2html.js
@@ -7,7 +7,6 @@
(function() {
var diffParser = require('./diff-parser.js').DiffParser;
- var fileLister = require('./file-list-printer.js').FileListPrinter;
var htmlPrinter = require('./html-printer.js').HtmlPrinter;
function Diff2Html() {
@@ -43,7 +42,7 @@
var fileList = '';
if (configOrEmpty.showFiles === true) {
- fileList = fileLister.generateFileList(diffJson, configOrEmpty);
+ fileList = htmlPrinter.generateFileListSummary(diffJson, configOrEmpty);
}
var diffOutput = '';
`;
let codeReviewIns = {};
const outputFormat = ref('line-by-line');
const isFullscreen = ref(false);
const showBlob = ref(true);
const comments = [
{
lineNumber: 10,
lineSide: 'left',
comment: createCommentBlock(10, 'left', 'This is a comment for delete lines.'),
},
{
lineNumber: 43,
lineSide: 'right',
comment: createCommentBlock(43, 'right', 'This is a comment for unchanged lines on Right.'),
},
{
lineNumber: 45,
lineSide: 'right',
comment: createCommentBlock(45, 'right', 'This is a comment for changed lines on Right.'),
},
{
lineNumber: 46,
lineSide: 'left',
comment: createCommentBlock(46, 'left', 'This is a comment for changed lines on Left.'),
},
];
function createCommentBlock(lineNumber, lineSide, param) {
const container = document.createElement('div');
container.classList.add('comment-container');
// render函数第一个参数Component是业务自定义的组件
// render(h(Component), container);
// render函数在demo中不能用,故用原生dom示意
const head = document.createElement('div');
head.classList.add('head-box');
const user = document.createElement('span');
user.innerText = '张三';
const deleteIcon = document.createElement('i');
deleteIcon.className = 'icon icon-delete';
deleteIcon.addEventListener('click', () => {
const index = comments.findIndex((item) => item.lineNumber === lineNumber && item.lineSide === lineSide);
comments.splice(index, 1);
codeReviewIns.removeComment(lineNumber, lineSide);
});
head.appendChild(user);
head.appendChild(deleteIcon);
const span = document.createElement('span');
span.innerText = param;
container.appendChild(head);
container.appendChild(span);
return container;
}
const renderComment = () => {
comments.forEach(({ lineNumber, lineSide, comment }) => {
codeReviewIns.insertComment(lineNumber, lineSide, comment);
});
};
const onChange = () => {
if (outputFormat.value === 'line-by-line') {
outputFormat.value = 'side-by-side';
} else {
outputFormat.value = 'line-by-line';
}
};
const onAddComment = ({ left, right }) => {
let lineSide = '';
let lineNumber = -1;
// line-by-line模式,当在未变更的行添加评论时,left和right均不为-1,业务可自行决定评论插入在left还是right,此demo插入在right。
if (left !== -1 && right !== -1) {
lineSide = 'right';
lineNumber = right;
} else if (left !== -1) {
lineSide = 'left';
lineNumber = left;
} else {
lineSide = 'right';
lineNumber = right;
}
const container = document.createElement('div');
container.classList.add('edit-comment-container');
const textarea = document.createElement('textarea');
textarea.setAttribute('autofocus', true);
const buttonBox = document.createElement('div');
const confirmButton = document.createElement('button');
const cancelButton = document.createElement('button');
confirmButton.innerText = '确定';
confirmButton.addEventListener('click', () => {
const inputValue = textarea.value;
comments.push({ lineNumber, lineSide, comment: createCommentBlock(lineNumber, lineSide, inputValue) });
codeReviewIns.removeComment(lineNumber, lineSide);
renderComment();
});
cancelButton.innerText = '取消';
cancelButton.addEventListener('click', () => {
codeReviewIns.removeComment(lineNumber, lineSide);
});
buttonBox.appendChild(confirmButton);
buttonBox.appendChild(cancelButton);
container.appendChild(textarea);
container.appendChild(buttonBox);
codeReviewIns.insertComment(lineNumber, lineSide, container);
};
const afterViewInit = (e) => {
codeReviewIns = e;
};
const onContentRefresh = (e) => {
renderComment();
};
const codeLoader = ([lStart, lEnd, rStart, rEnd], update) => {
if (lStart >= 60) {
return update('');
}
const code = [
"var diffParser = require('./diff-parser.js').DiffParser;\n",
"var htmlPrinter = require('./html-printer.js').HtmlPrinter;\n",
"var diffParser = require('./diff-parser.js').DiffParser;\n",
"var htmlPrinter = require('./html-printer.js').HtmlPrinter;\n",
"var diffParser = require('./diff-parser.js').DiffParser;\n",
"var htmlPrinter = require('./html-printer.js').HtmlPrinter;\n",
"var diffParser = require('./diff-parser.js').DiffParser;\n",
"var htmlPrinter = require('./html-printer.js').HtmlPrinter;\n",
];
const content =
'--- a/src/diff2html.js\n+++ b/src/diff2html.js\n@@ -' +
Math.min(lStart, lEnd) +
',' +
Math.abs(lStart - lEnd - 1) +
' +' +
Math.min(rStart, rEnd) +
',' +
Math.abs(rStart - rEnd - 1) +
' @@\n ' +
code.slice(0, Math.min(Math.abs(lStart - lEnd - 1), 10)).join(' ');
update(content);
};
return { diff, outputFormat, showBlob, isFullscreen, onChange, onAddComment, afterViewInit, onContentRefresh, codeLoader };
},
});
</script>
<style>
.comment-container {
padding: 8px 12px;
border-top: 1px solid var(--devui-dividing-line, #dfe1e6);
border-bottom: 1px solid var(--devui-dividing-line, #dfe1e6);
}
.comment-container .head-box {
display: flex;
align-items: center;
justify-content: space-between;
}
.comment-container .head-box > span {
font-size: var(--devui-font-size);
font-weight: bold;
}
.comment-container .icon-delete {
color: var(--devui-icon-text, #252b3a);
cursor: pointer;
}
.edit-comment-container {
padding: 8px 12px;
}
.edit-comment-container textarea {
width: 100%;
height: 70px;
resize: none;
outline: none;
}
.edit-comment-container textarea:focus {
border-color: #5e7ce0;
}
.edit-comment-container div {
display: flex;
align-items: center;
justify-content: flex-end;
}
.edit-comment-container div button {
width: 64px;
height: 32px;
font-size: var(--devui-font-size);
color: var(--devui-text, #252b3a);
border-radius: 4px;
background-color: #d1d1d1;
margin-left: 4px;
}
.edit-comment-container div button:first-of-type {
color: var(--devui-light-text, #252b3a);
background-color: var(--devui-primary);
}
.blob-box {
padding: 12px 16px;
text-align: center;
cursor: pointer;
}
.blob-box-expand {
color: var(--devui-brand, #5e7ce0);
}
.blob-box-expand:hover {
text-decoration: underline;
}
</style>
:::
여러 코드 행 선택 사용법
이 예제에서는 여러 코드 행 선택 기능을 켠 뒤, 여러 행을 선택하고 마지막으로 선택한 행을 클릭해 댓글을 추가하는 방법을 보여 줘요. 선택한 코드 행과 코드 블록이 댓글 내용에 함께 담겨요.
:::demo
<template>
<d-button @click="onChange">{{ outputFormat === 'line-by-line' ? '双栏' : '单栏' }}</d-button>
<d-fullscreen v-model="isFullscreen" :z-index="100">
<d-code-review
:diff="diff"
:expand-loader="codeLoader"
:expand-threshold="8"
:output-format="outputFormat"
:show-blob="false"
:allow-checked="true"
@add-comment="onAddComment"
@after-view-init="afterViewInit"
@content-refresh="onContentRefresh"
>
<template #headOperate>
<i :class="isFullscreen ? 'icon-frame-contract' : 'icon icon-frame-expand'" @click="isFullscreen = !isFullscreen"></i>
</template>
</d-code-review>
</d-fullscreen>
</template>
<script>
import { defineComponent, ref, h } from 'vue';
export default defineComponent({
setup() {
const diff = `--- a/src/diff2html.js
+++ b/src/diff2html.js
@@ -7,7 +7,6 @@
(function() {
var diffParser = require('./diff-parser.js').DiffParser;
- var fileLister = require('./file-list-printer.js').FileListPrinter;
var htmlPrinter = require('./html-printer.js').HtmlPrinter;
function Diff2Html() {
@@ -43,7 +42,7 @@
var fileList = '';
if (configOrEmpty.showFiles === true) {
- fileList = fileLister.generateFileList(diffJson, configOrEmpty);
+ fileList = htmlPrinter.generateFileListSummary(diffJson, configOrEmpty);
}
var diffOutput = '';
`;
let codeReviewIns = {};
const outputFormat = ref('line-by-line');
const isFullscreen = ref(false);
const comments = [];
function createCommentBlock(lineNumber, lineSide, param) {
const container = document.createElement('div');
container.classList.add('comment-container');
// render函数第一个参数Component是业务自定义的组件
// render(h(Component), container);
// render函数在demo中不能用,故用原生dom示意
const head = document.createElement('div');
head.classList.add('head-box');
const user = document.createElement('span');
user.innerText = '张三';
const deleteIcon = document.createElement('i');
deleteIcon.className = 'icon icon-delete';
deleteIcon.addEventListener('click', () => {
const index = comments.findIndex((item) => item.lineNumber === lineNumber && item.lineSide === lineSide);
comments.splice(index, 1);
codeReviewIns.removeComment(lineNumber, lineSide);
});
head.appendChild(user);
head.appendChild(deleteIcon);
const span = document.createElement('span');
span.innerText = param;
container.appendChild(head);
container.appendChild(span);
return container;
}
const renderComment = () => {
comments.forEach(({ lineNumber, lineSide, comment }) => {
codeReviewIns.insertComment(lineNumber, lineSide, comment);
});
};
const onChange = () => {
if (outputFormat.value === 'line-by-line') {
outputFormat.value = 'side-by-side';
} else {
outputFormat.value = 'line-by-line';
}
};
const onAddComment = ({ left, right, details }) => {
console.log(left, right, details);
let lineSide = '';
let lineNumber = -1;
// line-by-line模式,当在未变更的行添加评论时,left和right均不为-1,业务可自行决定评论插入在left还是right,此demo插入在right。
if (left !== -1 && right !== -1) {
lineSide = 'right';
lineNumber = right;
} else if (left !== -1) {
lineSide = 'left';
lineNumber = left;
} else {
lineSide = 'right';
lineNumber = right;
}
let codeCheckedStr = '';
let firstLineType = '';
let lastLineType = '';
let firstLine = '';
let lastLine = '';
let codeStr = '';
// 单栏
if (outputFormat.value === 'line-by-line' && details) {
firstLineType = details.codes[0].slice(0, 1);
lastLineType = details.codes[details.codes.length - 1].slice(0, 1);
console.log(firstLineType, lastLineType);
firstLine = details.lefts[0];
lastLine = details.lefts[details.lefts.length - 1];
// 删除行是left 增加行是right
if (firstLineType === '+') {
firstLine = details.rights[0];
}
if (lastLineType === '+') {
lastLine = details.rights[details.rights.length - 1];
}
codeCheckedStr = `lines ${firstLineType}${firstLine} to ${lastLineType}${lastLine}\n`;
codeStr = details.codes.join('\n');
}
// 双栏
if (outputFormat.value === 'side-by-side' && details) {
let lineNumbers = details.lefts;
let codes = details.codes.leftCode;
if (left === -1) {
lineNumbers = details.rights;
codes = details.codes.rightCode;
}
firstLineType = codes[0].slice(0, 1);
lastLineType = codes[codes.length - 1].slice(0, 1);
firstLine = lineNumbers[0];
lastLine = lineNumbers[lineNumbers.length - 1];
codeCheckedStr = `lines ${firstLineType}${firstLine} to ${lastLineType}${lastLine}\n`;
codeStr = codes.join('\n');
}
const container = document.createElement('div');
container.classList.add('edit-comment-container');
const textarea = document.createElement('textarea');
textarea.setAttribute('autofocus', true);
textarea.value = codeCheckedStr ? `${codeCheckedStr}\n \`\`\`suggestion \n
${codeStr} \n \`\`\`` : '';
const buttonBox = document.createElement('div');
const confirmButton = document.createElement('button');
const cancelButton = document.createElement('button');
confirmButton.innerText = '确定';
confirmButton.addEventListener('click', () => {
const inputValue = textarea.value;
comments.push({ lineNumber, lineSide, comment: createCommentBlock(lineNumber, lineSide, inputValue) });
codeReviewIns.removeComment(lineNumber, lineSide);
renderComment();
});
cancelButton.innerText = '取消';
cancelButton.addEventListener('click', () => {
codeReviewIns.removeComment(lineNumber, lineSide);
});
buttonBox.appendChild(confirmButton);
buttonBox.appendChild(cancelButton);
container.appendChild(textarea);
container.appendChild(buttonBox);
codeReviewIns.insertComment(lineNumber, lineSide, container);
};
const afterViewInit = (e) => {
codeReviewIns = e;
};
const onContentRefresh = (e) => {
renderComment();
};
const codeLoader = ([lStart, lEnd, rStart, rEnd], update) => {
if (lStart >= 60) {
return update('');
}
const code = [
"var diffParser = require('./diff-parser.js').DiffParser;\n",
"var htmlPrinter = require('./html-printer.js').HtmlPrinter;\n",
"var diffParser = require('./diff-parser.js').DiffParser;\n",
"var htmlPrinter = require('./html-printer.js').HtmlPrinter;\n",
"var diffParser = require('./diff-parser.js').DiffParser;\n",
"var htmlPrinter = require('./html-printer.js').HtmlPrinter;\n",
"var diffParser = require('./diff-parser.js').DiffParser;\n",
"var htmlPrinter = require('./html-printer.js').HtmlPrinter;\n",
];
const content =
'--- a/src/diff2html.js\n+++ b/src/diff2html.js\n@@ -' +
Math.min(lStart, lEnd) +
',' +
Math.abs(lStart - lEnd - 1) +
' +' +
Math.min(rStart, rEnd) +
',' +
Math.abs(rStart - rEnd - 1) +
' @@\n ' +
code.slice(0, Math.min(Math.abs(lStart - lEnd - 1), 10)).join(' ');
update(content);
};
return { diff, outputFormat, isFullscreen, onChange, onAddComment, afterViewInit, onContentRefresh, codeLoader };
},
});
</script>
<style>
.comment-container {
padding: 8px 12px;
border-top: 1px solid var(--devui-dividing-line, #dfe1e6);
border-bottom: 1px solid var(--devui-dividing-line, #dfe1e6);
}
.comment-container .head-box {
display: flex;
align-items: center;
justify-content: space-between;
}
.comment-container .head-box > span {
font-size: var(--devui-font-size);
font-weight: bold;
}
.comment-container .icon-delete {
color: var(--devui-icon-text, #252b3a);
cursor: pointer;
}
.edit-comment-container {
padding: 8px 12px;
}
.edit-comment-container textarea {
width: 100%;
height: 120px;
resize: none;
outline: none;
}
.edit-comment-container textarea:focus {
border-color: #5e7ce0;
}
.edit-comment-container div {
display: flex;
align-items: center;
justify-content: flex-end;
}
.edit-comment-container div button {
width: 64px;
height: 32px;
font-size: var(--devui-font-size);
color: var(--devui-text, #252b3a);
border-radius: 4px;
background-color: #d1d1d1;
margin-left: 4px;
}
.edit-comment-container div button:first-of-type {
color: var(--devui-light-text, #252b3a);
background-color: var(--devui-primary);
}
.blob-box {
padding: 12px 16px;
text-align: center;
cursor: pointer;
}
.blob-box-expand {
color: var(--devui-brand, #5e7ce0);
}
.blob-box-expand:hover {
text-decoration: underline;
}
</style>
:::
CodeReview 파라미터
| 파라미터 | 타입 | 기본값 | 설명 |
|---|---|---|---|
| diff | string |
'' | 필수, diff 콘텐츠 |
| fold | boolean |
false | 선택, 접어서 표시 여부 |
| allow-comment | boolean |
true | 선택, 댓글 지원 여부 |
| allow-checked | boolean |
false | 선택, 코드 라인 선택 지원 여부. 사용 시 드래그로 여러 코드 라인을 선택할 수 있고 행을 넘어 선택 가능하며, add-comment 이벤트 반환값이 달라짐. 파라미터 상세는 CommentPosition 참고 |
| show-blob | boolean |
false | 선택, 요약 콘텐츠 표시 여부. 주로 대용량 파일이나 바이너리 파일 등에서 요약 콘텐츠를 표시할 때 사용 |
| output-format | OutputFormat | 'line-by-line' | 선택, diff 표시 형식. 단일 컬럼 또는 분할 컬럼 표시 |
| diff-type | DiffType | 'modify' | 선택, 파일 diff 타입 |
| allow-expand | boolean |
true | 선택, diff가 아닌 접힌 코드 펼치기 지원 여부 |
| expand-threshold | number |
50 | 선택, 전체 코드 라인 펼치기 임계값. 임계값 미만이면 전체 펼침, 초과 시 위로/아래로 두 동작으로 펼침 |
| expand-loader | (interval: Array<number>, update: (code: string) => void) => void |
-- | 선택, 코드 펼치기 콜백 함수. interval은 펼치기 경계이며, 펼친 코드를 가져온 후 update로 뷰를 갱신 |
| options | object |
{} | 선택, diff2html에 전달할 설정 항목 |
CodeReview 이벤트
| 이벤트명 | 타입 | 설명 |
|---|---|---|
| fold-change | Function(status: boolean) |
접힘 상태 변경 시 트리거되는 이벤트. 콜백 파라미터는 현재 접힘 상태 |
| add-comment | Function(position: CommentPosition) |
댓글 추가 아이콘 클릭 시 트리거되는 이벤트. 파라미터 상세는 CommentPosition 참고 |
| after-view-init | Function(methods: CodeReviewMethods) |
초기화 완료 후 트리거되는 이벤트. 관련 조작 메서드를 반환하며, 파라미터 상세는 CodeReviewMethods 참고 |
| content-refresh | Function(diffFile: DiffFile) |
콘텐츠 새로고침 후 트리거되는 이벤트. 파싱된 관련 파일 정보를 반환하며, 파라미터 상세는 DiffFile 참고 |
| after-check-lines | Function(position: CommentPosition) |
여러 라인 선택 후 트리거되는 이벤트. 파라미터 상세는 CommentPosition 참고 |
CodeReview 슬롯
| 슬롯명 | 설명 |
|---|---|
| headOperate | head 오른쪽 액션 영역 콘텐츠 커스텀 |
| blob | 표시할 요약 콘텐츠 커스텀. 대용량 파일이나 바이너리 파일일 때 사용 |
인터페이스 정의
OutputFormat
type OutputFormat = 'line-by-line' | 'side-by-side';
DiffType
type DiffType = 'modify' | 'add' | 'delete' | 'rename';
LineSide
type LineSide = 'left' | 'right';
CommentPosition
line-by-line 모드에서 left는 왼쪽 컬럼의 행 번호, right는 오른쪽 컬럼의 행 번호를 나타내요. 댓글을 추가하는 위치에서 내용 변경이 없으면 left와 right 모두 실제 행 번호예요. 해당 위치의 내용이 삭제됐다면 left는 실제 행 번호, right는 -1이고, 해당 위치가 새로 추가된 내용이라면 left는 -1, right는 실제 행 번호가 돼요.
interface CommentPosition {
left: number;
right: number;
position?: 'left'|'right' // 双栏模式,点击的左侧还是右侧
}
allow-checked 모드에서는 CommentPosition 반환값에 details가 하나 더 포함돼요. lefts와 rights는 좌우 양쪽에서 선택된 행 번호를, codes는 선택된 부분의 코드 블록을 나타내고, 단일 컬럼 모드에서는 배열이며 이중 컬럼 모드에서는 왼쪽과 오른쪽 선택 코드 블록을 각각 반환하는 객체예요. 선택한 행의 왼쪽이나 오른쪽에 값이 없으면 배열의 마지막 항목이 -1이 돼요(단일 컬럼 모드에서는 방향을 바로 건너뛰어 선택할 수 없다는 점에 유의하세요. 예를 들어 왼쪽 행만 있는 줄을 선택한 뒤 바로 오른쪽 행만 있는 줄을 이어서 선택할 수 없고, 예제에서도 왼쪽 10을 선택했다고 바로 오른쪽 45를 선택할 수는 없어요).
interface CommentPosition {
left: number,
right: number,
details?: {
lefts: Array<number>,
rights: Array<number>,
codes: Array<string> | {
leftCode: Array<string>,
rightCode: Array<string>,
}
}
}
CodeReviewMethods
interface CodeReviewMethods {
// 切换折叠状态,可通过 status 参数设置折叠状态
toggleFold: (status?: boolean) => void;
// 插入评论的方法,传入行号、left/right、评论块DOM结构
insertComment: (lineNumber: number, lineSide: LineSide, commentDom: HTMLElement) => void;
// 删除评论的方法,传入行号、left/right
removeComment: (lineNumber: number, lineSide: LineSide) => void;
// 清除选中行样式
clearCheckedLines: () => void;
}
더 알아보기 (Learn more)
CodeReview 컴포넌트를 사용하면 diff 내용을 한눈에 살펴보고 줄 단위 댓글로 코드 리뷰를 진행할 수 있어요. allow-checked 옵션을 켜면 여러 코드 행을 선택해 댓글에 코드 블록을 함께 담을 수도 있어요.