Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Desktop: Fix editor/viewer loses focus when visible panels are changed with ctrl-l #11029

Merged
1 change: 1 addition & 0 deletions .eslintignore
Original file line number Diff line number Diff line change
Expand Up @@ -287,6 +287,7 @@ packages/app-desktop/gui/NoteEditor/NoteBody/CodeMirror/v6/CodeMirror.js
packages/app-desktop/gui/NoteEditor/NoteBody/CodeMirror/v6/Editor.js
packages/app-desktop/gui/NoteEditor/NoteBody/CodeMirror/v6/useEditorCommands.js
packages/app-desktop/gui/NoteEditor/NoteBody/CodeMirror/v6/utils/useKeymap.js
packages/app-desktop/gui/NoteEditor/NoteBody/CodeMirror/v6/utils/useRefocusOnVisiblePaneChange.js
packages/app-desktop/gui/NoteEditor/NoteBody/PlainEditor/PlainEditor.js
packages/app-desktop/gui/NoteEditor/NoteBody/TinyMCE/TinyMCE.js
packages/app-desktop/gui/NoteEditor/NoteBody/TinyMCE/styles/index.js
Expand Down
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -264,6 +264,7 @@ packages/app-desktop/gui/NoteEditor/NoteBody/CodeMirror/v6/CodeMirror.js
packages/app-desktop/gui/NoteEditor/NoteBody/CodeMirror/v6/Editor.js
packages/app-desktop/gui/NoteEditor/NoteBody/CodeMirror/v6/useEditorCommands.js
packages/app-desktop/gui/NoteEditor/NoteBody/CodeMirror/v6/utils/useKeymap.js
packages/app-desktop/gui/NoteEditor/NoteBody/CodeMirror/v6/utils/useRefocusOnVisiblePaneChange.js
packages/app-desktop/gui/NoteEditor/NoteBody/PlainEditor/PlainEditor.js
packages/app-desktop/gui/NoteEditor/NoteBody/TinyMCE/TinyMCE.js
packages/app-desktop/gui/NoteEditor/NoteBody/TinyMCE/styles/index.js
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ import useWebviewIpcMessage from '../utils/useWebviewIpcMessage';
import Toolbar from '../Toolbar';
import useEditorSearchHandler from '../utils/useEditorSearchHandler';
import CommandService from '@joplin/lib/services/CommandService';
import useRefocusOnVisiblePaneChange from './utils/useRefocusOnVisiblePaneChange';

const logger = Logger.create('CodeMirror6');
const logDebug = (message: string) => logger.debug(message);
Expand Down Expand Up @@ -318,6 +319,8 @@ const CodeMirror = (props: NoteBodyEditorProps, ref: ForwardedRef<NoteBodyEditor
return output;
}, [styles.cellViewer, props.visiblePanes]);

useRefocusOnVisiblePaneChange({ editorRef, webviewRef, visiblePanes: props.visiblePanes });

