Skip to content

Commit

Permalink
Desktop: Fix editor/viewer loses focus when visible panels are change…
Browse files Browse the repository at this point in the history
…d with ctrl-l (#11029)
  • Loading branch information
personalizedrefrigerator authored Sep 12, 2024
1 parent c897cc1 commit bcb5218
Show file tree
Hide file tree
Showing 10 changed files with 110 additions and 14 deletions.
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;
14 changes: 12 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 @@ -131,10 +130,21 @@ export default class NoteTextViewerComponent extends React.Component<Props, any>

public focus() {
if (this.webviewRef_.current) {
// Calling focus on webviewRef_ seems to be necessary when NoteTextViewer.focus
// is called outside of a user event (e.g. in a setTimeout) or during automated
// tests:
focus('NoteTextViewer::focus', this.webviewRef_.current);

// Calling .focus on this.webviewRef.current isn't sufficient.
// To allow arrow-key scrolling, focus must also be set within the iframe:
this.send('focus');
}
}

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

0 comments on commit bcb5218

Please sign in to comment.