Implement history diff viewer buttons (#12439)

GitOrigin-RevId: 0ed8eb8568783b4938188a86c4ee75c767e6d713
This commit is contained in:
Tim Down 2023-04-14 12:57:15 +02:00 committed by Copybot
parent 63b2064420
commit 38998afa8e
8 changed files with 419 additions and 14 deletions

View file

@ -510,6 +510,10 @@
"more": "", "more": "",
"n_items": "", "n_items": "",
"n_items_plural": "", "n_items_plural": "",
"n_more_updates_above": "",
"n_more_updates_above_plural": "",
"n_more_updates_below": "",
"n_more_updates_below_plural": "",
"name": "", "name": "",
"navigate_log_source": "", "navigate_log_source": "",
"navigation": "", "navigation": "",

View file

@ -9,6 +9,14 @@ import useScopeValue from '../../../../shared/hooks/use-scope-value'
import { theme, Options } from '../../extensions/theme' import { theme, Options } from '../../extensions/theme'
import { indentUnit } from '@codemirror/language' import { indentUnit } from '@codemirror/language'
import { Highlight } from '../../services/types/doc' import { Highlight } from '../../services/types/doc'
import useIsMounted from '../../../../shared/hooks/use-is-mounted'
import {
highlightLocations,
highlightLocationsField,
scrollToHighlight,
} from '../../extensions/highlight-locations'
import Icon from '../../../../shared/components/icon'
import { useTranslation } from 'react-i18next'
type FontFamily = 'monaco' | 'lucida' type FontFamily = 'monaco' | 'lucida'
type LineHeight = 'compact' | 'normal' | 'wide' type LineHeight = 'compact' | 'normal' | 'wide'
@ -19,9 +27,10 @@ function extensions(themeOptions: Options): Extension[] {
EditorView.editable.of(false), EditorView.editable.of(false),
lineNumbers(), lineNumbers(),
EditorView.lineWrapping, EditorView.lineWrapping,
indentUnit.of(' '), indentUnit.of(' '), // TODO: Vary this by file type
indentationMarkers({ hideFirstIndent: true, highlightActiveBlock: false }), indentationMarkers({ hideFirstIndent: true, highlightActiveBlock: false }),
highlights(), highlights(),
highlightLocations(),
theme(themeOptions), theme(themeOptions),
] ]
} }
@ -37,8 +46,10 @@ function DocumentDiffViewer({
const [fontSize] = useScopeValue<number>('settings.fontSize') const [fontSize] = useScopeValue<number>('settings.fontSize')
const [lineHeight] = useScopeValue<LineHeight>('settings.lineHeight') const [lineHeight] = useScopeValue<LineHeight>('settings.lineHeight')
const [overallTheme] = useScopeValue<OverallTheme>('settings.overallTheme') const [overallTheme] = useScopeValue<OverallTheme>('settings.overallTheme')
const isMounted = useIsMounted()
const { t } = useTranslation()
const [state] = useState(() => { const [state, setState] = useState(() => {
return EditorState.create({ return EditorState.create({
doc, doc,
extensions: extensions({ extensions: extensions({
@ -53,9 +64,17 @@ function DocumentDiffViewer({
const view = useRef( const view = useRef(
new EditorView({ new EditorView({
state, state,
dispatch: tr => {
view.update([tr])
if (isMounted.current) {
setState(view.state)
}
},
}) })
).current ).current
const highlightLocations = state.field(highlightLocationsField)
// Append the editor view DOM to the container node when mounted // Append the editor view DOM to the container node when mounted
const containerRef = useCallback( const containerRef = useCallback(
node => { node => {
@ -66,6 +85,20 @@ function DocumentDiffViewer({
[view] [view]
) )
const scrollToPrevious = useCallback(() => {
if (highlightLocations.previous) {
scrollToHighlight(view, highlightLocations.previous)
}
}, [highlightLocations.previous, view])
const scrollToNext = useCallback(() => {
if (highlightLocations.next) {
scrollToHighlight(view, highlightLocations.next)
}
}, [highlightLocations.next, view])
const { before, after } = highlightLocations
useEffect(() => { useEffect(() => {
view.dispatch({ view.dispatch({
changes: { from: 0, to: view.state.doc.length, insert: doc }, changes: { from: 0, to: view.state.doc.length, insert: doc },
@ -73,7 +106,31 @@ function DocumentDiffViewer({
}) })
}, [doc, highlights, view]) }, [doc, highlights, view])
return <div ref={containerRef} className="document-diff-container" /> return (
<div className="document-diff-container">
<div ref={containerRef} className="cm-viewer-container" />
{before > 0 ? (
<button
className="btn btn-secondary previous-highlight-button"
onClick={scrollToPrevious}
>
<Icon type="arrow-up" />
&nbsp;
{t('n_more_updates_above', { count: before })}
</button>
) : null}
{after > 0 ? (
<button
className="btn btn-secondary next-highlight-button"
onClick={scrollToNext}
>
<Icon type="arrow-down" />
&nbsp;
{t('n_more_updates_below', { count: after })}
</button>
) : null}
</div>
)
} }
export default withErrorBoundary(DocumentDiffViewer, ErrorBoundaryFallback) export default withErrorBoundary(DocumentDiffViewer, ErrorBoundaryFallback)

View file

@ -0,0 +1,135 @@
import { EditorSelection, StateEffect, StateField } from '@codemirror/state'
import { Highlight } from '../services/types/doc'
import { EditorView, ViewPlugin, ViewUpdate } from '@codemirror/view'
import { highlightsField } from './highlights'
import { throttle, isEqual } from 'lodash'
import { updateHasEffect } from '../../../../../modules/source-editor/frontend/js/utils/effects'
export type HighlightLocations = {
before: number
after: number
next?: Highlight
previous?: Highlight
}
const setHighlightLocationsEffect = StateEffect.define<HighlightLocations>()
const hasSetHighlightLocationsEffect = updateHasEffect(
setHighlightLocationsEffect
)
// Returns the range within the document that is currently visible to the user
function visibleRange(view: EditorView) {
const { top, bottom } = view.scrollDOM.getBoundingClientRect()
const first = view.lineBlockAtHeight(top - view.documentTop)
const last = view.lineBlockAtHeight(bottom - view.documentTop)
return { from: first.from, to: last.to }
}
function calculateHighlightLocations(view: EditorView): HighlightLocations {
const highlightsBefore: Highlight[] = []
const highlightsAfter: Highlight[] = []
let next
let previous
const highlights = view.state.field(highlightsField) || []
if (highlights.length === 0) {
return { before: 0, after: 0 }
}
const { from: visibleFrom, to: visibleTo } = visibleRange(view)
for (const highlight of highlights) {
if (highlight.range.to <= visibleFrom) {
highlightsBefore.push(highlight)
} else if (highlight.range.from >= visibleTo) {
highlightsAfter.push(highlight)
}
}
const before = highlightsBefore.length
const after = highlightsAfter.length
if (before > 0) {
previous = highlightsBefore[highlightsBefore.length - 1]
}
if (after > 0) {
next = highlightsAfter[0]
}
return {
before,
after,
previous,
next,
}
}
const plugin = ViewPlugin.fromClass(
class {
// eslint-disable-next-line no-useless-constructor
constructor(readonly view: EditorView) {}
dispatchIfChanged() {
const oldLocations = this.view.state.field(highlightLocationsField)
const newLocations = calculateHighlightLocations(this.view)
console.log(
'dispatchIfChanged, changed is',
!isEqual(oldLocations, newLocations)
)
if (!isEqual(oldLocations, newLocations)) {
this.view.dispatch({
effects: setHighlightLocationsEffect.of(newLocations),
})
}
}
update(update: ViewUpdate) {
if (!hasSetHighlightLocationsEffect(update)) {
// Normally, a timeout is a poor choice, but in this case it doesn't
// matter that there is a slight delay or that it might run after the
// viewer has been torn down
window.setTimeout(() => this.dispatchIfChanged())
}
}
},
{
eventHandlers: {
scroll: throttle(
(event, view: EditorView) => {
view.plugin(plugin)?.dispatchIfChanged()
},
120,
{ trailing: true }
),
},
}
)
export const highlightLocationsField = StateField.define<HighlightLocations>({
create() {
return { before: 0, visible: 0, after: 0 }
},
update(highlightLocations, tr) {
for (const effect of tr.effects) {
if (effect.is(setHighlightLocationsEffect)) {
return effect.value
}
}
return highlightLocations
},
provide: () => [plugin],
})
export function highlightLocations() {
return highlightLocationsField
}
export function scrollToHighlight(view: EditorView, highlight: Highlight) {
view.dispatch({
effects: EditorView.scrollIntoView(
EditorSelection.range(highlight.range.from, highlight.range.to)
),
})
}

View file

@ -73,7 +73,7 @@ const tooltip = (view: EditorView, pos: number, side: any): Tooltip | null => {
} }
} }
const highlightsField = StateField.define<Highlight[]>({ export const highlightsField = StateField.define<Highlight[]>({
create() { create() {
return [] return []
}, },

View file

@ -39,6 +39,12 @@ const highlights: Highlight[] = [
hue: 200, hue: 200,
label: 'Added by Wombat on Friday', label: 'Added by Wombat on Friday',
}, },
{
type: 'addition',
range: { from: 1770, to: 1780 },
hue: 200,
label: 'Added by Wombat on Tuesday',
},
] ]
const content = `\\documentclass{article} const content = `\\documentclass{article}
@ -85,6 +91,42 @@ Once you're familiar with the editor, you can find various project settings in t
\\item The numbers starts at 1 with each use of the \\text{enumerate} environment \\item The numbers starts at 1 with each use of the \\text{enumerate} environment
\\end{enumerate} \\end{enumerate}
\\bibliographystyle{alpha} \\bibliographystyle{alpha}
\\bibliography{sample} \\bibliography{sample}

View file

@ -35,16 +35,6 @@ history-root {
.doc-container { .doc-container {
flex: 1; flex: 1;
overflow-y: auto; overflow-y: auto;
.document-diff-container {
height: 100%;
display: flex;
flex-direction: column;
.cm-editor {
height: 100%;
}
}
} }
} }
@ -197,3 +187,29 @@ history-root {
} }
} }
} }
.document-diff-container {
height: 100%;
display: flex;
flex-direction: column;
position: relative;
.cm-viewer-container,
.cm-editor {
height: 100%;
}
.previous-highlight-button,
.next-highlight-button {
position: absolute;
right: 16px;
}
.previous-highlight-button {
top: 16px;
}
.next-highlight-button {
bottom: 16px;
}
}

View file

@ -939,6 +939,10 @@
"must_be_email_address": "Must be an email address", "must_be_email_address": "Must be an email address",
"n_items": "__count__ item", "n_items": "__count__ item",
"n_items_plural": "__count__ items", "n_items_plural": "__count__ items",
"n_more_updates_above": "__count__ more update above",
"n_more_updates_above_plural": "__count__ more updates above",
"n_more_updates_below": "__count__ more update below",
"n_more_updates_below_plural": "__count__ more updates below",
"name": "Name", "name": "Name",
"native": "Native", "native": "Native",
"navigate_log_source": "Navigate to log position in source code: __location__", "navigate_log_source": "Navigate to log position in source code: __location__",

View file

@ -0,0 +1,147 @@
import DocumentDiffViewer from '../../../../../frontend/js/features/history/components/diff-view/document-diff-viewer'
import { Highlight } from '../../../../../frontend/js/features/history/services/types/doc'
import { FC } from 'react'
import { EditorProviders } from '../../../helpers/editor-providers'
const doc = `\\documentclass{article}
% Language setting
% Replace \`english' with e.g. \`spanish' to change the document language
\\usepackage[english]{babel}
% Set page size and margins
% Replace \`letterpaper' with \`a4paper' for UK/EU standard size
\\usepackage[letterpaper,top=2cm,bottom=2cm,left=3cm,right=3cm,marginparwidth=1.75cm]{geometry}
% Useful packages
\\usepackage{amsmath}
\\usepackage{graphicx}
\\usepackage[colorlinks=true, allcolors=blue]{hyperref}
\\title{Your Paper}
\\author{You}
\\begin{document}
\\maketitle
\\begin{abstract}
Your abstract.
\\end{abstract}
\\section{Introduction}
Your introduction goes here! Simply start writing your document and use the Recompile button to view the updated PDF preview. Examples of commonly used commands and features are listed below, to help you get started.
Once you're familiar with the editor, you can find various project settings in the Overleaf menu, accessed via the button in the very top left of the editor. To view tutorials, user guides, and further documentation, please visit our \\href{https://www.overleaf.com/learn}{help library}, or head to our plans page to \\href{https://www.overleaf.com/user/subscription/plans}{choose your plan}.
${'\n'.repeat(200)}
\\end{document}`
const highlights: Highlight[] = [
{
type: 'addition',
range: { from: 15, to: 22 },
hue: 200,
label: 'Added by Wombat on Monday',
},
{
type: 'deletion',
range: { from: 27, to: 35 },
hue: 200,
label: 'Deleted by Wombat on Tuesday',
},
{
type: 'addition',
range: { from: doc.length - 9, to: doc.length - 1 },
hue: 200,
label: 'Added by Wombat on Wednesday',
},
]
const Container: FC = ({ children }) => (
<div style={{ width: 600, height: 400 }}>{children}</div>
)
const mockScope = () => {
return {
settings: {
fontSize: 12,
fontFamily: 'monaco',
lineHeight: 'normal',
overallTheme: '',
},
}
}
describe('document diff viewer', function () {
it('displays highlights with hover tooltips', function () {
const scope = mockScope()
cy.mount(
<Container>
<EditorProviders scope={scope}>
<DocumentDiffViewer doc={doc} highlights={highlights} />
</EditorProviders>
</Container>
)
cy.get('.ol-addition-marker').should('have.length', 1)
cy.get('.ol-addition-marker').first().as('addition')
cy.get('@addition').should('have.text', 'article')
cy.get('.ol-deletion-marker').should('have.length', 1)
cy.get('.ol-deletion-marker').first().as('deletion')
cy.get('@deletion').should('have.text', 'Language')
// Check hover tooltips
cy.get('@addition').trigger('mousemove')
cy.get('.ol-cm-highlight-tooltip').should('have.length', 1)
cy.get('.ol-cm-highlight-tooltip')
.first()
.should('have.text', 'Added by Wombat on Monday')
cy.get('@deletion').trigger('mousemove')
cy.get('.ol-cm-highlight-tooltip').should('have.length', 1)
cy.get('.ol-cm-highlight-tooltip')
.first()
.should('have.text', 'Deleted by Wombat on Tuesday')
})
it("renders 'More updates' buttons", function () {
const scope = mockScope()
cy.mount(
<Container>
<EditorProviders scope={scope}>
<DocumentDiffViewer doc={doc} highlights={highlights} />
</EditorProviders>
</Container>
)
cy.get('.cm-scroller').first().as('scroller')
// Check the initial state, which should be a "More updates below" button
// but no "More updates above", with the editor scrolled to the top
cy.get('.ol-addition-marker').should('have.length', 1)
cy.get('.ol-deletion-marker').should('have.length', 1)
cy.get('.previous-highlight-button').should('have.length', 0)
cy.get('.next-highlight-button').should('have.length', 1)
cy.get('@scroller').invoke('scrollTop').should('equal', 0)
// Click the "More updates below" button, which should scroll the editor,
// and check the new state
cy.get('.next-highlight-button').first().click()
cy.get('@scroller').invoke('scrollTop').should('not.equal', 0)
cy.get('.previous-highlight-button').should('have.length', 1)
cy.get('.next-highlight-button').should('have.length', 0)
// Click the "More updates above" button, which should scroll the editor up
// but not quite to the top, and check the new state
cy.get('.previous-highlight-button').first().click()
cy.get('@scroller').invoke('scrollTop').should('not.equal', 0)
cy.get('.previous-highlight-button').should('have.length', 1)
cy.get('.next-highlight-button').should('have.length', 1)
})
})