useEditorSearchHandler({
setLocalSearchResultCount: props.setLocalSearchResultCount,
searchMarkers: props.searchMarkers,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import { RefObject, useRef, useEffect } from 'react';
import { focus } from '@joplin/lib/utils/focusHandler';
import CodeMirrorControl from '@joplin/editor/CodeMirror/CodeMirrorControl';
import NoteTextViewer from '../../../../../NoteTextViewer';

interface Props {
editorRef: RefObject<CodeMirrorControl>;
webviewRef: RefObject<NoteTextViewer>;
visiblePanes: string[];
}

const useRefocusOnVisiblePaneChange = ({ editorRef, webviewRef, visiblePanes }: Props) => {
const lastVisiblePanes = useRef(visiblePanes);
useEffect(() => {
const editorHasFocus = editorRef.current?.cm6?.dom?.contains(document.activeElement);
const viewerHasFocus = webviewRef.current?.hasFocus();

const lastHadViewer = lastVisiblePanes.current.includes('viewer');
const hasViewer = visiblePanes.includes('viewer');
const lastHadEditor = lastVisiblePanes.current.includes('editor');
const hasEditor = visiblePanes.includes('editor');

const viewerJustHidden = lastHadViewer && !hasViewer;
if (viewerJustHidden && viewerHasFocus) {
focus('CodeMirror/refocusEditor1', editorRef.current);
}

// Jump focus to the editor just after showing it -- this assumes that the user
// shows the editor to start editing the note.
const editorJustShown = !lastHadEditor && hasEditor;
if (editorJustShown && viewerHasFocus) {
focus('CodeMirror/refocusEditor2', editorRef.current);
}

const editorJustHidden = lastHadEditor && !hasEditor;
if (editorJustHidden && editorHasFocus) {
focus('CodeMirror/refocusViewer', webviewRef.current);
}

lastVisiblePanes.current = visiblePanes;
}, [visiblePanes, editorRef, webviewRef]);
};

export default useRefocusOnVisiblePaneChange;
8 changes: 6 additions & 2 deletions packages/app-desktop/gui/NoteTextViewer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -26,8 +26,7 @@ export default class NoteTextViewerComponent extends React.Component<Props, any>

private initialized_ = false;
private domReady_ = false;
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
private webviewRef_: any;
private webviewRef_: React.RefObject<HTMLIFrameElement>;
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
private webviewListeners_: any = null;
private removePluginAssetsCallback_: RemovePluginAssetsCallback|null = null;
Expand Down Expand Up @@ -132,9 +131,14 @@ export default class NoteTextViewerComponent extends React.Component<Props, any>
public focus() {
if (this.webviewRef_.current) {
focus('NoteTextViewer::focus', this.webviewRef_.current);
this.send('focus');
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Previously, after .focusing the viewer, the arrow keys didn't scroll the viewer's content.

Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could you this comment in the code? I'm not a big fan of having two different ways to focus something but if it's an exception we can leave it but with a comment.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The original focus('NoteTextViewer::focus', this.webviewRef_.current); seems to be unnecessary — I've removed it (leaving this.send('focus')) and added a comment. Edit: Tests fail when focus(..., webviewRef) is removed. Additionally, without it, .focusing the viewer doesn't seem to work when not triggered by user interaction.

}
}

public hasFocus() {
return this.webviewRef_.current?.contains(document.activeElement);
}

public tryInit() {
if (!this.initialized_ && this.webviewRef_.current) {
this.initWebview();
Expand Down
6 changes: 3 additions & 3 deletions packages/app-desktop/integration-tests/main.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ test.describe('main', () => {
await mainWindow.keyboard.type('New note content!');

// Should render
const viewerFrame = editor.getNoteViewerIframe();
const viewerFrame = editor.getNoteViewerFrameLocator();
await expect(viewerFrame.locator('h1')).toHaveText('Test note!');
});

Expand Down Expand Up @@ -78,7 +78,7 @@ test.describe('main', () => {
}

// Should render mermaid
const viewerFrame = editor.getNoteViewerIframe();
const viewerFrame = editor.getNoteViewerFrameLocator();
await expect(
viewerFrame.locator('pre.mermaid text', { hasText: testCommitId }),
).toBeVisible();
Expand Down Expand Up @@ -115,7 +115,7 @@ test.describe('main', () => {
await setMessageBoxResponse(electronApp, /^No/i);
await editor.attachFileButton.click();

const viewerFrame = editor.getNoteViewerIframe();
const viewerFrame = editor.getNoteViewerFrameLocator();
const renderedImage = viewerFrame.getByAltText(filename);

const fullSize = await getImageSourceSize(renderedImage);
Expand Down
43 changes: 39 additions & 4 deletions packages/app-desktop/integration-tests/markdownEditor.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import MainScreen from './models/MainScreen';
import { join } from 'path';
import getImageSourceSize from './util/getImageSourceSize';
import setFilePickerResponse from './util/setFilePickerResponse';
import activateMainMenuItem from './util/activateMainMenuItem';


test.describe('markdownEditor', () => {
Expand All @@ -24,7 +25,7 @@ test.describe('markdownEditor', () => {
await importedHtmlFileItem.click({ timeout: 300 });
}).toPass();

const viewerFrame = mainScreen.noteEditor.getNoteViewerIframe();
const viewerFrame = mainScreen.noteEditor.getNoteViewerFrameLocator();
// Should render headers
await expect(viewerFrame.locator('h1')).toHaveText('Test HTML file!');

Expand All @@ -44,7 +45,7 @@ test.describe('markdownEditor', () => {
await setFilePickerResponse(electronApp, [join(__dirname, 'resources', 'small-pdf.pdf')]);
await editor.attachFileButton.click();

const viewerFrame = mainScreen.noteEditor.getNoteViewerIframe();
const viewerFrame = mainScreen.noteEditor.getNoteViewerFrameLocator();
const pdfLink = viewerFrame.getByText('small-pdf.pdf');
await expect(pdfLink).toBeVisible();

Expand Down Expand Up @@ -106,7 +107,7 @@ test.describe('markdownEditor', () => {
await setFilePickerResponse(electronApp, [join(__dirname, 'resources', 'video.mp4')]);
await editor.attachFileButton.click();

const videoLocator = editor.getNoteViewerIframe().locator('video');
const videoLocator = editor.getNoteViewerFrameLocator().locator('video');
const expectVideoToRender = async () => {
await expect(videoLocator).toBeSeekableMediaElement(6.9, 7);
};
Expand Down Expand Up @@ -172,7 +173,7 @@ test.describe('markdownEditor', () => {
await mainWindow.keyboard.press('Enter');
await mainWindow.keyboard.type('This is a test of search. `Test inline code`');

const viewer = noteEditor.getNoteViewerIframe();
const viewer = noteEditor.getNoteViewerFrameLocator();
await expect(viewer.locator('h1')).toHaveText('Testing');

const matches = viewer.locator('mark');
Expand Down Expand Up @@ -213,5 +214,39 @@ test.describe('markdownEditor', () => {
await expect(noteEditor.codeMirrorEditor).toBeVisible();
await expect(noteEditor.editorSearchInput).not.toBeVisible();
});

test('should move focus when the visible editor panes change', async ({ mainWindow, electronApp }) => {
const mainScreen = new MainScreen(mainWindow);
await mainScreen.waitFor();
const noteEditor = mainScreen.noteEditor;

await mainScreen.createNewNote('Note');

await noteEditor.focusCodeMirrorEditor();
await mainWindow.keyboard.type('test');
const focusInMarkdownEditor = noteEditor.codeMirrorEditor.locator(':focus');
await expect(focusInMarkdownEditor).toBeAttached();

const toggleEditorLayout = () => activateMainMenuItem(electronApp, 'Toggle editor layout');

// Editor only
await toggleEditorLayout();
await expect(noteEditor.noteViewerContainer).not.toBeVisible();
// Markdown editor should be focused
await expect(focusInMarkdownEditor).toBeAttached();

// Viewer only
await toggleEditorLayout();
await expect(noteEditor.codeMirrorEditor).not.toBeVisible();
// Viewer should be focused
await expect(noteEditor.noteViewerContainer).toBeFocused();

// Viewer and editor
await toggleEditorLayout();
await expect(noteEditor.noteViewerContainer).toBeAttached();
await expect(noteEditor.codeMirrorEditor).toBeVisible();
// Editor should be focused
await expect(focusInMarkdownEditor).toBeAttached();
});
});

Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { Locator, Page } from '@playwright/test';

export default class NoteEditorPage {
public readonly codeMirrorEditor: Locator;
public readonly noteViewerContainer: Locator;
public readonly richTextEditor: Locator;
public readonly noteTitleInput: Locator;
public readonly attachFileButton: Locator;
Expand All @@ -12,14 +13,15 @@ export default class NoteEditorPage {
public readonly viewerSearchInput: Locator;
private readonly containerLocator: Locator;

public constructor(private readonly page: Page) {
public constructor(page: Page) {
this.containerLocator = page.locator('.rli-editor');
this.codeMirrorEditor = this.containerLocator.locator('.cm-editor');
this.richTextEditor = this.containerLocator.locator('iframe[title="Rich Text Area"]');
this.noteTitleInput = this.containerLocator.locator('.title-input');
this.attachFileButton = this.containerLocator.getByRole('button', { name: 'Attach file' });
this.toggleEditorsButton = this.containerLocator.getByRole('button', { name: 'Toggle editors' });
this.toggleEditorLayoutButton = this.containerLocator.getByRole('button', { name: 'Toggle editor layout' });
this.noteViewerContainer = this.containerLocator.locator('iframe[src$="note-viewer/index.html"]');
// The editor and viewer have slightly different search UI
this.editorSearchInput = this.containerLocator.getByPlaceholder('Find');
this.viewerSearchInput = this.containerLocator.getByPlaceholder('Search...');
Expand All @@ -29,11 +31,11 @@ export default class NoteEditorPage {
return this.containerLocator.getByRole('button', { name: title });
}

public getNoteViewerIframe() {
public getNoteViewerFrameLocator() {
// The note viewer can change content when the note re-renders. As such,
// a new locator needs to be created after re-renders (and this can't be a
// static property).
return this.page.frameLocator('[src$="note-viewer/index.html"]');
return this.noteViewerContainer.frameLocator(':scope');
}

public getTinyMCEFrameLocator() {
Expand Down
2 changes: 1 addition & 1 deletion packages/app-desktop/integration-tests/noteList.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ test.describe('noteList', () => {
await mainWindow.keyboard.type('[Testing...](http://example.com/)');

// Wait to render
await expect(editor.getNoteViewerIframe().locator('a', { hasText: 'Testing...' })).toBeVisible();
await expect(editor.getNoteViewerFrameLocator().locator('a', { hasText: 'Testing...' })).toBeVisible();

// Updating the title should force the sidebar to update sooner
await expect(editor.noteTitleInput).toHaveValue('note-1');
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ test.describe('richTextEditor', () => {
await editor.attachFileButton.click();

// Wait to render
const viewerFrame = editor.getNoteViewerIframe();
const viewerFrame = editor.getNoteViewerFrameLocator();
await viewerFrame.locator('a[data-from-md]').waitFor();

// Should have an attached resource
Expand Down
Loading