mirror of
https://github.com/overleaf/overleaf.git
synced 2025-12-05 01:10:29 +00:00
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:
@@ -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),
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user