diff --git a/.github/workflows/auto-publish.yml b/.github/workflows/auto-publish.yml index 59d58e9774..7c5916a5c4 100644 --- a/.github/workflows/auto-publish.yml +++ b/.github/workflows/auto-publish.yml @@ -35,7 +35,7 @@ jobs: run: | ls node -v - npm install pnpm -g + npm install pnpm@9.15.4 -g pnpm -v pnpm install --no-frozen-lockfile pnpm run build:lib diff --git a/packages/devui-vue/devui/code-review/src/code-review-types.ts b/packages/devui-vue/devui/code-review/src/code-review-types.ts index 74a368ab81..d8eb92d2d1 100644 --- a/packages/devui-vue/devui/code-review/src/code-review-types.ts +++ b/packages/devui-vue/devui/code-review/src/code-review-types.ts @@ -10,6 +10,18 @@ export interface CommentPosition { left: number; right: number; } +export type ILineNumberTdMap = Record; +export interface IExpandLineNumberInfo { + nextL: string; + nextR: string; + prevL: string; + prevR: string; +} +export interface ICheckedLineDetails { + lefts: number[]; + rights: number[]; + codes: Record | string[]; +} export interface CodeReviewMethods { toggleFold: (status?: boolean) => void; insertComment: (lineNumber: number, lineSide: LineSide, commentDom: HTMLElement) => void; @@ -58,6 +70,10 @@ export const codeReviewProps = { expandLoader: { type: Function as PropType<(interval: Array, update: (code: string) => void) => void>, }, + options: { + type: Object as PropType>, + default: () => ({}), + }, }; export type CodeReviewProps = ExtractPropTypes; diff --git a/packages/devui-vue/devui/code-review/src/code-review.scss b/packages/devui-vue/devui/code-review/src/code-review.scss index 47a1c36bff..771f383b3a 100644 --- a/packages/devui-vue/devui/code-review/src/code-review.scss +++ b/packages/devui-vue/devui/code-review/src/code-review.scss @@ -183,6 +183,10 @@ content: ''; } + .d2h-code-linenumber { + border-right: 2px solid transparent; + } + .d2h-code-line-ctn { word-break: break-all; word-wrap: break-word !important; @@ -222,6 +226,11 @@ position: static; display: table-cell; } + + .d-code-left:nth-of-type(2) > .d2h-code-side-line, + .d-code-right:nth-of-type(4) > .d2h-code-side-line { + border-left: 2px solid transparent; + } } .d2h-file-diff { @@ -269,6 +278,15 @@ background-color: #daf4ae; // 增加行中的number } } + + &.d2h-code-linenumber { + border-right: 2px solid #fe7300; + } + + &.d-code-left:nth-of-type(2) > .d2h-code-side-line, + &.d-code-right:nth-of-type(4) > .d2h-code-side-line { + border-left: 2px solid #fe7300; + } } } @@ -286,4 +304,16 @@ box-shadow: 0 0 1px 1px rgba(37, 43, 58, 0.16); cursor: pointer; } + + &--left-selected { + .d-code-right span { + user-select: none; + } + } + + &--right-selected { + .d-code-left span { + user-select: none; + } + } } diff --git a/packages/devui-vue/devui/code-review/src/code-review.tsx b/packages/devui-vue/devui/code-review/src/code-review.tsx index 4337d95306..73ab7c5795 100644 --- a/packages/devui-vue/devui/code-review/src/code-review.tsx +++ b/packages/devui-vue/devui/code-review/src/code-review.tsx @@ -1,5 +1,5 @@ /* @jsxImportSource vue */ -import { defineComponent, onMounted, provide, toRefs, onBeforeUnmount } from 'vue'; +import { defineComponent, onMounted, provide, toRefs, ref } from 'vue'; import type { SetupContext } from 'vue'; import CodeReviewHeader from './components/code-review-header'; import { CommentIcon } from './components/code-review-icons'; @@ -14,30 +14,45 @@ import './code-review.scss'; export default defineComponent({ name: 'DCodeReview', props: codeReviewProps, - emits: ['foldChange', 'addComment', 'afterViewInit', 'contentRefresh'], + emits: ['foldChange', 'addComment', 'afterViewInit', 'contentRefresh', 'afterCheckLines'], setup(props: CodeReviewProps, ctx: SetupContext) { const ns = useNamespace('code-review'); const { diffType } = toRefs(props); - const { renderHtml, reviewContentRef, diffFile, onContentClick } = useCodeReview(props, ctx); + const reviewContentRef = ref(); + const { + commentLeft, + commentTop, + mouseEvent, + onCommentMouseLeave, + onCommentIconClick, + insertComment, + removeComment, + clearCheckedLines, + updateLineNumberMap, + updateCheckedLine, + } = useCodeReviewComment(reviewContentRef, props, ctx); + const { renderHtml, diffFile, selectionSide, onContentClick } = + useCodeReview(props, ctx, reviewContentRef, updateLineNumberMap, updateCheckedLine); const { isFold, toggleFold } = useCodeReviewFold(props, ctx); - const { commentLeft, commentTop, - mouseEvent, onCommentMouseLeave, - onCommentIconClick, onCommentKeyDown, - unCommentKeyDown, insertComment, - removeComment, updateCheckedLineClass } = useCodeReviewComment(reviewContentRef, props, ctx); onMounted(() => { - ctx.emit('afterViewInit', { toggleFold, insertComment, removeComment, updateCheckedLineClass }); - onCommentKeyDown(); + ctx.emit('afterViewInit', { + toggleFold, + insertComment, + removeComment, + clearCheckedLines, + }); }); - // 销毁 - onBeforeUnmount(() => { - unCommentKeyDown(); + provide(CodeReviewInjectionKey, { + diffType, + reviewContentRef, + diffInfo: diffFile.value[0], + isFold, + rootCtx: ctx, }); - provide(CodeReviewInjectionKey, { diffType, reviewContentRef, diffInfo: diffFile.value[0], isFold, rootCtx: ctx }); return () => ( -
+
(isFold.value = !isFold.value)} />
{props.showBlob ? ( @@ -59,8 +74,7 @@ export default defineComponent({ class="comment-icon" style={{ left: commentLeft.value + 'px', top: commentTop.value + 'px' }} onClick={onCommentIconClick} - onMouseleave={onCommentMouseLeave} - > + onMouseleave={onCommentMouseLeave}>
)} diff --git a/packages/devui-vue/devui/code-review/src/composables/use-code-review-comment.ts b/packages/devui-vue/devui/code-review/src/composables/use-code-review-comment.ts index c61346a564..5a87a0e21c 100644 --- a/packages/devui-vue/devui/code-review/src/composables/use-code-review-comment.ts +++ b/packages/devui-vue/devui/code-review/src/composables/use-code-review-comment.ts @@ -1,6 +1,7 @@ -import { ref, toRefs, onUnmounted, watch } from 'vue'; +import { ref, toRefs, onUnmounted } from 'vue'; import type { SetupContext, Ref } from 'vue'; -import type { LineSide, CodeReviewProps } from '../code-review-types'; +import { useCodeReviewLineSelection } from './use-code-review-line-selection'; +import type { LineSide, CodeReviewProps, ICheckedLineDetails } from '../code-review-types'; import { useNamespace } from '../../../shared/hooks/use-namespace'; import { notEmptyNode, @@ -13,23 +14,18 @@ import { export function useCodeReviewComment(reviewContentRef: Ref, props: CodeReviewProps, ctx: SetupContext) { const { outputFormat, allowComment, allowChecked } = toRefs(props); const ns = useNamespace('code-review'); + const { onMousedown, updateLineNumberMap, getCheckedLineDetails, clearCommentClass, updateCheckedLine } = useCodeReviewLineSelection( + reviewContentRef, + props, + afterMouseup + ); const commentLeft = ref(-100); const commentTop = ref(-100); let currentLeftLineNumber = -1; let currentRightLineNumber = -1; + let currentPosition: 'left' | 'right'; let lastLineNumberContainer: HTMLElement | null; - let checkedLineNumberContainer: Array = []; - let isShift = false; - let currentLeftLineNumbers: Array = []; - let currentRightLineNumbers: Array = []; - let checkedLineCodeString: Array | Record> = {}; - watch(() => outputFormat.value, () => { - // 如果出现单栏双栏切换则需要重置选中 - checkedLineNumberContainer = []; - currentLeftLineNumbers = []; - currentRightLineNumbers = []; - checkedLineCodeString = []; - }); + const resetLeftTop = () => { commentLeft.value = -100; commentTop.value = -100; @@ -79,6 +75,8 @@ export function useCodeReviewComment(reviewContentRef: Ref, props: commentLeft.value = left; commentTop.value = top; currentLeftLineNumber = parseInt(leftLineNumberContainer.innerText); + currentRightLineNumber = parseInt(rightLineNumberContainer.innerText || '-1'); + currentPosition = 'left'; } else { resetLeftTop(); } @@ -92,7 +90,9 @@ export function useCodeReviewComment(reviewContentRef: Ref, props: const { top, left } = rightLineNumberContainer.getBoundingClientRect(); commentLeft.value = left; commentTop.value = top; + currentLeftLineNumber = parseInt(leftLineNumberContainer.innerText || '-1'); currentRightLineNumber = parseInt(rightLineNumberContainer.innerText); + currentPosition = 'right'; } else { resetLeftTop(); } @@ -111,178 +111,32 @@ export function useCodeReviewComment(reviewContentRef: Ref, props: resetLeftTop(); } }; - function commentKeyDown(e: any) { - // keyCode已经被废弃了 用e.key代替 或者e.code代替 - switch (e.key) { - case 'Shift': - isShift = true; - break; - } - } - function commentKeyUp(e: any) { - e.preventDefault(); - switch (e.key) { - case 'Shift': - isShift = false; - break; - } - } - // 销毁键盘事件 - const unCommentKeyDown = () => { - document.removeEventListener('keydown', commentKeyDown); - document.removeEventListener('keyup', commentKeyUp); - }; - // 键盘事件 - const onCommentKeyDown = () => { - document.addEventListener('keydown', commentKeyDown); - document.addEventListener('keyup', commentKeyUp); - }; - // 获代码行 取值方法 - const getLineNumbers = (currentNumber: number, currentNumbers: Array, e: Event) => { - if (currentNumber === -1) { // 当前行没数据不代表之前选中的没数据,此时返回原来的 - return currentNumbers; - } - if (currentNumbers.length === 0) { - return [currentNumber]; - } - const numbers = [...currentNumbers]; - let max = Math.max(...numbers); - const min = Math.min(...numbers); - if (currentNumber > max) { // 限制规则只能从小选到大。 - max = currentNumber; - } - return Array.from({ length: max - min + 1 }, (_, i) => i + min); - }; - // 获取一些公共类和判断 - const getCommonClassAndJudge = (side: string) => { - const lineClassName = side === 'line-by-line' ? '.d2h-code-linenumber' : '.d2h-code-side-linenumber'; - const linenumberDom = reviewContentRef.value.querySelectorAll(lineClassName); - const checkedLine = [currentLeftLineNumbers, currentRightLineNumbers]; - return { - linenumberDom, - checkedLine - }; - }; - // 之前每次都先移出所有选中的方法过于浪费性能,增加具体dom节点选中方法(防重复添加) - const addCommentCheckedClass = (Dom: Element) => { - !Dom.classList.contains('comment-checked') && Dom.classList.add('comment-checked'); - }; - // 选中(单栏) - const addCommentClassSingle = (side: string) => { - const { linenumberDom, checkedLine } = getCommonClassAndJudge(side); - const checkedCodeContent = []; - // resetCommentClass(); - for (let i = 0; i < linenumberDom.length; i++) { - const lineNumberDomLeft = linenumberDom[i].children[0]; - const lineNumberDomRight = linenumberDom[i].children[1]; - if (lineNumberDomLeft || lineNumberDomRight) { - const codeLineNumberLeft = parseInt((lineNumberDomLeft as HTMLElement)?.innerText); - const codeLineNumberRight = parseInt((lineNumberDomRight as HTMLElement)?.innerText); - // 因为存在左边或者右边为空的num所以两边都要循环,但是同一个dom已经过就不需要再赋予 - if (checkedLine[0].includes(codeLineNumberLeft) || checkedLine[1].includes(codeLineNumberRight)) { - checkedLineNumberContainer.push(linenumberDom[i]); - // 两个节点之间可能间隔文本节点 - const codeNode = (linenumberDom[i].nextSibling as HTMLElement).nodeName === '#text' - ? (linenumberDom[i].nextSibling as HTMLElement).nextSibling - : linenumberDom[i].nextSibling; - checkedCodeContent.push((codeNode as HTMLElement)?.innerText); - addCommentCheckedClass(linenumberDom[i]); - addCommentCheckedClass(codeNode as HTMLElement); - } - } - } - checkedLineCodeString = checkedCodeContent; - }; - // 选中(双栏) - const addCommentClassDouble = (side: string) => { - const { linenumberDom, checkedLine } = getCommonClassAndJudge(side); - const checkedCodeContentLeft = []; - const checkedCodeContentRight = []; - - function checkedFunc(Dom: Element) { - checkedLineNumberContainer.push(Dom); - const codeNode = (Dom.nextSibling as HTMLElement).nodeName === '#text' - ? (Dom.nextSibling as HTMLElement).nextSibling - : Dom.nextSibling; - addCommentCheckedClass(Dom); - addCommentCheckedClass(codeNode as HTMLElement); - return (codeNode as HTMLElement)?.innerText; - } - for (let i = 0; i < linenumberDom.length; i++) { // 左右双栏一起遍历 - const codeLineNumber = parseInt(linenumberDom[i]?.innerHTML); - if (linenumberDom[i].classList.contains('d-code-left') && checkedLine[0].includes(codeLineNumber)) { - const lineNumText = checkedFunc(linenumberDom[i]); - checkedCodeContentLeft.push(lineNumText); - continue; - } - if (linenumberDom[i].classList.contains('d-code-right') && checkedLine[1].includes(codeLineNumber)) { - const lineNumText = checkedFunc(linenumberDom[i]); - checkedCodeContentRight.push(lineNumText); - } - } - checkedLineCodeString = { leftCode: checkedCodeContentLeft, rightCode: checkedCodeContentRight }; - }; - const updateCheckedLineClass = () => { - if (outputFormat.value === 'line-by-line') { - addCommentClassSingle(outputFormat.value); - return; - } - addCommentClassDouble(outputFormat.value); - }; - // 还原样式 - const resetCommentClass = () => { - for (let i = 0; i < checkedLineNumberContainer.length; i++) { - checkedLineNumberContainer[i].classList.remove('comment-checked'); - const codeNode = (checkedLineNumberContainer[i].nextSibling as HTMLElement).nodeName === '#text' - ? (checkedLineNumberContainer[i].nextSibling as HTMLElement).nextSibling - : checkedLineNumberContainer[i].nextSibling; - (codeNode as HTMLElement)?.classList.remove('comment-checked'); - } - checkedLineNumberContainer = []; - }; - // 按住shift键点击 - const commentShiftClick = (e: Event) => { - currentLeftLineNumbers = currentLeftLineNumber === -1 - ? currentLeftLineNumbers - : getLineNumbers(currentLeftLineNumber, currentLeftLineNumbers, e); - currentRightLineNumbers = currentRightLineNumber === -1 - ? currentRightLineNumbers - : getLineNumbers(currentRightLineNumber, currentRightLineNumbers, e); - updateCheckedLineClass(); - }; // 点击 - const commentClick = (e: Event) => { - interface recordType { - left: number; - right: number; - details?: { - lefts: Array; - rights: Array; - codes: Record> | Record>; - }; - } - let obj: recordType = { left: currentLeftLineNumber, right: currentRightLineNumber }; - if (currentLeftLineNumbers.length >= 1 || currentRightLineNumbers.length >= 1 && allowChecked.value) { // 选中模式 - const maxCurrentLeftLineNumber = currentLeftLineNumbers[currentLeftLineNumbers.length - 1]; - const maxCurrentRightLineNumber = currentRightLineNumbers[currentRightLineNumbers.length - 1]; - if (maxCurrentLeftLineNumber === currentLeftLineNumber || maxCurrentRightLineNumber === currentRightLineNumber) { - // 点击添加评论图标触发的事件 - obj = { left: currentLeftLineNumber, right: currentRightLineNumber, details: { - lefts: currentLeftLineNumbers, rights: currentRightLineNumbers, codes: checkedLineCodeString - }}; - } else{ - currentLeftLineNumbers = []; - currentRightLineNumbers = []; - resetCommentClass(); + const commentClick = () => { + let obj = { left: currentLeftLineNumber, right: currentRightLineNumber, position: currentPosition }; + const checkedLineDetails = getCheckedLineDetails(); + // 多行选中 + if (checkedLineDetails && allowChecked.value) { + const { lefts, rights } = checkedLineDetails; + const maxCheckedLeftLineNumber = lefts[lefts.length - 1]; + const maxCheckedRightLineNumber = rights[rights.length - 1]; + if (maxCheckedLeftLineNumber === currentLeftLineNumber || maxCheckedRightLineNumber === currentRightLineNumber) { + obj.details = checkedLineDetails; + } else { + clearCommentClass(); } } // 点击添加评论图标触发的事件 ctx.emit('addComment', obj); }; + function afterMouseup(details: ICheckedLineDetails) { + ctx.emit('afterCheckLines', { left: currentLeftLineNumber, right: currentRightLineNumber, position: currentPosition, details }); + } // 图标或者单行的点击 const onCommentIconClick = (e: Event) => { - if (e) { // 根据时间反回的dom判断是否点击中的制定区域 + if (e) { + // 根据时间反回的dom判断是否点击中的制定区域 const composedPath = e.composedPath() as HTMLElement[]; const lineNumberBox = composedPath.find( (item) => item.classList?.contains('comment-icon-hover') || item.classList?.contains('comment-icon') @@ -291,12 +145,7 @@ export function useCodeReviewComment(reviewContentRef: Ref, props: return; } } - // 按住shift键选中 - if (isShift && allowChecked.value) { - commentShiftClick(e); - return; - } - commentClick(e); + commentClick(); }; const insertComment = (lineNumber: number, lineSide: LineSide, commentDom: HTMLElement) => { if (outputFormat.value === 'line-by-line') { @@ -338,7 +187,18 @@ export function useCodeReviewComment(reviewContentRef: Ref, props: } }; - const mouseEvent = allowComment.value ? { onMousemove: onMouseMove, onMouseleave: onMouseleave } : {}; + const clearCheckedLines = () => { + clearCommentClass(); + }; + + const mouseEvent: Record void> = {}; + if (allowComment.value) { + mouseEvent.onMousemove = onMouseMove; + mouseEvent.onMouseleave = onMouseleave; + } + if (props.allowChecked) { + mouseEvent.onMousedown = onMousedown; + } window.addEventListener('scroll', resetLeftTop); @@ -350,14 +210,12 @@ export function useCodeReviewComment(reviewContentRef: Ref, props: commentLeft, commentTop, mouseEvent, - // currentLeftLineNumbers, - // currentRightLineNumbers, - updateCheckedLineClass, + clearCheckedLines, onCommentMouseLeave, onCommentIconClick, - onCommentKeyDown, - unCommentKeyDown, insertComment, removeComment, + updateLineNumberMap, + updateCheckedLine, }; -} +} \ No newline at end of file diff --git a/packages/devui-vue/devui/code-review/src/composables/use-code-review-expand.ts b/packages/devui-vue/devui/code-review/src/composables/use-code-review-expand.ts index cbc29a0c9e..d77d140947 100644 --- a/packages/devui-vue/devui/code-review/src/composables/use-code-review-expand.ts +++ b/packages/devui-vue/devui/code-review/src/composables/use-code-review-expand.ts @@ -1,6 +1,6 @@ import { toRefs } from 'vue'; import type { Ref } from 'vue'; -import type { CodeReviewProps, ExpandDirection } from '../code-review-types'; +import type { CodeReviewProps, ExpandDirection, IExpandLineNumberInfo } from '../code-review-types'; import { ExpandLineReg, FirstLineReg } from '../const'; import { attachExpandUpDownButton, @@ -14,7 +14,12 @@ import { ifRemoveExpandLineForDoubleColumn, } from '../utils'; -export function useCodeReviewExpand(reviewContentRef: Ref, props: CodeReviewProps) { +export function useCodeReviewExpand( + reviewContentRef: Ref, + props: CodeReviewProps, + updateLineNumberMap: (expandLineNumberInfo: IExpandLineNumberInfo, newCode: string, direction: 'up' | 'down') => void, + updateCheckedLine: (expandLineNumberInfo: IExpandLineNumberInfo, direction: 'up' | 'down') => void +) { const { outputFormat, expandThreshold, expandLoader } = toRefs(props); const processSideBySide = () => { @@ -58,7 +63,12 @@ export function useCodeReviewExpand(reviewContentRef: Ref, props: C attachExpandUpDownButton(loadMoreLine.children[0] as HTMLElement, 'down'); }; - const insertIncrementCodeForDoubleColumn = (code: string, direction: 'up' | 'down', referenceDom: HTMLElement | null | undefined) => { + const insertIncrementCodeForDoubleColumn = ( + code: string, + direction: 'up' | 'down', + referenceDom: HTMLElement | null | undefined, + options: Record + ) => { if (!referenceDom) { return; } @@ -69,7 +79,7 @@ export function useCodeReviewExpand(reviewContentRef: Ref, props: C const prefix = '--- updated_at\tJan 1, 2019, 0:0:0 AM\n+++ updated_at\tJan 1, 2019, 0:0:0 AM\n'; const container = document.createElement('div'); // 解析code - parseDiffCode(container, prefix + code, outputFormat.value, true); + parseDiffCode(container, prefix + code, outputFormat.value, options, true); const trNodes = Array.from(container.querySelectorAll('tr')); const expandLine = trNodes.find((element) => (element.children[1] as HTMLElement)?.innerText.trim().match(ExpandLineReg)); @@ -80,8 +90,12 @@ export function useCodeReviewExpand(reviewContentRef: Ref, props: C // 过滤有效行 const trNodesToBeInserted = trNodes.filter((element) => element !== expandLine); + /* 更新左右行号映射关系 */ + updateLineNumberMap(referenceDom.dataset as unknown as IExpandLineNumberInfo, prefix + code, direction); // 将有效代码行插入页面 insertIncrementLineToPage(referenceDom, trNodesToBeInserted, direction); + /* 若新增行在选中区间,则将新增行高亮 */ + updateCheckedLine(referenceDom.dataset as unknown as IExpandLineNumberInfo, direction); // 判断是否需要移除展开行,代码若已全部展开,不再需要展开行 const removedExpandLine = ifRemoveExpandLineForDoubleColumn(referenceDom, expandLine, direction); @@ -158,7 +172,12 @@ export function useCodeReviewExpand(reviewContentRef: Ref, props: C attachExpandUpDownButton(loadMoreLine.children[0] as HTMLElement, 'down'); }; - const insertIncrementCode = (code: string, direction: 'up' | 'down', referenceDom: HTMLElement | null | undefined) => { + const insertIncrementCode = ( + code: string, + direction: 'up' | 'down', + referenceDom: HTMLElement | null | undefined, + options: Record + ) => { if (!referenceDom) { return; } @@ -169,7 +188,7 @@ export function useCodeReviewExpand(reviewContentRef: Ref, props: C const prefix = '--- updated_at\tJan 1, 2019, 0:0:0 AM\n+++ updated_at\tJan 1, 2019, 0:0:0 AM\n'; const container = document.createElement('div'); // 解析code - parseDiffCode(container, prefix + code, outputFormat.value, true); + parseDiffCode(container, prefix + code, outputFormat.value, options, true); const trNodes = Array.from(container.querySelectorAll('tr')); const expandLine = trNodes.find((element) => (element.children[1] as HTMLElement)?.innerText.trim().match(ExpandLineReg)); @@ -182,6 +201,8 @@ export function useCodeReviewExpand(reviewContentRef: Ref, props: C const trNodesToBeInserted = trNodes.filter((element) => element.children[0].children.length === 2); // 将有效代码行插入页面 insertIncrementLineToPage(referenceDom, trNodesToBeInserted, direction); + /* 若新增行在选中区间,则将新增行高亮 */ + updateCheckedLine(referenceDom.dataset as unknown as IExpandLineNumberInfo, direction); // 判断是否需要移除展开行,代码若已全部展开,不再需要展开行 const removedExpandLine = ifRemoveExpandLine(referenceDom, expandLine, direction); @@ -213,7 +234,7 @@ export function useCodeReviewExpand(reviewContentRef: Ref, props: C } }; - const onExpandButtonClick = (e: Event) => { + const onExpandButtonClick = (e: Event, options: Record) => { const composedPath = e.composedPath() as HTMLElement[]; const expandIconDom = composedPath.find((element) => element.classList?.contains('expand-icon')); if (expandIconDom) { @@ -224,8 +245,8 @@ export function useCodeReviewExpand(reviewContentRef: Ref, props: C const [leftLineStart, leftLineEnd, rightLineStart, rightLineEnd] = getLineNumberFromDataset(expandIconDom, expandThreshold.value); expandLoader?.value?.([leftLineStart, leftLineEnd, rightLineStart, rightLineEnd], (code: string) => { outputFormat.value === 'line-by-line' - ? insertIncrementCode(code, direction, expandIconDom.parentElement?.parentElement) - : insertIncrementCodeForDoubleColumn(code, direction, expandIconDom.parentElement?.parentElement); + ? insertIncrementCode(code, direction, expandIconDom.parentElement?.parentElement, options) + : insertIncrementCodeForDoubleColumn(code, direction, expandIconDom.parentElement?.parentElement, options); }); } }; diff --git a/packages/devui-vue/devui/code-review/src/composables/use-code-review-line-selection.ts b/packages/devui-vue/devui/code-review/src/composables/use-code-review-line-selection.ts new file mode 100644 index 0000000000..ad2e3ea70a --- /dev/null +++ b/packages/devui-vue/devui/code-review/src/composables/use-code-review-line-selection.ts @@ -0,0 +1,251 @@ +import { watch } from 'vue'; +import type { Ref } from 'vue'; +import type { CodeReviewProps, CommentPosition, ICheckedLineDetails, IExpandLineNumberInfo, ILineNumberTdMap } from '../code-review-types'; +import { useNamespace } from '../../../shared/hooks/use-namespace'; +import { + findParentTrNode, + clearCommentChecked, + parseCodeToSingle, + getLineNumberMap, + getLineNumberTdMap, + getDoubleCheckedNumberAndCodes, + getSingleCheckedNumberAndCode, + addCommentCheckedForDouble, + addCommentCheckedForSingle, +} from '../utils'; + +export function useCodeReviewLineSelection( + reviewContentRef: Ref, + props: CodeReviewProps, + afterMouseup: (details: ICheckedLineDetails) => void +) { + const ns = useNamespace('code-review'); + let dragging = false; + let startTrNode: HTMLElement; + let trNodes: HTMLElement[]; + let allTdNodes: HTMLElement[] = []; + let shouldClear: boolean; + let isMouseMoved: boolean; + let leftRightLineNumberArr: CommentPosition[] = []; + let leftNumberTdMap: ILineNumberTdMap = {}; + let rightNumberTdMap: ILineNumberTdMap = {}; + let checkedTdNodes: HTMLElement[] = []; + let startPosition: 'left' | 'right'; + let leftMinNum: number; + let leftMaxNum: number; + let rightMinNum: number; + let rightMaxNum: number; + + const onMousedown = (e: MouseEvent) => { + // 鼠标左键按下 + if (e.button === 0) { + const composedPath = e.composedPath() as HTMLElement[]; + const lineNumberBox = composedPath.find( + (item) => item.classList?.contains('comment-icon-hover') || item.classList?.contains('comment-icon') + ); + trNodes = Array.from(reviewContentRef.value.querySelectorAll('tr')).filter((item) => !item.classList?.contains('expand-line')); + // 根据事件返回的dom判断是否点击的行号 + if (!lineNumberBox) { + return; + } + const parentTrNode = findParentTrNode(e.target as HTMLElement); + // 判断点击的是否是展开图标 + if (parentTrNode && parentTrNode?.classList?.contains('expand-line')) { + return; + } + startTrNode = parentTrNode as HTMLElement; + allTdNodes = []; + for (let i = 0; i < trNodes.length; i++) { + allTdNodes.push(...trNodes[i].children); + } + if (props.outputFormat === 'side-by-side') { + const { left, right } = getLineNumberTdMap(trNodes); + leftNumberTdMap = left; + rightNumberTdMap = right; + startPosition = composedPath.some((item) => item.classList?.contains('d-code-left')) ? 'left' : 'right'; + } + + dragging = true; + shouldClear = true; + isMouseMoved = false; + e.preventDefault(); + e.stopPropagation(); + document.addEventListener('mousemove', onMousemove); + document.addEventListener('mouseup', onMouseup); + } + }; + + function onMousemove(e: MouseEvent) { + if (!dragging) { + return; + } + if (shouldClear) { + clearCommentChecked(checkedTdNodes); + shouldClear = false; + } + const composedPath = e.composedPath() as HTMLElement[]; + const inReviewContent = composedPath.some((item) => item.classList?.contains(ns.e('content'))); + if (!inReviewContent) { + return; + } + const endTrNode = findParentTrNode(e.target as HTMLElement); + let endPosition: 'left' | 'right'; + if (props.outputFormat === 'side-by-side') { + if (composedPath.some((item) => item.classList?.contains('d-code-left'))) { + endPosition = 'left'; + } + if (composedPath.some((item) => item.classList?.contains('d-code-right'))) { + endPosition = 'right'; + } + } + if (!endTrNode) { + return; + } + isMouseMoved = true; + const endTrChildren = endTrNode.children; + if ( + (endPosition === 'left' && isNaN(parseInt(endTrChildren[0]?.innerText))) || + (endPosition === 'right' && isNaN(parseInt(endTrChildren[2]?.innerText))) + ) { + return; + } + + checkedTdNodes = []; + + if (props.outputFormat === 'line-by-line') { + let startIndex = trNodes.indexOf(startTrNode); + let endIndex = trNodes.indexOf(endTrNode); + if (endIndex === -1) { + return; + } + if (startIndex > endIndex) { + [startIndex, endIndex] = [endIndex, startIndex]; + } + for (let i = 0; i < trNodes.length; i++) { + const tdNodes = Array.from(trNodes[i].children) as HTMLElement[]; + if (i >= startIndex && i <= endIndex) { + checkedTdNodes.push(...tdNodes); + } + } + } + + if (props.outputFormat === 'side-by-side') { + const startNum = parseInt((startTrNode.children[startPosition === 'left' ? 0 : 2] as HTMLElement).innerText); + let sIndex = leftRightLineNumberArr.findIndex((item) => item[startPosition] === startNum); + const endNum = parseInt((endTrNode.children[endPosition === 'left' ? 0 : 2] as HTMLElement).innerText); + let eIndex = leftRightLineNumberArr.findIndex((item) => item[endPosition] === endNum); + if (sIndex > eIndex) { + [sIndex, eIndex] = [eIndex, sIndex]; + } + const tempArr = leftRightLineNumberArr.slice(sIndex, eIndex + 1); + for (let i = 0; i < tempArr.length; i++) { + const { left, right } = tempArr[i]; + if (left !== -1) { + checkedTdNodes.push(...leftNumberTdMap[left]); + } + if (right !== -1) { + checkedTdNodes.push(...rightNumberTdMap[right]); + } + } + } + + /* 更新节点选中状态 */ + for (let i = 0; i < allTdNodes.length; i++) { + if (checkedTdNodes.includes(allTdNodes[i])) { + allTdNodes[i].classList.add('comment-checked'); + } else { + allTdNodes[i].classList.remove('comment-checked'); + } + } + } + + function onMouseup() { + dragging = false; + if (isMouseMoved) { + let details: ICheckedLineDetails; + if (props.outputFormat === 'side-by-side') { + details = getDoubleCheckedNumberAndCodes(checkedTdNodes); + } else { + details = getSingleCheckedNumberAndCode(checkedTdNodes); + } + leftMinNum = details.lefts[0]; + leftMaxNum = details.lefts[details.lefts.length - 1]; + rightMinNum = details.rights[0]; + rightMaxNum = details.rights[details.rights.length - 1]; + afterMouseup(details); + } + + document.removeEventListener('mouseup', onMouseup); + document.removeEventListener('mousemove', onMousemove); + } + + /* 点击评论时,获取选中行的数据 */ + const getCheckedLineDetails = () => { + if (checkedTdNodes.length) { + return props.outputFormat === 'side-by-side' + ? getDoubleCheckedNumberAndCodes(checkedTdNodes) + : getSingleCheckedNumberAndCode(checkedTdNodes); + } + }; + + /* 清除选中行 */ + const clearCommentClass = () => { + clearCommentChecked(checkedTdNodes); + checkedTdNodes = []; + }; + + /* 点击展开行后,更新左右行号映射关系 */ + const updateLineNumberMap = (expandLineNumberInfo: IExpandLineNumberInfo, newCode: string, direction: 'down' | 'up') => { + const container = document.createElement('div'); + parseCodeToSingle(container, newCode, props.options); + const { prevL, prevR, nextL, nextR } = expandLineNumberInfo; + const arr = getLineNumberMap(Array.from(container.querySelectorAll('tr'))); + if (direction === 'down') { + const preLeft = Number(prevL) - 1; + const preRight = Number(prevR) - 1; + const index = leftRightLineNumberArr.findIndex((item) => item.left === preLeft && item.right === preRight); + leftRightLineNumberArr.splice(index + 1, 0, ...arr); + } else { + const nextLeft = Number(nextL) + 1; + const nextRight = Number(nextR) + 1; + const index = leftRightLineNumberArr.findIndex((item) => item.left === nextLeft && item.right === nextRight); + leftRightLineNumberArr.splice(index, 0, ...arr); + } + }; + + /* 点击展开行后,更新选中行的数据 */ + const updateCheckedLine = (expandLineNumberInfo: IExpandLineNumberInfo, direction: 'down' | 'up') => { + const allTrNodes = Array.from(reviewContentRef.value.querySelectorAll('tr')).filter((item) => !item.classList?.contains('expand-line')); + const { prevL, nextL } = expandLineNumberInfo; + const num = direction === 'down' ? Number(prevL) : Number(nextL); + + if (!checkedTdNodes.length || num < leftMinNum || num > leftMaxNum) { + return; + } + + checkedTdNodes = []; + + for (let i = 0; i < allTrNodes.length; i++) { + const itemTrNode = allTrNodes[i]; + if (props.outputFormat === 'side-by-side') { + checkedTdNodes.push(...addCommentCheckedForDouble(itemTrNode, leftMinNum, leftMaxNum, rightMinNum, rightMaxNum)); + } else { + checkedTdNodes.push(...addCommentCheckedForSingle(itemTrNode, leftMinNum, leftMaxNum, rightMinNum, rightMaxNum)); + } + } + }; + + watch( + [() => props.outputFormat, () => props.allowChecked], + () => { + if (props.allowChecked && props.outputFormat === 'side-by-side') { + const container = document.createElement('div'); + parseCodeToSingle(container, props.diff, props.options); + leftRightLineNumberArr = getLineNumberMap(Array.from(container.querySelectorAll('tr'))); + } + }, + { immediate: true } + ); + + return { onMousedown, updateLineNumberMap, getCheckedLineDetails, clearCommentClass, updateCheckedLine }; +} \ No newline at end of file diff --git a/packages/devui-vue/devui/code-review/src/composables/use-code-review.ts b/packages/devui-vue/devui/code-review/src/composables/use-code-review.ts index 02d5e585c1..7d60ab1717 100644 --- a/packages/devui-vue/devui/code-review/src/composables/use-code-review.ts +++ b/packages/devui-vue/devui/code-review/src/composables/use-code-review.ts @@ -1,24 +1,32 @@ -import { toRefs, ref, watch, nextTick } from 'vue'; +import { toRefs, ref, watch, nextTick, onUnmounted } from 'vue'; import type { SetupContext, Ref } from 'vue'; import type { DiffFile } from 'diff2html/lib/types'; import * as Diff2Html from 'diff2html'; +import { useNamespace } from '../../../shared/hooks/use-namespace'; import { inBrowser } from '../../../shared/utils/common-var'; -import type { CodeReviewProps } from '../code-review-types'; +import type { CodeReviewProps, IExpandLineNumberInfo } from '../code-review-types'; import { useCodeReviewExpand } from './use-code-review-expand'; -import { parseDiffCode } from '../utils'; +import { getSelectionParent, parseDiffCode } from '../utils'; -export function useCodeReview(props: CodeReviewProps, ctx: SetupContext) { +export function useCodeReview( + props: CodeReviewProps, + ctx: SetupContext, + reviewContentRef: Ref, + updateLineNumberMap: (expandLineNumberInfo: IExpandLineNumberInfo, newCode: string, direction: 'up' | 'down') => void, + updateCheckedLine: (expandLineNumberInfo: IExpandLineNumberInfo, direction: 'up' | 'down') => void +) { const { diff, outputFormat, allowExpand, showBlob } = toRefs(props); const renderHtml = ref(''); - const reviewContentRef = ref(); const diffFile: Ref = ref([]); - const { insertExpandButton, onExpandButtonClick } = useCodeReviewExpand(reviewContentRef, props); + const ns = useNamespace('code-review'); + const selectionSide = ref(''); + const { insertExpandButton, onExpandButtonClick } = useCodeReviewExpand(reviewContentRef, props, updateLineNumberMap, updateCheckedLine); const initDiffContent = () => { diffFile.value = Diff2Html.parse(diff.value); nextTick(() => { if (inBrowser && !showBlob.value) { - parseDiffCode(reviewContentRef.value, diff.value, outputFormat.value); + parseDiffCode(reviewContentRef.value, diff.value, outputFormat.value, props.options); allowExpand.value && insertExpandButton(); ctx.emit('contentRefresh', JSON.parse(JSON.stringify(diffFile.value))); } @@ -26,14 +34,76 @@ export function useCodeReview(props: CodeReviewProps, ctx: SetupContext) { }; const onContentClick = (e: Event) => { - onExpandButtonClick(e); + onExpandButtonClick(e, props.options); }; + function onSelectionChange() { + if (selectionSide.value) { + return; + } + if (typeof window === 'undefined') { + return; + } + const selection = window.getSelection(); + if (selection?.toString() && selection?.anchorNode) { + const side = getSelectionParent(selection.anchorNode as HTMLElement); + if (side) { + selectionSide.value = side; + } + } + } + function onMousedown(e: Event) { + if (typeof window === 'undefined') { + return; + } + const selection = window.getSelection(); + const composedPath = e.composedPath(); + const isLineNumber = composedPath.some((item: HTMLElement) => item.classList?.contains('d2h-code-side-linenumber')); + const isClickInner = composedPath.some((item: HTMLElement) => item.classList?.contains(ns.e('content'))); + const clickSide = getSelectionParent(e.target as HTMLElement); + if (selection && selection.toString()) { + const isInRange = selection?.getRangeAt(0).intersectsNode(e.target); + if ( + !isInRange || + !isClickInner || + (clickSide === 'left' && selectionSide.value === 'right') || + (clickSide === 'right' && selectionSide.value === 'left') || + isLineNumber + ) { + setTimeout(() => { + selectionSide.value = ''; + }); + selection.removeRange(selection.getRangeAt(0)); + } + } else { + selectionSide.value = ''; + } + } + watch(showBlob, initDiffContent); watch(outputFormat, initDiffContent); watch(diff, initDiffContent, { immediate: true }); - return { renderHtml, reviewContentRef, diffFile, onContentClick }; + watch( + () => props.outputFormat, + (val) => { + if (val === 'side-by-side') { + document.addEventListener('selectionchange', onSelectionChange); + document.addEventListener('mousedown', onMousedown, true); + } else { + document.removeEventListener('selectionchange', onSelectionChange); + document.removeEventListener('mousedown', onMousedown, true); + } + }, + { immediate: true } + ); + + onUnmounted(() => { + document.removeEventListener('selectionchange', onSelectionChange); + document.removeEventListener('mousedown', onMousedown, true); + }); + + return { renderHtml, diffFile, selectionSide, onContentClick }; } diff --git a/packages/devui-vue/devui/code-review/src/utils.ts b/packages/devui-vue/devui/code-review/src/utils.ts index 8f5a58a3e1..fa838bee4e 100644 --- a/packages/devui-vue/devui/code-review/src/utils.ts +++ b/packages/devui-vue/devui/code-review/src/utils.ts @@ -1,5 +1,12 @@ import { Diff2HtmlUI } from 'diff2html/lib/ui/js/diff2html-ui'; -import type { OutputFormat, ExpandDirection, LineSide, IncrementCodeInsertDirection } from './code-review-types'; +import type { + OutputFormat, + ExpandDirection, + LineSide, + IncrementCodeInsertDirection, + CommentPosition, + ILineNumberTdMap, +} from './code-review-types'; import { UpExpandIcon, DownExpandIcon, AllExpandIcon } from './components/code-review-icons'; import { ExpandLineReg, TemplateMap, TableTrReg, TableTdReg, TableTbodyReg, TableTbodyAttrReg, EmptyDataLangReg } from './const'; @@ -159,14 +166,20 @@ function addClassToDiffCode(codeStrArr: RegExpMatchArray | null, theClassName: s } // 解析diff -export function parseDiffCode(container: HTMLElement, code: string, outputFormat: OutputFormat, isAddCode = false) { +export function parseDiffCode( + container: HTMLElement, + code: string, + outputFormat: OutputFormat, + options: Record, + isAddCode = false +) { const diff2HtmlUi = new Diff2HtmlUI(container, code, { drawFileList: false, matching: 'lines', outputFormat: outputFormat, highlight: true, - diffStyle: 'char', rawTemplates: TemplateMap[outputFormat], + ...options, }); if (outputFormat === 'side-by-side') { let diffHtmlStr = diff2HtmlUi.diffHtml; @@ -511,3 +524,218 @@ export function findReferenceDomForDoubleColumn(parentNode: HTMLElement, lineNum } } } + +/* 多行选中,根据当前dom节点,向上寻找tr节点 */ +export function findParentTrNode(node: HTMLElement | null) { + if (!node) { + return null; + } + if (node.tagName === 'TR') { + return node; + } + return findParentTrNode(node.parentElement); +} + +/* 根据最大最小行号,获取从小到大的完整行号列表 */ +function getFullNumberList(min: number, max: number) { + return Array.from({ length: max - min + 1 }, (_, i) => i + min); +} + +/* 拖拽开始时,清除上次的选中行 */ +export function clearCommentChecked(checkedTdNodes: HTMLElement[]) { + for (let i = 0; i < checkedTdNodes.length; i++) { + checkedTdNodes[i].classList.remove('comment-checked'); + } +} + +/* 渲染为单栏模式,用于后续获取左右行号映射 */ +export function parseCodeToSingle(container: HTMLElement, code: string, options: Record) { + const diff2HtmlUi = new Diff2HtmlUI(container, code, { + drawFileList: false, + outputFormat: 'line-by-line', + highlight: true, + rawTemplates: TemplateMap['line-by-line'], + ...options, + }); + diff2HtmlUi.draw(); +} + +function generateNumberTdObj(tdNodes: HTMLElement[]) { + const lineNumber = tdNodes[0]?.innerText ? parseInt(tdNodes[0].innerText) : -1; + if (lineNumber !== -1) { + return { [lineNumber]: tdNodes }; + } +} + +/* 获取行号和对应td的映射关系 */ +export function getLineNumberTdMap(trNodes: HTMLElement[]) { + const left: ILineNumberTdMap = {}; + const right: ILineNumberTdMap = {}; + for (let i = 0; i < trNodes.length; i++) { + const tdNodes = Array.from(trNodes[i].children) as HTMLElement[]; + Object.assign(left, generateNumberTdObj(tdNodes.slice(0, 2))); + Object.assign(right, generateNumberTdObj(tdNodes.slice(2))); + } + + return { left, right }; +} + +/* 获取左右行号映射关系 */ +export function getLineNumberMap(trNodes: HTMLElement[]) { + const result: CommentPosition[] = []; + + for (let i = 0; i < trNodes.length; i++) { + const lineNumberNodes = trNodes[i].children[0].children; // 行号所在的div + if (lineNumberNodes.length === 2) { + const left = parseInt((lineNumberNodes[0] as HTMLElement)?.innerText) || -1; + const right = parseInt((lineNumberNodes[1] as HTMLElement)?.innerText) || -1; + result.push({ left, right }); + } + } + + return result; +} + +/* 获取双栏模式下,选中行的左右行号和代码 */ +export function getDoubleCheckedNumberAndCodes(checkedTdNodes: HTMLElement[]) { + const lefts: number[] = []; + const rights: number[] = []; + const leftCode: string[] = []; + const rightCode: string[] = []; + const leftNumberNodes: HTMLElement[] = []; + const rightNumberNodes: HTMLElement[] = []; + + for (let i = 0; i < checkedTdNodes.length; i++) { + const itemTdNode = checkedTdNodes[i]; + if (itemTdNode.classList.contains('d-code-left')) { + if (itemTdNode.classList.contains('d2h-code-side-linenumber')) { + leftNumberNodes.push(itemTdNode); + } else { + leftCode.push(itemTdNode.innerText); + } + } else { + if (itemTdNode.classList.contains('d2h-code-side-linenumber')) { + rightNumberNodes.push(itemTdNode); + } else { + rightCode.push(itemTdNode.innerText); + } + } + } + + if (leftNumberNodes.length) { + const leftMinNum = parseInt(leftNumberNodes[0].innerText); + const leftMaxNum = parseInt(leftNumberNodes[leftNumberNodes.length - 1].innerText); + lefts.push(...getFullNumberList(leftMinNum, leftMaxNum)); + } + if (rightNumberNodes.length) { + const rightMinNum = parseInt(rightNumberNodes[0].innerText); + const rightMaxNum = parseInt(rightNumberNodes[rightNumberNodes.length - 1].innerText); + rights.push(...getFullNumberList(rightMinNum, rightMaxNum)); + } + + return { lefts, rights, codes: { leftCode, rightCode } }; +} + +/* 获取单栏模式下,选中行的左右行号和代码 */ +export function getSingleCheckedNumberAndCode(checkedTdNodes: HTMLElement[]) { + const lefts: number[] = []; + const rights: number[] = []; + const codes: string[] = []; + const leftNumbers: number[] = []; + const rightNumbers: number[] = []; + + for (let i = 0; i < checkedTdNodes.length; i++) { + const itemTdNode = checkedTdNodes[i]; + if (itemTdNode.classList.contains('d2h-code-linenumber')) { + const numberChildren = itemTdNode.children as unknown as HTMLElement[]; + const leftNum = parseInt(numberChildren[0].innerText); + const rightNum = parseInt(numberChildren[1].innerText); + !isNaN(leftNum) && leftNumbers.push(leftNum); + !isNaN(rightNum) && rightNumbers.push(rightNum); + } else { + codes.push(itemTdNode.innerText); + } + } + + lefts.push(...getFullNumberList(leftNumbers[0], leftNumbers[leftNumbers.length - 1])); + rights.push(...getFullNumberList(rightNumbers[0], rightNumbers[rightNumbers.length - 1])); + + return { lefts, rights, codes }; +} + +/* 双栏模式,点击展开行后,为新增的行设置选中样式 */ +export function addCommentCheckedForDouble( + trNode: HTMLElement, + leftMinNum: number, + leftMaxNum: number, + rightMinNum: number, + rightMaxNum: number +) { + const [leftNumTd, leftCodeTd, rightNumTd, rightCodeTd] = trNode.children as unknown as HTMLElement[]; + const leftNum = parseInt(leftNumTd.innerText); + const rightNum = parseInt(rightNumTd.innerText); + const result: HTMLElement[] = []; + + if (!isNaN(leftNum) && leftNum >= leftMinNum && leftNum <= leftMaxNum) { + if (!leftNumTd.classList.contains('comment-checked')) { + leftNumTd.classList.add('comment-checked'); + leftCodeTd.classList.add('comment-checked'); + } + result.push(leftNumTd, leftCodeTd); + } + if (!isNaN(rightNum) && rightNum >= rightMinNum && rightNum <= rightMaxNum) { + if (!rightNumTd.classList.contains('comment-checked')) { + rightNumTd.classList.add('comment-checked'); + rightCodeTd.classList.add('comment-checked'); + } + result.push(rightNumTd, rightCodeTd); + } + + return result; +} + +/* 单栏模式,点击展开行后,为新增的行设置选中样式 */ +export function addCommentCheckedForSingle( + trNode: HTMLElement, + leftMinNum: number, + leftMaxNum: number, + rightMinNum: number, + rightMaxNum: number +) { + const [numTd, codeTd] = trNode.children as unknown as HTMLElement[]; + const [leftNumNode, rightNumNode] = numTd.children as unknown as HTMLElement[]; + const leftNum = parseInt(leftNumNode.innerText); + const rightNum = parseInt(rightNumNode.innerText); + const result: HTMLElement[] = []; + + if ( + (!isNaN(leftNum) && leftNum >= leftMinNum && leftNum <= leftMaxNum) || + (!isNaN(rightNum) && rightNum >= rightMinNum && rightNum <= rightMaxNum) + ) { + if (!numTd.classList.contains('comment-checked')) { + numTd.classList.add('comment-checked'); + codeTd.classList.add('comment-checked'); + } + result.push(numTd, codeTd); + } + + return result; +} + +/* 双栏模式,选中文本时,根据选择的节点查找其父节点,用于判断左侧选中还是右侧选中 */ +export function getSelectionParent(el: HTMLElement) { + if (el.tagName === 'TR') { + return; + } + if (el.tagName === 'TD' && (el.classList.contains('d-code-left') || el.classList.contains('d-code-right'))) { + if (el.classList.contains('d-code-left')) { + return 'left'; + } + if (el.classList.contains('d-code-right')) { + return 'right'; + } + } + if (el.parentElement) { + return getSelectionParent(el.parentElement); + } +} diff --git a/packages/devui-vue/devui/editor-md/src/composables/md-render-service.ts b/packages/devui-vue/devui/editor-md/src/composables/md-render-service.ts index e3e3a688da..cf1dedbe11 100644 --- a/packages/devui-vue/devui/editor-md/src/composables/md-render-service.ts +++ b/packages/devui-vue/devui/editor-md/src/composables/md-render-service.ts @@ -102,7 +102,14 @@ export class MDRenderService { } private onIgnoreTagAttr(tag: string, name: string, value: string, isWhiteAttr: boolean) { - if (!isWhiteAttr && (name === 'id' || (tag === 'span' && name === 'style'))) { + if (!isWhiteAttr && (name === 'id' || (tag === 'span' && name === 'style') + || (tag === 'a' && name === 'href'))) { + return name + '=' + value; + } + } + + private onTagAttr(tag: string, name: string, value: string, isWhiteAttr: boolean) { + if (isWhiteAttr && (tag === 'a' && name === 'href')) { return name + '=' + value; } } @@ -139,6 +146,7 @@ export class MDRenderService { html = filterXSS(html, { whiteList: this.xssWhiteList, onIgnoreTagAttr: this.onIgnoreTagAttr, + onTagAttr: this.onTagAttr, css: { whiteList: Object.assign({}, this.cssWhiteList, { top: true, diff --git a/packages/devui-vue/devui/editor-md/src/composables/use-editor-md.ts b/packages/devui-vue/devui/editor-md/src/composables/use-editor-md.ts index 65d0c0a00e..d3ae10bb75 100644 --- a/packages/devui-vue/devui/editor-md/src/composables/use-editor-md.ts +++ b/packages/devui-vue/devui/editor-md/src/composables/use-editor-md.ts @@ -2,7 +2,7 @@ import cloneDeep from 'lodash/cloneDeep'; import { computed, nextTick, onMounted, reactive, Ref, ref, SetupContext, toRefs, watch, onBeforeUnmount } from 'vue'; import { debounce } from '../../../shared/utils'; import { EditorMdProps, Mode } from '../editor-md-types'; -import { DEFAULT_TOOLBARS } from '../toolbar-config'; +import { DEFAULT_TOOLBARS, GET_ALT_KEY } from '../toolbar-config'; import { parseHTMLStringToDomList } from '../utils'; import { refreshEditorCursor, _enforceMaxLength } from './helper'; import { throttle } from 'lodash'; @@ -289,8 +289,8 @@ export function useEditorMd(props: EditorMdProps, ctx: SetupContext) { const tempToolbars = { ...toolbars, ...customToolbars?.value }; for (const key of Object.keys(tempToolbars)) { const toolbarItem = tempToolbars[key]; - if (toolbarItem.shortKey && flatToolbarConfig.includes(toolbarItem.id)) { - shortKeys[toolbarItem.shortKey.replace(/\+/g, '-')] = toolbarItem.handler?.bind(null, editorIns, toolbarItem.params); + if (toolbarItem.shortKeyWithCode && flatToolbarConfig.includes(toolbarItem.id)) { + shortKeys[toolbarItem.shortKeyWithCode.replace(/\+/g, '-')] = toolbarItem.handler?.bind(null, editorIns, toolbarItem.params); } } @@ -316,6 +316,28 @@ export function useEditorMd(props: EditorMdProps, ctx: SetupContext) { setTimeout(() => { ctx.emit('contentChange', editorIns.getValue()); }, 100); + + containerRef.value.addEventListener('keydown', (e: KeyboardEvent) => { + let keyCombination = ''; + if (e.ctrlKey) { + keyCombination += 'Ctrl-'; + } + if (e.metaKey) { + keyCombination += '⌘-'; + } + if (e.altKey) { + keyCombination += `${GET_ALT_KEY()}-`; + } + if (e.shiftKey) { + keyCombination += 'Shift-'; + } + + keyCombination += e.keyCode; + if (shortKeys[keyCombination] && typeof shortKeys[keyCombination] === 'function') { + e.preventDefault(); + shortKeys[keyCombination](); + } + }); }; const onPaste = (e: ClipboardEvent) => { diff --git a/packages/devui-vue/devui/editor-md/src/toolbar-config.ts b/packages/devui-vue/devui/editor-md/src/toolbar-config.ts index d3711660ed..6852ef22c9 100644 --- a/packages/devui-vue/devui/editor-md/src/toolbar-config.ts +++ b/packages/devui-vue/devui/editor-md/src/toolbar-config.ts @@ -31,6 +31,7 @@ export interface IToolbarItemConfig { template?: any; component?: any; shortKey?: string; + shortKeyWithCode?: string; params?: { [key: string]: any }; handler?(editor?: any, params?: any): void; } @@ -257,7 +258,11 @@ class ToolBarHandler { static code = (editor: any): void => { const cursor = editor.getCursor(); const selection = editor.getSelection(); - editor.replaceSelection('`' + selection + '`'); + if (selection.indexOf('\n') === -1) { + editor.replaceSelection('`' + selection + '`'); + } else { + editor.replaceSelection('```\n' + selection + '\n```'); + } editor.focus(); if (selection === '') { @@ -277,13 +282,25 @@ class ToolBarHandler { static color = (): void => {}; } +export const GET_CTRL_KEY = () => { + if (typeof window !== 'undefined') { + return navigator?.platform?.indexOf('Mac') !== -1 ? '⌘' : 'Ctrl'; + } +} +export const GET_ALT_KEY = () => { + if (typeof window !== 'undefined') { + return navigator?.platform?.indexOf('Mac') !== -1 ? '⌥' : 'Alt'; + } +} + export const DEFAULT_TOOLBARS: Record = { undo: { id: 'undo', name: 'undo', type: 'button', icon: UNDO_ICON, - shortKey: 'Ctrl+Z', + shortKey: `${GET_CTRL_KEY()}+Z`, + shortKeyWithCode: `${GET_CTRL_KEY()}+90`, handler: ToolBarHandler.undo, }, redo: { @@ -291,7 +308,8 @@ export const DEFAULT_TOOLBARS: Record = { name: 'redo', type: 'button', icon: REDO_ICON, - shortKey: 'Ctrl+Y', + shortKey: `${GET_CTRL_KEY()}+Y`, + shortKeyWithCode: `${GET_CTRL_KEY()}+89`, handler: ToolBarHandler.redo, }, bold: { @@ -299,7 +317,8 @@ export const DEFAULT_TOOLBARS: Record = { name: 'bold', type: 'button', icon: BOLD_ICON, - shortKey: 'Ctrl+B', + shortKey: `${GET_CTRL_KEY()}+B`, + shortKeyWithCode: `${GET_CTRL_KEY()}+66`, handler: ToolBarHandler.bold, }, italic: { @@ -307,7 +326,8 @@ export const DEFAULT_TOOLBARS: Record = { name: 'italic', type: 'button', icon: ITALIC_ICON, - shortKey: 'Ctrl+I', + shortKey: `${GET_CTRL_KEY()}+I`, + shortKeyWithCode: `${GET_CTRL_KEY()}+73`, handler: ToolBarHandler.italic, }, strike: { @@ -315,7 +335,8 @@ export const DEFAULT_TOOLBARS: Record = { name: 'strike', type: 'button', icon: STRIKE_ICON, - shortKey: 'Ctrl+D', + shortKey: `${GET_CTRL_KEY()}+D`, + shortKeyWithCode: `${GET_CTRL_KEY()}+68`, handler: ToolBarHandler.strike, }, h1: { @@ -323,7 +344,8 @@ export const DEFAULT_TOOLBARS: Record = { name: 'h1', type: 'button', icon: H1_ICON, - shortKey: 'Ctrl+1', + shortKey: `${GET_CTRL_KEY()}+1`, + shortKeyWithCode: `${GET_CTRL_KEY()}+49`, handler: ToolBarHandler.h1, }, h2: { @@ -331,7 +353,8 @@ export const DEFAULT_TOOLBARS: Record = { name: 'h2', type: 'button', icon: H2_ICON, - shortKey: 'Ctrl+2', + shortKey: `${GET_CTRL_KEY()}+2`, + shortKeyWithCode: `${GET_CTRL_KEY()}+50`, handler: ToolBarHandler.h2, }, ul: { @@ -339,7 +362,8 @@ export const DEFAULT_TOOLBARS: Record = { name: 'unorderedlist', type: 'button', icon: LIST_UNORDERED_ICON, - shortKey: 'Ctrl+U', + shortKey: `${GET_CTRL_KEY()}+U`, + shortKeyWithCode: `${GET_CTRL_KEY()}+85`, handler: ToolBarHandler.ul, }, ol: { @@ -347,7 +371,8 @@ export const DEFAULT_TOOLBARS: Record = { name: 'orderedlist', type: 'button', icon: LIST_ORDERED_ICON, - shortKey: 'Ctrl+O', + shortKey: `${GET_CTRL_KEY()}+O`, + shortKeyWithCode: `${GET_CTRL_KEY()}+79`, handler: ToolBarHandler.ol, }, checklist: { @@ -355,7 +380,8 @@ export const DEFAULT_TOOLBARS: Record = { name: 'checklist', type: 'button', icon: LIST_CHECK_ICON, - shortKey: 'Ctrl+Alt+C', + shortKey: `${GET_CTRL_KEY()}+${GET_ALT_KEY()}+C`, + shortKeyWithCode: `${GET_CTRL_KEY()}+${GET_ALT_KEY()}+67`, handler: ToolBarHandler.checkList, }, underline: { @@ -363,7 +389,8 @@ export const DEFAULT_TOOLBARS: Record = { name: 'underline', type: 'button', icon: UNDERLINE_ICON, - shortKey: 'Ctrl+R', + shortKey: `${GET_CTRL_KEY()}+R`, + shortKeyWithCode: `${GET_CTRL_KEY()}+82`, handler: ToolBarHandler.underline, }, font: { @@ -379,7 +406,8 @@ export const DEFAULT_TOOLBARS: Record = { name: 'link', type: 'button', icon: LINK_ICON, - shortKey: 'Ctrl+L', + shortKey: `${GET_CTRL_KEY()}+L`, + shortKeyWithCode: `${GET_CTRL_KEY()}+76`, handler: ToolBarHandler.link, }, image: { @@ -387,7 +415,8 @@ export const DEFAULT_TOOLBARS: Record = { name: 'image', type: 'button', icon: IMAGE_ICON, - shortKey: 'Ctrl+G', + shortKey: `${GET_CTRL_KEY()}+G`, + shortKeyWithCode: `${GET_CTRL_KEY()}+71`, params: { imageUploadToServer: false }, handler: ToolBarHandler.image, }, @@ -397,7 +426,8 @@ export const DEFAULT_TOOLBARS: Record = { type: 'button', icon: FILE_ICON, params: {}, - shortKey: 'Ctrl+F', + shortKey: `${GET_CTRL_KEY()}+F`, + shortKeyWithCode: `${GET_CTRL_KEY()}+70`, handler: ToolBarHandler.file, }, code: { @@ -405,7 +435,8 @@ export const DEFAULT_TOOLBARS: Record = { name: 'code', type: 'button', icon: CODE_ICON, - shortKey: 'Ctrl+K', + shortKey: `${GET_CTRL_KEY()}+K`, + shortKeyWithCode: `${GET_CTRL_KEY()}+75`, handler: ToolBarHandler.code, }, table: { @@ -413,7 +444,8 @@ export const DEFAULT_TOOLBARS: Record = { name: 'table', type: 'button', icon: TABLE_ICON, - shortKey: 'Ctrl+Alt+T', + shortKey: `${GET_CTRL_KEY()}+${GET_ALT_KEY()}+T`, + shortKeyWithCode: `${GET_CTRL_KEY()}+${GET_ALT_KEY()}+84`, handler: ToolBarHandler.table, }, fullscreen: { diff --git a/packages/devui-vue/devui/editor-md/src/utils.ts b/packages/devui-vue/devui/editor-md/src/utils.ts index a3ffb3be3f..905eb3107d 100644 --- a/packages/devui-vue/devui/editor-md/src/utils.ts +++ b/packages/devui-vue/devui/editor-md/src/utils.ts @@ -40,7 +40,7 @@ export function locale(key: string): string { file: '文件', table: '表格', link: '超链接', - code: '行内代码', + code: '代码', codeblock: '代码块', blockquote: '引用', superscript: '上标', diff --git a/packages/devui-vue/devui/git-graph/src/git-graph-class.ts b/packages/devui-vue/devui/git-graph/src/git-graph-class.ts index c620811109..05d5da3a71 100644 --- a/packages/devui-vue/devui/git-graph/src/git-graph-class.ts +++ b/packages/devui-vue/devui/git-graph/src/git-graph-class.ts @@ -72,12 +72,12 @@ export class GitGraph { this.graphHeight = (this.element as HTMLElement).getBoundingClientRect().height; this.graphWidth = (this.element as HTMLElement).getBoundingClientRect().width; - // 按提交数据计算画布高度,并留出下方150,右边300空白,保证悬浮框不超出画布 + // 按提交数据计算画布高度,并留出下方150,右边500空白,保证悬浮框不超出画布 const ch = Math.max(this.graphHeight, this.offsetY + this.unitTime * this.mtime + 150); - const cw = Math.max(this.graphWidth, this.offsetX + this.unitSpace * this.mspace + 300); + const cw = Math.max(this.graphWidth, this.offsetX + this.unitSpace * this.mspace + 500); this.svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg'); this.svg.setAttribute('height', ch + ''); - this.svg.setAttribute('width', '100%'); + this.svg.setAttribute('width', cw + ''); this.element?.appendChild(this.svg); this.barHeight = Math.max(this.graphHeight, this.unitTime * this.commits.length + 320); @@ -237,7 +237,7 @@ export class GitGraph { r: 4, fill: '#fff', strokeWidth: 1, - stroke: this.colors[commit.space], + stroke: this.colors[commit.space % 20], style: 'cursor: pointer;' }; this.setNodeAttr(circle, attrs); @@ -265,7 +265,7 @@ export class GitGraph { this.svg.appendChild(img); if (!this.messageBoxWidth) { - this.messageBoxWidth = this.svg.getBoundingClientRect.width - (avatar_box_x + 40); + this.messageBoxWidth = this.svg.getBoundingClientRect().width - (avatar_box_x + 40); } // 画竖线 let route = ['M', avatar_box_x + 15, avatar_box_y - 20, 'L', avatar_box_x + 15, avatar_box_y]; @@ -292,17 +292,30 @@ export class GitGraph { commit.author.name = commit.author.name.substr(0, this.maxNameLength) + '...'; } - const commitText = document.createElementNS('http://www.w3.org/2000/svg', 'text'); + const commitText = document.createElementNS('http://www.w3.org/2000/svg', 'foreignObject'); const commitAttrs = { x: avatar_box_x + 40, - y: y + 4, + y: y - 8, 'text-anchor': 'start', style: 'cursor: pointer;text-anchor: start;', - fill: isdark ? '#e8e8e8' : '#2e2e2e', - 'font-size': 14, + width: this.messageBoxWidth, + height: 20, }; this.setNodeAttr(commitText, commitAttrs); + const textArr = { + style: 'width: 100%; height: 20px; overflow: hidden; white-space: nowrap; text-overflow: ellipsis;', + title: commit.message, + }; + + const text = document.createElement('div'); + this.setNodeAttr(text, textArr); + + text.innerText = commit.message.replace(/\n/g, ' '); + commitText.appendChild(text); + + this.svg.appendChild(commitText); + const tspan = document.createElementNS('http://www.w3.org/2000/svg', 'tspan'); tspan.appendChild(document.createTextNode(commit.message.replace(/\n/g, ' '))); commitText.appendChild(tspan); @@ -339,9 +352,9 @@ export class GitGraph { parentX1 = this.offsetX + this.unitSpace * (this.mspace - parentCommit.space); parentX2 = this.offsetX + this.unitSpace * (this.mspace - parent[1]); if (parentCommit.space <= commit.space) { - color = this.colors[commit.space]; + color = this.colors[commit.space % 20]; } else { - color = this.colors[parentCommit.space]; + color = this.colors[parentCommit.space % 20]; } if (parent[1] === commit.space) { offset = [0, 5]; @@ -438,7 +451,7 @@ export class GitGraph { const rectAttrs = { fill: this.isDark ? '#4C4C4C' : '#fff', - stroke: this.colors[commit.space], + stroke: this.colors[commit.space % 20], 'stroke-width': '1px', d: path.join(' '), transform: `matrix(1,0,0,1,-${textbox.width + 26},0)`, @@ -446,7 +459,7 @@ export class GitGraph { const newAttrs = { transform: `matrix(1,0,0,1,-${textbox.width + 26},0)`, - fill: this.colors[commit.space], + fill: this.colors[commit.space % 20], }; this.setNodeAttr(text, newAttrs); diff --git a/packages/devui-vue/devui/git-graph/src/git-graph.scss b/packages/devui-vue/devui/git-graph/src/git-graph.scss new file mode 100644 index 0000000000..28e11539b2 --- /dev/null +++ b/packages/devui-vue/devui/git-graph/src/git-graph.scss @@ -0,0 +1,3 @@ +.d-graph-wrapper { + overflow-x: auto; +} diff --git a/packages/devui-vue/devui/git-graph/src/git-graph.tsx b/packages/devui-vue/devui/git-graph/src/git-graph.tsx index 95614bb715..ee2f044160 100644 --- a/packages/devui-vue/devui/git-graph/src/git-graph.tsx +++ b/packages/devui-vue/devui/git-graph/src/git-graph.tsx @@ -1,7 +1,7 @@ import { defineComponent, onMounted, ref, SetupContext, nextTick } from "vue"; import { GitGraphProps, gitGraphProps } from "./git-graph-types"; import useGitGraph from "./use-git-graph"; - +import './git-graph.scss'; export default defineComponent({ name: 'DGitGraph', diff --git a/packages/devui-vue/docs/components/code-review/index.md b/packages/devui-vue/docs/components/code-review/index.md index fd69a9682d..7ae1fa29cf 100644 --- a/packages/devui-vue/docs/components/code-review/index.md +++ b/packages/devui-vue/docs/components/code-review/index.md @@ -279,7 +279,6 @@ export default defineComponent({ ::: - ### 多选代码行用法 本示例将展示在开启多选代码行,多选后单击最后一个选中的行,添加评论,并且将选中代码行和代码块放入评论内容中。 @@ -501,7 +500,6 @@ export default defineComponent({ ' @@\n ' + code.slice(0, Math.min(Math.abs(lStart - lEnd - 1), 10)).join(' '); update(content); - codeReviewIns.updateCheckedLineClass(); }; return { diff, outputFormat, isFullscreen, onChange, onAddComment, afterViewInit, onContentRefresh, codeLoader }; @@ -571,6 +569,7 @@ export default defineComponent({ } ``` + ::: ### CodeReview 参数 @@ -580,13 +579,14 @@ export default defineComponent({ | diff | `string` | '' | 必选,diff 内容 | | fold | `boolean` | false | 可选,是否折叠显示 | | allow-comment | `boolean` | true | 可选,是否支持评论 | -| allow-checked | `boolean` | false | 可选,是否支持代码行选中,开启后可以按住 shift 点击鼠标选中多行代码,只能按照从小到大的顺序选择,可以跨行选择,开启后add-comment事件的反回值会发生变化。参数内容详见[CommentPosition](#commentposition) | +| allow-checked | `boolean` | false | 可选,是否支持代码行选中,开启后可以通过拖拽选中多行代码,可以跨行选择,开启后add-comment事件的返回值会发生变化。参数内容详见[CommentPosition](#commentposition) | | show-blob | `boolean` | false | 可选,是否展示缩略内容,一般大文件或二进制文件等需要展示缩略内容时使用 | | output-format | [OutputFormat](#outputformat) | 'line-by-line' | 可选,diff 展示格式,单栏展示或者分栏展示 | | diff-type | [DiffType](#difftype) | 'modify' | 可选,文件 diff 类型 | | allow-expand | `boolean` | true | 可选,是否支持展开非 diff 折叠代码 | | expand-threshold | `number` | 50 | 可选,展开所有代码行的阈值,低于此阈值全部展开,高于此阈值分向上和向下两个操作展开 | | expand-loader | `(interval: Array, update: (code: string) => void) => void` | -- | 可选,展开代码回调函数,interval 为展开边界,获取展开代码后,执行 update 更新视图 | +|options|`object`|{}|可选,传给`diff2html`的配置项| ### CodeReview 事件 @@ -596,6 +596,7 @@ export default defineComponent({ | add-comment | `Function(position: CommentPosition)` | 点击添加评论图标时触发的事件,参数内容详见[CommentPosition](#commentposition) | | after-view-init | `Function(methods: CodeReviewMethods)` | 初始化完成后触发的事件,返回相关操作方法,参数内容详见[CodeReviewMethods](#codereviewmethods) | | content-refresh | `Function(diffFile: DiffFile)` | 内容刷新后触发的事件,返回解析后的相关文件信息,参数内容详见[DiffFile](https://github.com/rtfpessoa/diff2html/blob/master/src/types.ts#L49) | +|after-check-lines|`Function(position: CommentPosition)`|多行选中后触发的事件,参数内容详见[CommentPosition](#commentposition)| ### CodeReview 插槽 @@ -632,6 +633,7 @@ line-by-line 模式,left 表示左侧一栏的行号,right 表示右侧一 interface CommentPosition { left: number; right: number; + position?: 'left'|'right' // 双栏模式,点击的左侧还是右侧 } ``` @@ -665,7 +667,7 @@ interface CodeReviewMethods { // 删除评论的方法,传入行号、left/right removeComment: (lineNumber: number, lineSide: LineSide) => void; - // 更新选中行样式,直接调用一般用于展开时更新选中行样式,像示例中一样使用 - updateCheckedLineClass: (); + // 清除选中行样式 + clearCheckedLines: () => void; } ``` diff --git a/packages/devui-vue/package.json b/packages/devui-vue/package.json index b665bfd698..6c2491da58 100644 --- a/packages/devui-vue/package.json +++ b/packages/devui-vue/package.json @@ -1,6 +1,6 @@ { "name": "vue-devui", - "version": "1.6.22", + "version": "1.6.33", "license": "MIT", "description": "DevUI components based on Vite and Vue3", "keywords": [