Merge pull request #29971 from overleaf/mg-edit-search-bug

Make editor focusable in view-only mode

GitOrigin-RevId: ed9b079fa379d84f7f410669fa2d865f82e21cb1
This commit is contained in:
Malik Glossop
2025-12-01 11:21:51 +01:00
committed by Copybot
parent dab59520c3
commit 472e05f32b
3 changed files with 209 additions and 10 deletions

View File

@@ -3,27 +3,43 @@ import { EditorView } from '@codemirror/view'
const readOnlyConf = new Compartment()
// Make the editor focusable even when contenteditable="false" (read-only mode)
// This allows keyboard shortcuts like Cmd+F to work in read-only mode
const focusableReadOnly = EditorView.contentAttributes.of({ tabindex: '0' })
// Hide the blinking cursor in read-only mode
const hideCursor = EditorView.theme({
'&.cm-editor .cm-cursorLayer': {
display: 'none',
},
})
const readOnlyAttributes = [
EditorState.readOnly.of(true),
EditorView.editable.of(false),
focusableReadOnly,
hideCursor,
]
const editableAttributes = [
EditorState.readOnly.of(false),
EditorView.editable.of(true),
]
/**
* A custom extension which determines whether the content is editable, by setting the value of the EditorState.readOnly and EditorView.editable facets.
* Commands and extensions read the EditorState.readOnly facet to decide whether they should be applied.
* EditorView.editable determines whether the DOM can be focused, by changing the value of the contenteditable attribute.
* We add tabindex="0" in read-only mode to ensure the editor remains focusable for keyboard shortcuts.
*/
export const editable = () => {
return [
readOnlyConf.of([
EditorState.readOnly.of(true),
EditorView.editable.of(false),
]),
]
return [readOnlyConf.of(readOnlyAttributes)]
}
export const setEditable = (value = true): TransactionSpec => {
return {
effects: [
readOnlyConf.reconfigure([
EditorState.readOnly.of(!value),
EditorView.editable.of(value),
]),
readOnlyConf.reconfigure(value ? editableAttributes : readOnlyAttributes),
],
}
}

View File

@@ -8,6 +8,7 @@ import { FC } from 'react'
import { FileTreePathContext } from '@/features/file-tree/contexts/file-tree-path'
import { TestContainer } from '../helpers/test-container'
import { PermissionsContext } from '@/features/ide-react/context/permissions-context'
import { metaKey } from '../helpers/meta-key'
const FileTreePathProvider: FC<React.PropsWithChildren> = ({ children }) => (
<FileTreePathContext.Provider
@@ -154,4 +155,56 @@ describe('<CodeMirrorEditor/> in Visual mode with read-only permission', functio
cy.findByLabelText('URL').should('be.disabled')
cy.findByRole('button', { name: 'Remove link' }).should('not.exist')
})
it('opens the CodeMirror search panel with Cmd/Ctrl+F', function () {
mountEditor('Hello world\n\nThis is a test document.')
// Click to focus the editor
cy.get('.cm-content').click()
// Search panel should not be open initially
cy.findByRole('search').should('not.exist')
// Press Cmd/Ctrl+F to open search
cy.get('.cm-content').type(`{${metaKey}+f}`)
// Search panel should now be open
cy.findByRole('search').should('exist')
cy.findByRole('textbox', { name: 'Find' }).should('be.visible')
})
it('allows searching for text in read-only mode', function () {
mountEditor('Hello world\n\nThis is a test document with hello again.')
// Click to focus the editor
cy.get('.cm-content').click()
// Open search panel
cy.get('.cm-content').type(`{${metaKey}+f}`)
// Type a search query
cy.findByRole('textbox', { name: 'Find' }).type('hello')
// Should find matches (case insensitive)
cy.get('.cm-searchMatch').should('have.length.at.least', 1)
})
it('closes the search panel with Escape', function () {
mountEditor('Hello world')
// Click to focus the editor
cy.get('.cm-content').click()
// Open search panel
cy.get('.cm-content').type(`{${metaKey}+f}`)
// Search panel should be open
cy.findByRole('search').should('exist')
// Press Escape to close
cy.findByRole('textbox', { name: 'Find' }).type('{esc}')
// Search panel should be closed
cy.findByRole('search').should('not.exist')
})
})

View File

@@ -0,0 +1,130 @@
import { expect } from 'chai'
import { EditorState } from '@codemirror/state'
import { EditorView } from '@codemirror/view'
import {
editable,
setEditable,
} from '../../../../../frontend/js/features/source-editor/extensions/editable'
const doc = `\\documentclass{article}
\\begin{document}
Hello world
\\end{document}`
describe('editable extension', function () {
let view: EditorView
let container: HTMLElement
beforeEach(function () {
container = document.createElement('div')
document.body.appendChild(container)
})
afterEach(function () {
view?.destroy()
container?.remove()
})
function createView(extensions = [editable()]) {
view = new EditorView({
parent: container,
state: EditorState.create({
doc,
extensions,
}),
})
return view
}
describe('initial read-only state', function () {
beforeEach(function () {
createView()
})
it('should set EditorState.readOnly to true', function () {
expect(view.state.readOnly).to.be.true
})
it('should set EditorView.editable to false', function () {
expect(view.state.facet(EditorView.editable)).to.be.false
})
it('should set contenteditable="false" on the content element', function () {
expect(view.contentDOM.getAttribute('contenteditable')).to.equal('false')
})
it('should set tabindex="0" to allow focus in read-only mode', function () {
expect(view.contentDOM.getAttribute('tabindex')).to.equal('0')
})
it('should allow the editor to receive focus via tabindex', function () {
view.contentDOM.focus()
expect(document.activeElement).to.equal(view.contentDOM)
})
})
describe('setEditable(true) - switching to editable mode', function () {
beforeEach(function () {
createView()
view.dispatch(setEditable(true))
})
it('should set EditorState.readOnly to false', function () {
expect(view.state.readOnly).to.be.false
})
it('should set EditorView.editable to true', function () {
expect(view.state.facet(EditorView.editable)).to.be.true
})
it('should set contenteditable="true" on the content element', function () {
expect(view.contentDOM.getAttribute('contenteditable')).to.equal('true')
})
it('should not have tabindex attribute (not needed when contenteditable)', function () {
expect(view.contentDOM.getAttribute('tabindex')).to.be.null
})
it('should allow document modifications', function () {
view.dispatch({
changes: { from: 0, insert: 'New text ' },
})
expect(view.state.doc.toString().startsWith('New text ')).to.be.true
})
it('should allow the editor to receive focus', function () {
view.contentDOM.focus()
expect(document.activeElement).to.equal(view.contentDOM)
})
})
describe('setEditable(false) - switching to read-only mode', function () {
beforeEach(function () {
createView()
view.dispatch(setEditable(true))
view.dispatch(setEditable(false))
})
it('should set EditorState.readOnly to true', function () {
expect(view.state.readOnly).to.be.true
})
it('should set EditorView.editable to false', function () {
expect(view.state.facet(EditorView.editable)).to.be.false
})
it('should set contenteditable="false" on the content element', function () {
expect(view.contentDOM.getAttribute('contenteditable')).to.equal('false')
})
it('should restore tabindex="0" for focusability', function () {
expect(view.contentDOM.getAttribute('tabindex')).to.equal('0')
})
it('should still allow the editor to receive focus after switching modes', function () {
view.contentDOM.focus()
expect(document.activeElement).to.equal(view.contentDOM)
})
})
})