Skip to content

Commit 3d6bcc9

Browse files
committed
feat: CodeMirror - add diff minimap
1 parent 312a518 commit 3d6bcc9

File tree

7 files changed

+273
-31
lines changed

7 files changed

+273
-31
lines changed

src/Common/CodeMirror/CodeEditor.tsx

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -226,6 +226,18 @@ const CodeEditor = <DiffView extends boolean = false>({
226226

227227
const modifiedViewExtensions: Extension[] = [...baseExtensions, readOnlyTooltip]
228228

229+
const diffMinimapExtensions: Extension[] = [
230+
basicSetup({
231+
...basicSetupOptions,
232+
lineNumbers: false,
233+
foldGutter: false,
234+
highlightActiveLine: false,
235+
highlightActiveLineGutter: false,
236+
syntaxHighlighting: false,
237+
}),
238+
getLanguageExtension(mode, true),
239+
]
240+
229241
return (
230242
<CodeEditorContext.Provider value={contextValue}>
231243
{children}
@@ -250,6 +262,7 @@ const CodeEditor = <DiffView extends boolean = false>({
250262
originalViewExtensions={originalViewExtensions}
251263
modifiedViewExtensions={modifiedViewExtensions}
252264
extensions={extensions}
265+
diffMinimapExtensions={diffMinimapExtensions}
253266
/>
254267
</CodeEditorContext.Provider>
255268
)

src/Common/CodeMirror/CodeEditorRenderer.tsx

Lines changed: 39 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -16,13 +16,14 @@
1616

1717
import { FocusEventHandler, useEffect, useRef, useState } from 'react'
1818
import CodeMirror, { ReactCodeMirrorRef } from '@uiw/react-codemirror'
19-
import CodeMirrorMerge from 'react-codemirror-merge'
19+
import CodeMirrorMerge, { CodeMirrorMergeRef } from 'react-codemirror-merge'
2020

2121
import { getComponentSpecificThemeClass } from '@Shared/Providers'
2222
import { Progressing } from '@Common/Progressing'
2323

2424
import { CodeEditorRendererProps } from './types'
2525
import { getCodeEditorHeight, getRevertControlButton } from './utils'
26+
import { DiffMinimap } from './Extensions'
2627

2728
export const CodeEditorRenderer = ({
2829
theme,
@@ -45,13 +46,15 @@ export const CodeEditorRenderer = ({
4546
onBlur,
4647
extensions,
4748
autoFocus,
49+
diffMinimapExtensions,
4850
}: CodeEditorRendererProps) => {
4951
// STATES
5052
const [isFocused, setIsFocused] = useState(false)
5153
const [gutterWidth, setGutterWidth] = useState(0)
5254

5355
// REFS
5456
const codeMirrorRef = useRef<ReactCodeMirrorRef>()
57+
const codeMirrorMergeRef = useRef<CodeMirrorMergeRef>()
5558

5659
// CONSTANTS
5760
const componentSpecificThemeClass = getComponentSpecificThemeClass(theme)
@@ -115,29 +118,41 @@ export const CodeEditorRenderer = ({
115118
}
116119

117120
return state.diffMode ? (
118-
<CodeMirrorMerge
119-
theme={codeEditorTheme}
120-
key={codemirrorMergeKey}
121-
className={`w-100 ${componentSpecificThemeClass} ${codeEditorParentClassName} ${readOnly ? 'code-editor__read-only' : ''}`}
122-
gutter
123-
destroyRerender={false}
124-
{...(!readOnly ? { revertControls: 'a-to-b', renderRevertControl: getRevertControlButton } : {})}
125-
>
126-
<CodeMirrorMerge.Original
127-
basicSetup={false}
128-
value={state.lhsCode}
129-
readOnly={readOnly || !isOriginalModifiable}
130-
onChange={handleLhsOnChange}
131-
extensions={originalViewExtensions}
132-
/>
133-
<CodeMirrorMerge.Modified
134-
basicSetup={false}
135-
value={state.code}
136-
readOnly={readOnly}
137-
onChange={handleOnChange}
138-
extensions={modifiedViewExtensions}
139-
/>
140-
</CodeMirrorMerge>
121+
<div className={`flexbox w-100 ${codeEditorParentClassName}`}>
122+
<CodeMirrorMerge
123+
ref={codeMirrorMergeRef}
124+
theme={codeEditorTheme}
125+
key={codemirrorMergeKey}
126+
className={`flex-grow-1 h-100 dc__overflow-hidden ${componentSpecificThemeClass} ${readOnly ? 'code-editor__read-only' : ''}`}
127+
gutter
128+
destroyRerender={false}
129+
{...(!readOnly ? { revertControls: 'a-to-b', renderRevertControl: getRevertControlButton } : {})}
130+
>
131+
<CodeMirrorMerge.Original
132+
basicSetup={false}
133+
value={state.lhsCode}
134+
readOnly={readOnly || !isOriginalModifiable}
135+
onChange={handleLhsOnChange}
136+
extensions={originalViewExtensions}
137+
/>
138+
<CodeMirrorMerge.Modified
139+
basicSetup={false}
140+
value={state.code}
141+
readOnly={readOnly}
142+
onChange={handleOnChange}
143+
extensions={modifiedViewExtensions}
144+
/>
145+
</CodeMirrorMerge>
146+
{codeMirrorMergeRef.current && (
147+
<DiffMinimap
148+
theme={theme}
149+
codeEditorTheme={codeEditorTheme}
150+
view={codeMirrorMergeRef.current.view}
151+
state={state}
152+
diffMinimapExtensions={diffMinimapExtensions}
153+
/>
154+
)}
155+
</div>
141156
) : (
142157
<div ref={codeMirrorParentDivRef} className={`w-100 ${codeEditorParentClassName}`}>
143158
{shebang && (
Lines changed: 178 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,178 @@
1+
import { useEffect, useMemo, useRef, useState } from 'react'
2+
import CodeMirrorMerge from 'react-codemirror-merge'
3+
4+
import { getComponentSpecificThemeClass } from '@Shared/Providers'
5+
6+
import { DiffMinimapProps } from '../types'
7+
8+
export const DiffMinimap = ({ view, state, diffMinimapExtensions, codeEditorTheme, theme }: DiffMinimapProps) => {
9+
const minimapContainerRef = useRef<HTMLDivElement>(null)
10+
const overlayRef = useRef<HTMLDivElement>(null)
11+
const [overlayHeight, setOverlayHeight] = useState<number>(50)
12+
const [overlayTop, setOverlayTop] = useState<number>(0)
13+
const [isDragging, setIsDragging] = useState<boolean>(false)
14+
const dragStartY = useRef<number>(0)
15+
const startScrollTop = useRef<number>(0)
16+
17+
const componentSpecificThemeClass = getComponentSpecificThemeClass(theme)
18+
19+
const { scaleFactor, diffMinimapHeight } = useMemo(() => {
20+
if (view?.dom) {
21+
return {
22+
scaleFactor: view.dom.parentElement.parentElement.clientHeight / view.dom.scrollHeight,
23+
diffMinimapHeight: view.dom.scrollHeight,
24+
}
25+
}
26+
27+
return { scaleFactor: 0, diffMinimapHeight: 0 }
28+
}, [view])
29+
30+
// Update the overlay position and size
31+
const updateOverlay = () => {
32+
if (!view?.dom || !minimapContainerRef.current || !overlayRef.current) {
33+
return
34+
}
35+
36+
const { clientHeight, scrollHeight, scrollTop } = view.dom
37+
const minimapHeight = minimapContainerRef.current.clientHeight
38+
39+
const _overlayHeight = (clientHeight / scrollHeight) * minimapHeight
40+
const _overlayTop = (scrollTop / scrollHeight) * minimapHeight
41+
42+
setOverlayHeight(_overlayHeight)
43+
setOverlayTop(_overlayTop)
44+
}
45+
46+
useEffect(() => {
47+
updateOverlay()
48+
}, [scaleFactor])
49+
50+
// Sync overlay scrolling with the diff view
51+
const handleDiffScroll = () => {
52+
updateOverlay()
53+
}
54+
55+
// Start dragging the overlay
56+
const handleOverlayMouseDown = (event: React.MouseEvent<HTMLDivElement>) => {
57+
event.preventDefault()
58+
59+
setIsDragging(true)
60+
dragStartY.current = event.clientY
61+
startScrollTop.current = view.dom?.scrollTop || 0
62+
63+
// Disable selection globally while dragging
64+
document.body.style.userSelect = 'none'
65+
document.body.style.pointerEvents = 'none'
66+
}
67+
68+
// Dragging the overlay to scroll
69+
const handleOverlayMouseMove = (event: MouseEvent) => {
70+
if (!isDragging || !view?.dom || !minimapContainerRef.current) {
71+
return
72+
}
73+
74+
const { scrollHeight, clientHeight } = view.dom
75+
const minimapHeight = minimapContainerRef.current.clientHeight
76+
const deltaY = event.clientY - dragStartY.current
77+
const scrollRatio = deltaY / minimapHeight
78+
79+
view.dom.scrollTo({ top: startScrollTop.current + scrollRatio * (scrollHeight - clientHeight) })
80+
}
81+
82+
// Stop dragging
83+
const handleOverlayMouseUp = () => {
84+
setIsDragging(false)
85+
86+
// Re-enable selection after dragging
87+
document.body.style.userSelect = 'auto'
88+
document.body.style.pointerEvents = 'auto'
89+
}
90+
91+
useEffect(() => {
92+
if (view?.dom) {
93+
const { dom } = view
94+
dom.addEventListener('scroll', handleDiffScroll)
95+
}
96+
97+
return () => {
98+
if (view?.dom) {
99+
const { dom } = view
100+
dom.removeEventListener('scroll', handleDiffScroll)
101+
}
102+
}
103+
}, [view])
104+
105+
useEffect(() => {
106+
if (isDragging) {
107+
document.addEventListener('mousemove', handleOverlayMouseMove)
108+
document.addEventListener('mouseup', handleOverlayMouseUp)
109+
} else {
110+
document.removeEventListener('mousemove', handleOverlayMouseMove)
111+
document.removeEventListener('mouseup', handleOverlayMouseUp)
112+
}
113+
return () => {
114+
document.removeEventListener('mousemove', handleOverlayMouseMove)
115+
document.removeEventListener('mouseup', handleOverlayMouseUp)
116+
}
117+
}, [isDragging])
118+
119+
// Clicking on the minimap scrolls the diff viewer
120+
const handleMinimapClick = (event: React.MouseEvent<HTMLDivElement>) => {
121+
if (!view.dom || !minimapContainerRef.current) return
122+
123+
const { clientHeight, scrollHeight } = view.dom
124+
const minimapHeight = minimapContainerRef.current.clientHeight
125+
const clickY = event.clientY - minimapContainerRef.current.getBoundingClientRect().top
126+
const scrollRatio = clickY / minimapHeight
127+
128+
view.dom.scrollTo({ top: scrollRatio * (scrollHeight - clientHeight) })
129+
}
130+
131+
return (
132+
<div ref={minimapContainerRef} className="dc__position-rel">
133+
<CodeMirrorMerge
134+
key={scaleFactor}
135+
theme={codeEditorTheme}
136+
className={`code-editor__mini-map dc__overflow-hidden ${componentSpecificThemeClass}`}
137+
gutter={false}
138+
destroyRerender={false}
139+
style={{
140+
maxWidth: '30px',
141+
transform: `scale(1, ${scaleFactor})`,
142+
height: `${diffMinimapHeight}px`,
143+
transformOrigin: 'top left',
144+
}}
145+
>
146+
<CodeMirrorMerge.Original
147+
basicSetup={false}
148+
value={state.lhsCode}
149+
readOnly
150+
editable={false}
151+
extensions={diffMinimapExtensions}
152+
/>
153+
<CodeMirrorMerge.Modified
154+
basicSetup={false}
155+
value={state.code}
156+
readOnly
157+
editable={false}
158+
extensions={diffMinimapExtensions}
159+
/>
160+
</CodeMirrorMerge>
161+
<div
162+
className="dc__position-abs dc__top-0 dc__left-0 dc__right-0 dc__bottom-0 dc__zi-1 cursor"
163+
onClick={handleMinimapClick}
164+
/>
165+
<div
166+
ref={overlayRef}
167+
className="dc__position-abs dc__left-0 w-100 br-4 dc__zi-2"
168+
style={{
169+
top: `${overlayTop}px`,
170+
height: `${overlayHeight}px`,
171+
backgroundColor: 'rgba(0, 0, 0, 0.3)',
172+
cursor: 'grab',
173+
}}
174+
onMouseDown={handleOverlayMouseDown}
175+
/>
176+
</div>
177+
)
178+
}

src/Common/CodeMirror/Extensions/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,3 +18,4 @@ export * from './yamlParseLinter'
1818
export * from './readOnlyTooltip'
1919
export * from './findAndReplace'
2020
export * from './yamlHighlight'
21+
export * from './diffMinimap'

src/Common/CodeMirror/codeEditor.scss

Lines changed: 32 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -20,11 +20,11 @@
2020
background-color: var(--bg-code-editor-base);
2121
}
2222

23-
.cm-mergeViewEditor:first-child {
24-
border-right: 1px solid var(--N200);
25-
}
23+
.cm-merge-theme:not(.code-editor__read-only):not(.code-editor__mini-map) {
24+
.cm-mergeViewEditor:first-child {
25+
border-right: 1px solid var(--N200);
26+
}
2627

27-
.cm-merge-theme:not(.code-editor__read-only) {
2828
.cm-mergeViewEditor:last-child {
2929
border-left: 1px solid var(--N200);
3030
}
@@ -219,6 +219,34 @@
219219
}
220220
}
221221

222+
// MINIMAP STYLES
223+
.code-editor__mini-map {
224+
.cm-editor {
225+
&.cm-merge-a .cm-changedLine,
226+
.cm-deletedChunk,
227+
&.cm-merge-a .cm-changedText,
228+
.cm-deletedChunk .cm-deletedText,
229+
&.cm-merge-a .cm-changedLineGutter {
230+
background-color: var(--R200);
231+
}
232+
233+
&.cm-merge-b .cm-changedLine,
234+
&.cm-merge-b .cm-changedText,
235+
&.cm-merge-b .cm-changedLineGutter {
236+
background: var(--G200);
237+
}
238+
}
239+
240+
& .cm-line,
241+
& .cm-line span {
242+
color: transparent !important;
243+
}
244+
245+
& .cm-scroller {
246+
overflow: hidden !important;
247+
}
248+
}
249+
222250
// CODE EDITOR STYLES
223251
.code-editor {
224252
&__schema-tooltip {

src/Common/CodeMirror/types.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
import { Dispatch, FunctionComponent, Key, MutableRefObject, ReactNode, SVGProps } from 'react'
1818
import { JSONSchema7 } from 'json-schema'
1919
import { EditorView, ReactCodeMirrorProps } from '@uiw/react-codemirror'
20+
import { CodeMirrorMergeRef } from 'react-codemirror-merge'
2021
import { SearchQuery } from '@codemirror/search'
2122

2223
import { MODES } from '@Common/Constants'
@@ -163,4 +164,10 @@ export type CodeEditorRendererProps = Required<
163164
originalViewExtensions: ReactCodeMirrorProps['extensions']
164165
modifiedViewExtensions: ReactCodeMirrorProps['extensions']
165166
extensions: ReactCodeMirrorProps['extensions']
167+
diffMinimapExtensions: ReactCodeMirrorProps['extensions']
166168
}
169+
170+
export interface DiffMinimapProps
171+
extends Pick<CodeEditorRendererProps, 'state' | 'diffMinimapExtensions' | 'codeEditorTheme' | 'theme'> {
172+
view: CodeMirrorMergeRef['view']
173+
}

src/Common/CodeMirror/utils.tsx

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -177,12 +177,12 @@ export const getRevertControlButton = () => {
177177
}
178178

179179
// EXTENSION UTILS
180-
export const getLanguageExtension = (mode: CodeEditorProps['mode']): Extension => {
180+
export const getLanguageExtension = (mode: CodeEditorProps['mode'], disableLint = false): Extension => {
181181
switch (mode) {
182182
case MODES.JSON:
183-
return [json(), linter(jsonParseLinter())]
183+
return [json(), ...(!disableLint ? [linter(jsonParseLinter())] : [])]
184184
case MODES.YAML:
185-
return [yaml(), linter(yamlParseLinter())]
185+
return [yaml(), ...(!disableLint ? [linter(yamlParseLinter())] : [])]
186186
case MODES.SHELL:
187187
return StreamLanguage.define(shell)
188188
case MODES.DOCKERFILE:

0 commit comments

Comments
 (0)