116 changes: 67 additions & 49 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -131,6 +131,7 @@ packages/app-desktop/commands/exportFolders.js
packages/app-desktop/commands/exportNotes.js
packages/app-desktop/commands/focusElement.js
packages/app-desktop/commands/index.js
packages/app-desktop/commands/openNoteInNewWindow.js
packages/app-desktop/commands/openProfileDirectory.js
packages/app-desktop/commands/renderMarkup.test.js
packages/app-desktop/commands/renderMarkup.js
Expand Down Expand Up @@ -184,60 +185,14 @@ packages/app-desktop/gui/KeymapConfig/styles/index.js
packages/app-desktop/gui/KeymapConfig/utils/getLabel.js
packages/app-desktop/gui/KeymapConfig/utils/useCommandStatus.js
packages/app-desktop/gui/KeymapConfig/utils/useKeymap.js
packages/app-desktop/gui/MainScreen/MainScreen.js
packages/app-desktop/gui/MainScreen/commands/addProfile.js
packages/app-desktop/gui/MainScreen/commands/commandPalette.js
packages/app-desktop/gui/MainScreen/commands/deleteFolder.js
packages/app-desktop/gui/MainScreen/commands/duplicateNote.js
packages/app-desktop/gui/MainScreen/commands/editAlarm.js
packages/app-desktop/gui/MainScreen/commands/exportPdf.js
packages/app-desktop/gui/MainScreen/commands/gotoAnything.js
packages/app-desktop/gui/MainScreen/commands/hideModalMessage.js
packages/app-desktop/gui/MainScreen/commands/index.js
packages/app-desktop/gui/MainScreen/commands/leaveSharedFolder.js
packages/app-desktop/gui/MainScreen/commands/moveToFolder.js
packages/app-desktop/gui/MainScreen/commands/newFolder.js
packages/app-desktop/gui/MainScreen/commands/newNote.js
packages/app-desktop/gui/MainScreen/commands/newSubFolder.js
packages/app-desktop/gui/MainScreen/commands/newTodo.js
packages/app-desktop/gui/MainScreen/commands/openFolder.js
packages/app-desktop/gui/MainScreen/commands/openFolderDialog.js
packages/app-desktop/gui/MainScreen/commands/openItem.js
packages/app-desktop/gui/MainScreen/commands/openNote.js
packages/app-desktop/gui/MainScreen/commands/openPdfViewer.js
packages/app-desktop/gui/MainScreen/commands/openTag.js
packages/app-desktop/gui/MainScreen/commands/print.js
packages/app-desktop/gui/MainScreen/commands/renameFolder.js
packages/app-desktop/gui/MainScreen/commands/renameTag.js
packages/app-desktop/gui/MainScreen/commands/resetLayout.js
packages/app-desktop/gui/MainScreen/commands/restoreFolder.js
packages/app-desktop/gui/MainScreen/commands/restoreNote.js
packages/app-desktop/gui/MainScreen/commands/revealResourceFile.js
packages/app-desktop/gui/MainScreen/commands/search.js
packages/app-desktop/gui/MainScreen/commands/setTags.js
packages/app-desktop/gui/MainScreen/commands/showModalMessage.js
packages/app-desktop/gui/MainScreen/commands/showNoteContentProperties.js
packages/app-desktop/gui/MainScreen/commands/showNoteProperties.js
packages/app-desktop/gui/MainScreen/commands/showPrompt.js
packages/app-desktop/gui/MainScreen/commands/showShareFolderDialog.js
packages/app-desktop/gui/MainScreen/commands/showShareNoteDialog.js
packages/app-desktop/gui/MainScreen/commands/showSpellCheckerMenu.test.js
packages/app-desktop/gui/MainScreen/commands/showSpellCheckerMenu.js
packages/app-desktop/gui/MainScreen/commands/toggleEditors.js
packages/app-desktop/gui/MainScreen/commands/toggleLayoutMoveMode.js
packages/app-desktop/gui/MainScreen/commands/toggleMenuBar.js
packages/app-desktop/gui/MainScreen/commands/toggleNoteList.js
packages/app-desktop/gui/MainScreen/commands/toggleNoteType.js
packages/app-desktop/gui/MainScreen/commands/toggleNotesSortOrderField.js
packages/app-desktop/gui/MainScreen/commands/toggleNotesSortOrderReverse.js
packages/app-desktop/gui/MainScreen/commands/togglePerFolderSortOrder.js
packages/app-desktop/gui/MainScreen/commands/toggleSideBar.js
packages/app-desktop/gui/MainScreen/commands/toggleVisiblePanes.js
packages/app-desktop/gui/MainScreen.js
packages/app-desktop/gui/MasterPasswordDialog/Dialog.js
packages/app-desktop/gui/MenuBar.js
packages/app-desktop/gui/MultiNoteActions.js
packages/app-desktop/gui/Navigator.js
packages/app-desktop/gui/NewWindowOrIFrame.js
packages/app-desktop/gui/NoteContentPropertiesDialog.js
packages/app-desktop/gui/NoteEditor/EditorWindow.js
packages/app-desktop/gui/NoteEditor/NoteBody/CodeMirror/Toolbar.js
packages/app-desktop/gui/NoteEditor/NoteBody/CodeMirror/utils/index.js
packages/app-desktop/gui/NoteEditor/NoteBody/CodeMirror/utils/normalizeAccelerator.test.js
Expand Down Expand Up @@ -298,6 +253,7 @@ packages/app-desktop/gui/NoteEditor/utils/clipboardUtils.test.js
packages/app-desktop/gui/NoteEditor/utils/clipboardUtils.js
packages/app-desktop/gui/NoteEditor/utils/contextMenu.js
packages/app-desktop/gui/NoteEditor/utils/contextMenuUtils.js
packages/app-desktop/gui/NoteEditor/utils/getWindowCommandPriority.js
packages/app-desktop/gui/NoteEditor/utils/index.js
packages/app-desktop/gui/NoteEditor/utils/markupRenderOptions.js
packages/app-desktop/gui/NoteEditor/utils/resourceHandling.test.js
Expand Down Expand Up @@ -430,7 +386,66 @@ packages/app-desktop/gui/ToolbarButton/ToolbarButton.js
packages/app-desktop/gui/ToolbarSpace.js
packages/app-desktop/gui/TrashNotification/TrashNotification.js
packages/app-desktop/gui/UpdateNotification/UpdateNotification.js
packages/app-desktop/gui/WindowCommandsAndDialogs/AppDialogs.js
packages/app-desktop/gui/WindowCommandsAndDialogs/ModalMessageOverlay.js
packages/app-desktop/gui/WindowCommandsAndDialogs/PluginDialogs.js
packages/app-desktop/gui/WindowCommandsAndDialogs/WindowCommandsAndDialogs.js
packages/app-desktop/gui/WindowCommandsAndDialogs/commands/addProfile.js
packages/app-desktop/gui/WindowCommandsAndDialogs/commands/commandPalette.js
packages/app-desktop/gui/WindowCommandsAndDialogs/commands/deleteFolder.js
packages/app-desktop/gui/WindowCommandsAndDialogs/commands/duplicateNote.js
packages/app-desktop/gui/WindowCommandsAndDialogs/commands/editAlarm.js
packages/app-desktop/gui/WindowCommandsAndDialogs/commands/exportPdf.js
packages/app-desktop/gui/WindowCommandsAndDialogs/commands/gotoAnything.js
packages/app-desktop/gui/WindowCommandsAndDialogs/commands/hideModalMessage.js
packages/app-desktop/gui/WindowCommandsAndDialogs/commands/index.js
packages/app-desktop/gui/WindowCommandsAndDialogs/commands/leaveSharedFolder.js
packages/app-desktop/gui/WindowCommandsAndDialogs/commands/moveToFolder.js
packages/app-desktop/gui/WindowCommandsAndDialogs/commands/newFolder.js
packages/app-desktop/gui/WindowCommandsAndDialogs/commands/newNote.js
packages/app-desktop/gui/WindowCommandsAndDialogs/commands/newSubFolder.js
packages/app-desktop/gui/WindowCommandsAndDialogs/commands/newTodo.js
packages/app-desktop/gui/WindowCommandsAndDialogs/commands/openFolder.js
packages/app-desktop/gui/WindowCommandsAndDialogs/commands/openFolderDialog.js
packages/app-desktop/gui/WindowCommandsAndDialogs/commands/openItem.js
packages/app-desktop/gui/WindowCommandsAndDialogs/commands/openNote.js
packages/app-desktop/gui/WindowCommandsAndDialogs/commands/openPdfViewer.js
packages/app-desktop/gui/WindowCommandsAndDialogs/commands/openTag.js
packages/app-desktop/gui/WindowCommandsAndDialogs/commands/print.js
packages/app-desktop/gui/WindowCommandsAndDialogs/commands/renameFolder.js
packages/app-desktop/gui/WindowCommandsAndDialogs/commands/renameTag.js
packages/app-desktop/gui/WindowCommandsAndDialogs/commands/resetLayout.js
packages/app-desktop/gui/WindowCommandsAndDialogs/commands/restoreFolder.js
packages/app-desktop/gui/WindowCommandsAndDialogs/commands/restoreNote.js
packages/app-desktop/gui/WindowCommandsAndDialogs/commands/revealResourceFile.js
packages/app-desktop/gui/WindowCommandsAndDialogs/commands/search.js
packages/app-desktop/gui/WindowCommandsAndDialogs/commands/setTags.js
packages/app-desktop/gui/WindowCommandsAndDialogs/commands/showModalMessage.js
packages/app-desktop/gui/WindowCommandsAndDialogs/commands/showNoteContentProperties.js
packages/app-desktop/gui/WindowCommandsAndDialogs/commands/showNoteProperties.js
packages/app-desktop/gui/WindowCommandsAndDialogs/commands/showPrompt.js
packages/app-desktop/gui/WindowCommandsAndDialogs/commands/showShareFolderDialog.js
packages/app-desktop/gui/WindowCommandsAndDialogs/commands/showShareNoteDialog.js
packages/app-desktop/gui/WindowCommandsAndDialogs/commands/showSpellCheckerMenu.test.js
packages/app-desktop/gui/WindowCommandsAndDialogs/commands/showSpellCheckerMenu.js
packages/app-desktop/gui/WindowCommandsAndDialogs/commands/toggleEditors.js
packages/app-desktop/gui/WindowCommandsAndDialogs/commands/toggleLayoutMoveMode.js
packages/app-desktop/gui/WindowCommandsAndDialogs/commands/toggleMenuBar.js
packages/app-desktop/gui/WindowCommandsAndDialogs/commands/toggleNoteList.js
packages/app-desktop/gui/WindowCommandsAndDialogs/commands/toggleNoteType.js
packages/app-desktop/gui/WindowCommandsAndDialogs/commands/toggleNotesSortOrderField.js
packages/app-desktop/gui/WindowCommandsAndDialogs/commands/toggleNotesSortOrderReverse.js
packages/app-desktop/gui/WindowCommandsAndDialogs/commands/togglePerFolderSortOrder.js
packages/app-desktop/gui/WindowCommandsAndDialogs/commands/toggleSideBar.js
packages/app-desktop/gui/WindowCommandsAndDialogs/commands/toggleVisiblePanes.js
packages/app-desktop/gui/WindowCommandsAndDialogs/types.js
packages/app-desktop/gui/WindowCommandsAndDialogs/utils/appDialogs.js
packages/app-desktop/gui/WindowCommandsAndDialogs/utils/usePrintToCallback.js
packages/app-desktop/gui/WindowCommandsAndDialogs/utils/useSyncDialogState.js
packages/app-desktop/gui/WindowCommandsAndDialogs/utils/useWindowCommands.js
packages/app-desktop/gui/WindowCommandsAndDialogs/utils/useWindowControl.js
packages/app-desktop/gui/dialogs.js
packages/app-desktop/gui/hooks/useDocument.js
packages/app-desktop/gui/hooks/useEffectDebugger.js
packages/app-desktop/gui/hooks/useElementHeight.js
packages/app-desktop/gui/hooks/useImperativeHandlerDebugger.js
Expand Down Expand Up @@ -519,6 +534,7 @@ packages/app-desktop/utils/isSafeToOpen.test.js
packages/app-desktop/utils/isSafeToOpen.js
packages/app-desktop/utils/restartInSafeModeFromMain.test.js
packages/app-desktop/utils/restartInSafeModeFromMain.js
packages/app-desktop/utils/window/types.js
packages/app-mobile/PluginAssetsLoader.js
packages/app-mobile/commands/index.js
packages/app-mobile/commands/newNote.test.js
Expand Down Expand Up @@ -969,6 +985,8 @@ packages/lib/geolocation-node.js
packages/lib/hooks/useAsyncEffect.js
packages/lib/hooks/useElementSize.js
packages/lib/hooks/useEventListener.js
packages/lib/hooks/useNowEffect.test.js
packages/lib/hooks/useNowEffect.js
packages/lib/hooks/usePlugin.js
packages/lib/hooks/usePrevious.js
packages/lib/hooks/useQueuedAsyncEffect.test.js
Expand Down
73 changes: 66 additions & 7 deletions packages/app-desktop/ElectronAppWrapper.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@ import { _ } from '@joplin/lib/locale';
import restartInSafeModeFromMain from './utils/restartInSafeModeFromMain';
import handleCustomProtocols, { CustomProtocolHandler } from './utils/customProtocols/handleCustomProtocols';
import { clearTimeout, setTimeout } from 'timers';
import { resolve } from 'path';
import { defaultWindowId } from '@joplin/lib/reducer';

interface RendererProcessQuitReply {
canClose: boolean;
Expand All @@ -27,21 +29,30 @@ interface PluginWindows {
[key: string]: any;
}

export default class ElectronAppWrapper {
type SecondaryWindowId = string;
interface SecondaryWindowData {
electronId: number;
}

export default class ElectronAppWrapper {
private logger_: Logger = null;
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
private electronApp_: any;
private env_: string;
private isDebugMode_: boolean;
private profilePath_: string;

private win_: BrowserWindow = null;
private mainWindowHidden_ = true;
private pluginWindows_: PluginWindows = {};
private secondaryWindows_: Map<SecondaryWindowId, SecondaryWindowData> = new Map();

private willQuitApp_ = false;
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
private tray_: any = null;
private buildDir_: string = null;
private rendererProcessQuitReply_: RendererProcessQuitReply = null;
private pluginWindows_: PluginWindows = {};

private initialCallbackUrl_: string = null;
private updaterService_: AutoUpdaterService = null;
private customProtocolHandler_: CustomProtocolHandler = null;
Expand All @@ -68,10 +79,26 @@ export default class ElectronAppWrapper {
return this.logger_;
}

public window() {
public mainWindow() {
return this.win_;
}

public activeWindow() {
return BrowserWindow.getFocusedWindow() ?? this.win_;
}

public windowById(joplinId: string) {
if (joplinId === defaultWindowId) {
return this.mainWindow();
}

const windowData = this.secondaryWindows_.get(joplinId);
if (windowData !== undefined) {
return BrowserWindow.fromId(windowData.electronId);
}
return null;
}

public env() {
return this.env_;
}
Expand Down Expand Up @@ -210,6 +237,15 @@ export default class ElectronAppWrapper {
}
});

this.mainWindowHidden_ = !windowOptions.show;
this.win_.on('hide', () => {
this.mainWindowHidden_ = true;
});

this.win_.on('show', () => {
this.mainWindowHidden_ = false;
});

void this.win_.loadURL(url.format({
pathname: path.join(__dirname, 'index.html'),
protocol: 'file:',
Expand Down Expand Up @@ -249,6 +285,11 @@ export default class ElectronAppWrapper {
// Script-controlled pages: Used for opening notes in new windows
return {
action: 'allow',
overrideBrowserWindowOptions: {
webPreferences: {
preload: resolve(__dirname, './utils/window/secondaryWindowPreload.js'),
},
},
};
} else if (event.url.match(/^https?:\/\//)) {
void bridge().openExternal(event.url);
Expand Down Expand Up @@ -281,7 +322,8 @@ export default class ElectronAppWrapper {
this.hide();
}
} else {
if (this.trayShown() && !this.willQuitApp_) {
const hasBackgroundWindows = this.secondaryWindows_.size > 0;
if ((hasBackgroundWindows || this.trayShown()) && !this.willQuitApp_) {
event.preventDefault();
this.win_.hide();
Comment on lines +325 to 328
Copy link
Collaborator Author

Choose a reason for hiding this comment

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

This allows the main window to be closed without quitting the app, even if "show tray icon" is disabled.

} else {
Expand Down Expand Up @@ -311,6 +353,23 @@ export default class ElectronAppWrapper {
}
});

ipcMain.on('secondary-window-added', (event, windowId: string) => {
const window = BrowserWindow.fromWebContents(event.sender);
const electronWindowId = window?.id;
this.secondaryWindows_.set(windowId, { electronId: electronWindowId });

window.once('close', () => {
this.secondaryWindows_.delete(windowId);

const allSecondaryWindowsClosed = this.secondaryWindows_.size === 0;
const mainWindowVisuallyClosed = this.mainWindowHidden_;
if (allSecondaryWindowsClosed && mainWindowVisuallyClosed && !this.trayShown()) {
// Gracefully quit the app if the user has closed all windows
this.win_.close();
}
});
});

// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
ipcMain.on('asynchronous-message', (_event: any, message: string, args: any) => {
if (message === 'appCloseReply') {
Expand Down Expand Up @@ -442,11 +501,11 @@ export default class ElectronAppWrapper {
this.tray_.setContextMenu(contextMenu);

this.tray_.on('click', () => {
if (!this.window()) {
if (!this.mainWindow()) {
console.warn('The window object was not available during the click event from tray icon');
return;
}
this.window().show();
this.mainWindow().show();
});
} catch (error) {
console.error('Cannot create tray', error);
Expand All @@ -473,7 +532,7 @@ export default class ElectronAppWrapper {
// Someone tried to open a second instance - focus our window instead
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
this.electronApp_.on('second-instance', (_e: any, argv: string[]) => {
const win = this.window();
const win = this.mainWindow();
if (!win) return;
if (win.isMinimized()) win.restore();
win.show();
Expand Down
26 changes: 25 additions & 1 deletion packages/app-desktop/app.reducer.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { AppState } from './app.reducer';
import { AppState, createAppDefaultWindowState } from './app.reducer';
import appReducer, { createAppDefaultState } from './app.reducer';

describe('app.reducer', () => {
Expand Down Expand Up @@ -47,4 +47,28 @@ describe('app.reducer', () => {
]);
});

it('showing a dialog in one window should hide dialogs with the same ID in background windows', () => {
const state: AppState = {
...createAppDefaultState({}, {}),
backgroundWindows: {
testWindow: {
...createAppDefaultWindowState(),
windowId: 'testWindow',

visibleDialogs: {
testDialog: true,
},
},
},
};

const newState = appReducer(state, {
type: 'VISIBLE_DIALOGS_ADD',
name: 'testDialog',
});

expect(newState.backgroundWindows.testWindow.visibleDialogs).toEqual({});
expect(newState.visibleDialogs).toEqual({ testDialog: true });
});

});
75 changes: 63 additions & 12 deletions packages/app-desktop/app.reducer.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import produce from 'immer';
import Setting from '@joplin/lib/models/Setting';
import { defaultState, State } from '@joplin/lib/reducer';
import { defaultState, defaultWindowState, State, WindowState } from '@joplin/lib/reducer';
import iterateItems from './gui/ResizableLayout/utils/iterateItems';
import { LayoutItem } from './gui/ResizableLayout/utils/types';
import validateLayout from './gui/ResizableLayout/utils/validateLayout';
Expand Down Expand Up @@ -30,56 +30,89 @@ export interface EditorScrollPercents {
[noteId: string]: number;
}

export interface AppState extends State {
export interface VisibleDialogs {
[dialogKey: string]: boolean;
}

export interface AppWindowState extends WindowState {
noteVisiblePanes: string[];
editorCodeView: boolean;
visibleDialogs: VisibleDialogs;
dialogs: AppStateDialog[];
devToolsVisible: boolean;
}

interface BackgroundWindowStates {
[windowId: string]: AppWindowState;
}

export interface AppState extends State, AppWindowState {
backgroundWindows: BackgroundWindowStates;

route: AppStateRoute;
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
navHistory: any[];
noteVisiblePanes: string[];
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
windowContentSize: any;
watchedNoteFiles: string[];
lastEditorScrollPercents: EditorScrollPercents;
devToolsVisible: boolean;
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
visibleDialogs: any; // empty object if no dialog is visible. Otherwise contains the list of visible dialogs.
focusedField: string;
layoutMoveMode: boolean;
startupPluginsLoaded: boolean;
modalOverlayMessage: string|null;

// Extra reducer keys go here
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
watchedResources: any;
mainLayout: LayoutItem;
dialogs: AppStateDialog[];
isResettingLayout: boolean;
}

export const createAppDefaultWindowState = (): AppWindowState => {
return {
...defaultWindowState,
visibleDialogs: {},
dialogs: [],
noteVisiblePanes: ['editor', 'viewer'],
editorCodeView: true,
devToolsVisible: false,
};
};

// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
export function createAppDefaultState(windowContentSize: any, resourceEditWatcherDefaultState: any): AppState {
return {
...defaultState,
...createAppDefaultWindowState(),
route: {
type: 'NAV_GO',
routeName: 'Main',
props: {},
},
navHistory: [],
noteVisiblePanes: ['editor', 'viewer'],
windowContentSize, // bridge().windowContentSize(),
watchedNoteFiles: [],
lastEditorScrollPercents: {},
devToolsVisible: false,
visibleDialogs: {}, // empty object if no dialog is visible. Otherwise contains the list of visible dialogs.
focusedField: null,
layoutMoveMode: false,
mainLayout: null,
startupPluginsLoaded: false,
dialogs: [],
isResettingLayout: false,
modalOverlayMessage: null,
...resourceEditWatcherDefaultState,
};
}

const hideBackgroundDialogsWithId = produce((state: AppState, id: string) => {
for (const windowId of Object.keys(state.backgroundWindows)) {
const win = state.backgroundWindows[windowId];
if (id in win.visibleDialogs) {
delete win.visibleDialogs[id];
}
}
});

// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
export default function(state: AppState, action: any) {
let newState = state;
Expand Down Expand Up @@ -171,9 +204,17 @@ export default function(state: AppState, action: any) {
break;

case 'NOTE_VISIBLE_PANES_SET':
newState = {
...state,
noteVisiblePanes: action.panes,
};
break;

newState = { ...state };
newState.noteVisiblePanes = action.panes;
case 'EDITOR_CODE_VIEW_CHANGE':
newState = {
...state,
editorCodeView: action.value,
};
break;

case 'MAIN_LAYOUT_SET':
Expand Down Expand Up @@ -217,6 +258,14 @@ export default function(state: AppState, action: any) {

break;

case 'SHOW_MODAL_MESSAGE':
newState = { ...newState, modalOverlayMessage: action.message };
break;

case 'HIDE_MODAL_MESSAGE':
newState = { ...newState, modalOverlayMessage: null };
break;

case 'NOTE_FILE_WATCHER_ADD':

if (newState.watchedNoteFiles.indexOf(action.id) < 0) {
Expand Down Expand Up @@ -272,12 +321,14 @@ export default function(state: AppState, action: any) {
newState = { ...state };
newState.visibleDialogs = { ...newState.visibleDialogs };
newState.visibleDialogs[action.name] = true;
newState = hideBackgroundDialogsWithId(newState, action.name);
break;

case 'VISIBLE_DIALOGS_REMOVE':
newState = { ...state };
newState.visibleDialogs = { ...newState.visibleDialogs };
delete newState.visibleDialogs[action.name];
newState = hideBackgroundDialogsWithId(newState, action.name);
break;

case 'FOCUS_SET':
Expand Down
73 changes: 35 additions & 38 deletions packages/app-desktop/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,8 +34,8 @@ const Menu = bridge().Menu;
const PluginManager = require('@joplin/lib/services/PluginManager');
import RevisionService from '@joplin/lib/services/RevisionService';
import MigrationService from '@joplin/lib/services/MigrationService';
import { loadCustomCss, injectCustomStyles } from '@joplin/lib/CssUtils';
import mainScreenCommands from './gui/MainScreen/commands/index';
import { loadCustomCss } from '@joplin/lib/CssUtils';
import mainScreenCommands from './gui/WindowCommandsAndDialogs/commands/index';
import noteEditorCommands from './gui/NoteEditor/commands/index';
import noteListCommands from './gui/NoteList/commands/index';
import noteListControlsCommands from './gui/NoteListControls/commands/index';
Expand Down Expand Up @@ -151,10 +151,6 @@ class Application extends BaseApplication {
void this.setupOcrService();
}

if (action.type === 'SETTING_UPDATE_ONE' && action.key === 'style.editor.fontFamily' || action.type === 'SETTING_UPDATE_ALL') {
this.updateEditorFont();
}

if (action.type === 'SETTING_UPDATE_ONE' && action.key === 'windowContentZoomFactor' || action.type === 'SETTING_UPDATE_ALL') {
webFrame.setZoomFactor(Setting.value('windowContentZoomFactor') / 100);
}
Expand Down Expand Up @@ -218,31 +214,14 @@ class Application extends BaseApplication {
app.destroyTray();
} else {
const contextMenu = Menu.buildFromTemplate([
{ label: _('Open %s', app.electronApp().name), click: () => { app.window().show(); } },
{ label: _('Open %s', app.electronApp().name), click: () => { app.mainWindow().show(); } },
{ type: 'separator' },
{ label: _('Quit'), click: () => { void app.quit(); } },
]);
app.createTray(contextMenu);
}
}

public updateEditorFont() {
const fontFamilies = [];
if (Setting.value('style.editor.fontFamily')) fontFamilies.push(`"${Setting.value('style.editor.fontFamily')}"`);
fontFamilies.push('\'Avenir Next\', Avenir, Arial, sans-serif');

// The '*' and '!important' parts are necessary to make sure Russian text is displayed properly
// https://github.com/laurent22/joplin/issues/155
//
// Note: Be careful about the specificity here. Incorrect specificity can break monospaced fonts in tables.

const css = `.CodeMirror5 *, .cm-editor .cm-content { font-family: ${fontFamilies.join(', ')} !important; }`;
const styleTag = document.createElement('style');
styleTag.type = 'text/css';
styleTag.appendChild(document.createTextNode(css));
document.head.appendChild(styleTag);
}

public setupContextMenu() {
// bridge().setupContextMenu((misspelledWord: string, dictionarySuggestions: string[]) => {
// let output = SpellCheckerService.instance().contextMenuItems(misspelledWord, dictionarySuggestions);
Expand Down Expand Up @@ -430,6 +409,23 @@ class Application extends BaseApplication {
}
}

private async setupCustomCss() {
const chromeCssPath = Setting.customCssFilePath(Setting.customCssFilenames.JOPLIN_APP);
if (await shim.fsDriver().exists(chromeCssPath)) {
this.store().dispatch({
// Main window custom CSS
type: 'CUSTOM_CHROME_CSS_ADD',
filePath: chromeCssPath,
});
}

this.store().dispatch({
// Markdown preview pane
type: 'CUSTOM_VIEWER_CSS_APPEND',
css: await loadCustomCss(Setting.customCssFilePath(Setting.customCssFilenames.RENDERED_MARKDOWN)),
});
}

// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
public async start(argv: string[], startOptions: StartOptions = null): Promise<any> {
// If running inside a package, the command line, instead of being "node.exe <path> <flags>" is "joplin.exe <flags>" so
Expand All @@ -444,7 +440,7 @@ class Application extends BaseApplication {

if (Setting.value('sync.upgradeState') === Setting.SYNC_UPGRADE_STATE_MUST_DO) {
reg.logger().info('app.start: doing upgradeSyncTarget action');
bridge().window().show();
bridge().mainWindow().show();
return { action: 'upgradeSyncTarget' };
}

Expand All @@ -462,9 +458,6 @@ class Application extends BaseApplication {
syncDebugLog.info(`Profile dir: ${dir}`);
}

// Loads app-wide styles. (Markdown preview-specific styles loaded in app.js)
await injectCustomStyles('appStyles', Setting.customCssFilePath(Setting.customCssFilenames.JOPLIN_APP));

this.setupAutoUpdaterService();

AlarmService.setDriver(new AlarmServiceDriverNode({ appName: packageInfo.build.appId }));
Expand Down Expand Up @@ -541,6 +534,8 @@ class Application extends BaseApplication {
items: tags,
});

await this.setupCustomCss();

// const masterKeys = await MasterKey.all();

// this.dispatch({
Expand Down Expand Up @@ -583,13 +578,6 @@ class Application extends BaseApplication {
ids: Setting.value('collapsedFolderIds'),
});

// Loads custom Markdown preview styles
const cssString = await loadCustomCss(Setting.customCssFilePath(Setting.customCssFilenames.RENDERED_MARKDOWN));
this.store().dispatch({
type: 'CUSTOM_CSS_APPEND',
css: cssString,
});

this.store().dispatch({
type: 'NOTE_DEVTOOLS_SET',
value: Setting.value('flagOpenDevTools'),
Expand All @@ -602,7 +590,7 @@ class Application extends BaseApplication {
if (shim.isWindows() || shim.isMac()) {
const runAutoUpdateCheck = () => {
if (Setting.value('autoUpdateEnabled')) {
void checkForUpdates(true, bridge().window(), { includePreReleases: Setting.value('autoUpdate.includePreReleases') });
void checkForUpdates(true, bridge().mainWindow(), { includePreReleases: Setting.value('autoUpdate.includePreReleases') });
}
};

Expand All @@ -623,9 +611,9 @@ class Application extends BaseApplication {
}, 1000 * 60 * 60);

if (Setting.value('startMinimized') && Setting.value('showTrayIcon')) {
bridge().window().hide();
bridge().mainWindow().hide();
} else {
bridge().window().show();
bridge().mainWindow().show();
}

void ShareService.instance().maintenance();
Expand Down Expand Up @@ -698,6 +686,15 @@ class Application extends BaseApplication {
Setting.setValue('linking.extraAllowedExtensions', newExtensions);
});

window.addEventListener('focus', () => {
const currentWindowId = this.store().getState().windowId;
this.dispatch({
type: 'WINDOW_FOCUS',
windowId: 'default',
lastWindowId: currentWindowId,
});
});

await this.initPluginService();

this.setupContextMenu();
Expand Down
63 changes: 40 additions & 23 deletions packages/app-desktop/bridge.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import { extname, normalize } from 'path';
import isSafeToOpen from './utils/isSafeToOpen';
import { closeSync, openSync, readSync, statSync } from 'fs';
import { KB } from '@joplin/utils/bytes';
import { defaultWindowId } from '@joplin/lib/reducer';

interface LastSelectedPath {
file: string;
Expand Down Expand Up @@ -234,7 +235,7 @@ export class Bridge {
// eslint-disable-next-line @typescript-eslint/ban-types -- Old code before rule was applied
public setupContextMenu(_spellCheckerMenuItemsHandler: Function) {
require('electron-context-menu')({
allWindows: [this.window()],
allWindows: [this.mainWindow()],

electronApp: this.electronApp(),

Expand All @@ -259,8 +260,29 @@ export class Bridge {
});
}

public window() {
return this.electronWrapper_.window();
public mainWindow() {
return this.electronWrapper_.mainWindow();
}

public activeWindow() {
return this.electronWrapper_.activeWindow();
}

public windowById(id: string) {
return this.electronWrapper_.windowById(id);
}

// Switches to the window with the given ID, but only if that window was not the
// last focused window
public switchToWindow(windowId: string) {
const targetWindow = this.windowById(windowId);
if (this.activeWindow() !== this.windowById(windowId)) {
targetWindow.show();
}
}

public switchToMainWindow() {
this.switchToWindow(defaultWindowId);
}

public showItemInFolder(fullPath: string) {
Expand All @@ -272,36 +294,31 @@ export class Bridge {
return new BrowserWindow(options);
}

// Note: This provides the size of the main window. Prefer CSS where possible.
public windowContentSize() {
if (!this.window()) return { width: 0, height: 0 };
const s = this.window().getContentSize();
return { width: s[0], height: s[1] };
}

public windowSize() {
if (!this.window()) return { width: 0, height: 0 };
const s = this.window().getSize();
if (!this.mainWindow()) return { width: 0, height: 0 };
const s = this.mainWindow().getContentSize();
return { width: s[0], height: s[1] };
}

public windowSetSize(width: number, height: number) {
if (!this.window()) return;
return this.window().setSize(width, height);
if (!this.mainWindow()) return;
return this.mainWindow().setSize(width, height);
}

public openDevTools() {
return this.window().webContents.openDevTools();
return this.activeWindow().webContents.openDevTools();
}

public closeDevTools() {
return this.window().webContents.closeDevTools();
return this.activeWindow().webContents.closeDevTools();
}

// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
public async showSaveDialog(options: any) {
if (!options) options = {};
if (!('defaultPath' in options) && this.lastSelectedPaths_.file) options.defaultPath = this.lastSelectedPaths_.file;
const { filePath } = await dialog.showSaveDialog(this.window(), options);
const { filePath } = await dialog.showSaveDialog(this.activeWindow(), options);
if (filePath) {
this.lastSelectedPaths_.file = filePath;
}
Expand All @@ -316,7 +333,7 @@ export class Bridge {
if (!('defaultPath' in options) && (this.lastSelectedPaths_ as any)[fileType]) options.defaultPath = (this.lastSelectedPaths_ as any)[fileType];
if (!('createDirectory' in options)) options.createDirectory = true;
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
const { filePaths } = await dialog.showOpenDialog(this.window(), options as any);
const { filePaths } = await dialog.showOpenDialog(this.activeWindow(), options as any);
if (filePaths && filePaths.length) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
(this.lastSelectedPaths_ as any)[fileType] = dirname(filePaths[0]);
Expand All @@ -327,7 +344,7 @@ export class Bridge {
// Don't use this directly - call one of the showXxxxxxxMessageBox() instead
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
private showMessageBox_(window: any, options: MessageDialogOptions): number {
if (!window) window = this.window();
if (!window) window = this.activeWindow();
return dialog.showMessageBoxSync(window, { message: '', ...options });
}

Expand All @@ -337,7 +354,7 @@ export class Bridge {
...options,
};

return this.showMessageBox_(this.window(), {
return this.showMessageBox_(this.activeWindow(), {
type: 'error',
message: message,
buttons: options.buttons,
Expand All @@ -350,7 +367,7 @@ export class Bridge {
...options,
};

const result = this.showMessageBox_(this.window(), { type: 'question',
const result = this.showMessageBox_(this.activeWindow(), { type: 'question',
message: message,
cancelId: 1,
buttons: options.buttons, ...options });
Expand All @@ -360,7 +377,7 @@ export class Bridge {

/* returns the index of the clicked button */
public showMessageBox(message: string, options: MessageDialogOptions = {}) {
const result = this.showMessageBox_(this.window(), { type: 'question',
const result = this.showMessageBox_(this.activeWindow(), { type: 'question',
message: message,
buttons: [_('OK'), _('Cancel')], ...options });

Expand All @@ -369,7 +386,7 @@ export class Bridge {

// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
public showInfoMessageBox(message: string, options: any = {}) {
const result = this.showMessageBox_(this.window(), { type: 'info',
const result = this.showMessageBox_(this.activeWindow(), { type: 'info',
message: message,
buttons: [_('OK')], ...options });
return result === 0;
Expand Down Expand Up @@ -413,7 +430,7 @@ export class Bridge {
const allowOpenId = 2;
const learnMoreId = 1;
const fileExtensionDescription = JSON.stringify(fileExtension);
const result = await dialog.showMessageBox(this.window(), {
const result = await dialog.showMessageBox(this.activeWindow(), {
title: _('Unknown file type'),
message:
_('Joplin doesn\'t recognise the %s extension. Opening this file could be dangerous. What would you like to do?', fileExtensionDescription),
Expand Down
2 changes: 2 additions & 0 deletions packages/app-desktop/commands/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import * as emptyTrash from './emptyTrash';
import * as exportFolders from './exportFolders';
import * as exportNotes from './exportNotes';
import * as focusElement from './focusElement';
import * as openNoteInNewWindow from './openNoteInNewWindow';
import * as openProfileDirectory from './openProfileDirectory';
import * as renderMarkup from './renderMarkup';
import * as replaceMisspelling from './replaceMisspelling';
Expand All @@ -25,6 +26,7 @@ const index: any[] = [
exportFolders,
exportNotes,
focusElement,
openNoteInNewWindow,
openProfileDirectory,
renderMarkup,
replaceMisspelling,
Expand Down
36 changes: 36 additions & 0 deletions packages/app-desktop/commands/openNoteInNewWindow.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import { CommandRuntime, CommandDeclaration, CommandContext } from '@joplin/lib/services/CommandService';
import { _ } from '@joplin/lib/locale';
import { stateUtils } from '@joplin/lib/reducer';
import Note from '@joplin/lib/models/Note';
import { createAppDefaultWindowState } from '../app.reducer';
import Setting from '@joplin/lib/models/Setting';

export const declaration: CommandDeclaration = {
name: 'openNoteInNewWindow',
label: () => _('Edit in new window'),
iconName: 'icon-share',
};

let idCounter = 0;

export const runtime = (): CommandRuntime => {
return {
execute: async (context: CommandContext, noteId: string = null) => {
noteId = noteId || stateUtils.selectedNoteId(context.state);

const note = await Note.load(noteId, { fields: ['parent_id'] });
Copy link
Owner

Choose a reason for hiding this comment

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

I think noteId could be undefined? If there's no note in the notebook for example

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 enabledCondition is currently oneNoteSelected. As such, the command should only run if triggered by a script or oneNoteSelected is true. To make this requirement clearer, I've added a throw new Error if !noteId.

Copy link
Owner

Choose a reason for hiding this comment

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

No you're right, with the condition the note should indeed be defined so the exception is not necessary. Could you remove it please?

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

Resolved in 9919dca.

context.dispatch({
type: 'WINDOW_OPEN',
noteId,
folderId: note.parent_id,
windowId: `window-${noteId}-${idCounter++}`,
defaultAppWindowState: {
...createAppDefaultWindowState(),
noteVisiblePanes: Setting.value('noteVisiblePanes'),
editorCodeView: Setting.value('editor.codeView'),
},
});
},
enabledCondition: 'oneNoteSelected',
};
};
2 changes: 1 addition & 1 deletion packages/app-desktop/commands/replaceMisspelling.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ export const runtime = (): CommandRuntime => {
if (!modalDialogVisible && (isInsideContainer(activeElement, 'codeMirrorEditor') || isInsideContainer(activeElement, 'tox-edit-area__iframe'))) {
await CommandService.instance().execute('replaceSelection', suggestion);
} else {
bridge().window().webContents.replaceMisspelling(suggestion);
bridge().activeWindow().webContents.replaceMisspelling(suggestion);
}
},
};
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -229,7 +229,7 @@ export default function(props: Props) {
];

const menu = bridge().Menu.buildFromTemplate(template);
menu.popup({ window: bridge().window() });
menu.popup({ window: bridge().mainWindow() });
}, [onInstall, onBrowsePlugins]);

const onSearchQueryChange = useCallback((event: OnChangeEvent) => {
Expand Down
37 changes: 27 additions & 10 deletions packages/app-desktop/gui/Dialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,17 +2,22 @@ import * as React from 'react';
import { ReactNode, useEffect, useRef, useState } from 'react';
import { createPortal } from 'react-dom';
import { blur, focus } from '@joplin/lib/utils/focusHandler';
import useDocument from './hooks/useDocument';

type OnCancelListener = ()=> void;

interface Props {
className?: string;
onCancel?: OnCancelListener;
contentStyle?: React.CSSProperties;
contentFillsScreen?: boolean;
children: ReactNode;
}

const Dialog: React.FC<Props> = props => {
const [containerElement, setContainerElement] = useState<HTMLDivElement|null>(null);
const containerDocument = useDocument(containerElement);

// For correct focus handling, the dialog element needs to be managed separately from React. In particular,
// just after creating the dialog, we need to call .showModal() and just **before** closing the dialog, we
// need to call .close(). This second requirement is particularly difficult, as this needs to happen even
Expand All @@ -21,7 +26,7 @@ const Dialog: React.FC<Props> = props => {
// Because useEffect cleanup can happen after an element is removed from the HTML DOM, the dialog is managed
// using native HTML APIs. This allows us to call .close() while the dialog is still attached to the DOM, which
// allows the browser to restore the focus from before the dialog was opened.
const dialogElement = useDialogElement(props.onCancel);
const dialogElement = useDialogElement(containerDocument, props.onCancel);
useDialogClassNames(dialogElement, props.className);

const [contentRendered, setContentRendered] = useState(false);
Expand All @@ -34,6 +39,16 @@ const Dialog: React.FC<Props> = props => {
}
}, [dialogElement, contentRendered]);

useEffect(() => {
if (!dialogElement) return;

if (props.contentFillsScreen) {
dialogElement.classList.add('-fullscreen');
} else {
dialogElement.classList.remove('-fullscreen');
}
}, [props.contentFillsScreen, dialogElement]);

if (dialogElement && !contentRendered) {
setContentRendered(true);
}
Expand All @@ -43,19 +58,21 @@ const Dialog: React.FC<Props> = props => {
{props.children}
</div>
);
return <>
{dialogElement && createPortal(content, dialogElement)}
</>;
return <div ref={setContainerElement} className='dialog-anchor-node'>
{dialogElement && createPortal(content, dialogElement) as ReactNode}
</div>;
};

const useDialogElement = (onCancel: undefined|OnCancelListener) => {
const useDialogElement = (containerDocument: Document, onCancel: undefined|OnCancelListener) => {
const [dialogElement, setDialogElement] = useState<HTMLDialogElement|null>(null);

const onCancelRef = useRef(onCancel);
onCancelRef.current = onCancel;

useEffect(() => {
const dialog = document.createElement('dialog');
if (!containerDocument) return () => {};

const dialog = containerDocument.createElement('dialog');
dialog.addEventListener('click', event => {
const onCancel = onCancelRef.current;
const isBackgroundClick = event.target === dialog;
Expand Down Expand Up @@ -84,13 +101,13 @@ const useDialogElement = (onCancel: undefined|OnCancelListener) => {
// Work around what seems to be an Electron bug -- if an input or contenteditable region is refocused after
// dismissing a dialog, it won't be editable.
// Note: While this addresses the issue in the note title input, it does not address the issue in the Rich Text Editor.
if (document.activeElement?.tagName === 'INPUT') {
const element = document.activeElement as HTMLElement;
if (containerDocument.activeElement?.tagName === 'INPUT') {
const element = containerDocument.activeElement as HTMLElement;
blur('Dialog', element);
focus('Dialog', element);
}
});
document.body.appendChild(dialog);
containerDocument.body.appendChild(dialog);

setDialogElement(dialog);

Expand All @@ -102,7 +119,7 @@ const useDialogElement = (onCancel: undefined|OnCancelListener) => {
}
dialog.remove();
};
}, []);
}, [containerDocument]);

return dialogElement;
};
Expand Down
4 changes: 2 additions & 2 deletions packages/app-desktop/gui/EditFolderDialog/IconSelector.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ export const IconSelector = (props: Props) => {
attrs: {
type: 'module',
},
});
}, document);

if (event.cancelled) return;

Expand All @@ -45,7 +45,7 @@ export const IconSelector = (props: Props) => {
attrs: {
type: 'module',
},
});
}, document);

if (event.cancelled) return;

Expand Down

Large diffs are not rendered by default.

14 changes: 0 additions & 14 deletions packages/app-desktop/gui/MainScreen/commands/hideModalMessage.ts

This file was deleted.

32 changes: 0 additions & 32 deletions packages/app-desktop/gui/MainScreen/commands/showModalMessage.tsx

This file was deleted.

83 changes: 54 additions & 29 deletions packages/app-desktop/gui/MenuBar.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { useEffect, useState, useRef, useCallback, useMemo } from 'react';
import { AppState } from '../app.reducer';
import InteropService from '@joplin/lib/services/interop/InteropService';
import { stateUtils } from '@joplin/lib/reducer';
import { defaultWindowId, stateUtils } from '@joplin/lib/reducer';
import CommandService from '@joplin/lib/services/CommandService';
import MenuUtils from '@joplin/lib/services/commands/MenuUtils';
import KeymapService from '@joplin/lib/services/KeymapService';
Expand All @@ -19,14 +19,19 @@ import menuCommandNames from './menuCommandNames';
import stateToWhenClauseContext from '../services/commands/stateToWhenClauseContext';
import bridge from '../services/bridge';
import checkForUpdates from '../checkForUpdates';
const { connect } = require('react-redux');
import { connect } from 'react-redux';
import { reg } from '@joplin/lib/registry';
import { ProfileConfig } from '@joplin/lib/services/profileConfig/types';
import PluginService, { PluginSettings } from '@joplin/lib/services/plugins/PluginService';
import { getListRendererById, getListRendererIds } from '@joplin/lib/services/noteList/renderers';
import useAsyncEffect from '@joplin/lib/hooks/useAsyncEffect';
import { EventName } from '@joplin/lib/eventManager';
import { ipcRenderer } from 'electron';
import NavService from '@joplin/lib/services/NavService';
import Logger from '@joplin/utils/Logger';

const logger = Logger.create('MenuBar');

const packageInfo: PackageInfo = require('../packageInfo.js');
const { clipboard } = require('electron');
const Menu = bridge().Menu;
Expand Down Expand Up @@ -150,7 +155,7 @@ interface Props {
dispatch: Function;
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
menuItemProps: any;
routeName: string;
mainScreenVisible: boolean;
selectedFolderId: string;
layoutButtonSequence: number;
['notes.sortOrder.field']: string;
Expand All @@ -173,6 +178,8 @@ interface Props {
pluginSettings: PluginSettings;
noteListRendererIds: string[];
noteListRendererId: string;
windowId: string;
secondaryWindowFocused: boolean;
showMenuBar: boolean;
}

Expand All @@ -192,11 +199,11 @@ function menuItemSetEnabled(id: string, enabled: boolean) {
menuItem.enabled = enabled;
}

const applyMenuBarVisibility = (showMenuBar: boolean) => {
const applyMenuBarVisibility = (windowId: string, showMenuBar: boolean) => {
// The menu bar cannot be hidden on macOS
if (shim.isMac()) return;

const window = bridge().window();
const window = bridge().windowById(windowId) ?? bridge().mainWindow();
window.setAutoHideMenuBar(!showMenuBar);
window.setMenuBarVisibility(showMenuBar);
};
Expand Down Expand Up @@ -402,6 +409,17 @@ function useMenu(props: Props) {

const keymapService = KeymapService.instance();

const navigateTo = (routeName: string) => {
void NavService.go(routeName);

// NavService.go opens in the main window -- switch to it to show the screen:
const isBackgroundWindow = props.windowId !== defaultWindowId;
if (isBackgroundWindow) {
logger.info('Focusing the main window');
bridge().mainWindow().show();
}
};

const quitMenuItem = {
label: _('Quit'),
accelerator: keymapService.getAccelerator('quit'),
Expand Down Expand Up @@ -515,10 +533,7 @@ function useMenu(props: Props) {
const syncStatusItem = {
label: _('Synchronisation Status'),
click: () => {
props.dispatch({
type: 'NAV_GO',
routeName: 'Status',
});
navigateTo('Status');
},
};

Expand Down Expand Up @@ -548,10 +563,7 @@ function useMenu(props: Props) {
label: _('Options'),
accelerator: keymapService.getAccelerator('config'),
click: () => {
props.dispatch({
type: 'NAV_GO',
routeName: 'Config',
});
navigateTo('Config');
},
},
separator(),
Expand All @@ -561,10 +573,7 @@ function useMenu(props: Props) {
const toolsItemsAll = [{
label: _('Note attachments...'),
click: () => {
props.dispatch({
type: 'NAV_GO',
routeName: 'Resources',
});
navigateTo('Resources');
},
}];

Expand All @@ -579,7 +588,7 @@ function useMenu(props: Props) {
if (Setting.value('featureFlag.autoUpdaterServiceEnabled')) {
ipcRenderer.send('check-for-updates');
} else {
void checkForUpdates(false, bridge().window(), { includePreReleases: Setting.value('autoUpdate.includePreReleases') });
void checkForUpdates(false, bridge().mainWindow(), { includePreReleases: Setting.value('autoUpdate.includePreReleases') });
}

}
Expand Down Expand Up @@ -619,10 +628,7 @@ function useMenu(props: Props) {
visible: !!shim.isMac(),
accelerator: shim.isMac() && keymapService.getAccelerator('config'),
click: () => {
props.dispatch({
type: 'NAV_GO',
routeName: 'Config',
});
navigateTo('Config');
},
}, {
label: _('Check for updates...'),
Expand Down Expand Up @@ -1019,7 +1025,7 @@ function useMenu(props: Props) {
rootMenus.help,
].filter(item => item !== null);

if (props.routeName !== 'Main') {
if (!props.mainScreenVisible) {
setMenu(Menu.buildFromTemplate([
{
label: _('&File'),
Expand Down Expand Up @@ -1049,7 +1055,8 @@ function useMenu(props: Props) {
};
// eslint-disable-next-line @seiyab/react-hooks/exhaustive-deps -- Old code before rule was applied
}, [
props.routeName,
props.windowId,
props.mainScreenVisible,
props.pluginMenuItems,
props.pluginMenus,
keymapLastChangeTime,
Expand Down Expand Up @@ -1099,18 +1106,36 @@ function useMenu(props: Props) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
function MenuBar(props: Props): any {
const menu = useMenu(props);
if (menu) Menu.setApplicationMenu(menu);
applyMenuBarVisibility(props.showMenuBar);

useEffect(() => {
// Currently, this sets the menu for all windows. Although it's possible to set the menu
// for individual windows with BrowserWindow.setMenu, it causes issues with updating the
// state of existing menu items (and doesn't work with MacOS/Playwright).
if (menu) {
Menu.setApplicationMenu(menu);
}
}, [menu]);

useEffect(() => {
applyMenuBarVisibility(props.windowId, props.showMenuBar);
}, [props.showMenuBar, props.windowId]);

return null;
}

const mapStateToProps = (state: AppState) => {

const mapStateToProps = (state: AppState): Partial<Props> => {
const whenClauseContext = stateToWhenClauseContext(state);

const secondaryWindowFocused = state.windowId !== defaultWindowId;

return {
windowId: state.windowId,
menuItemProps: menuUtils.commandsToMenuItemProps(commandNames.concat(getPluginCommandNames(state.pluginService.plugins)), whenClauseContext),
locale: state.settings.locale,
routeName: state.route.routeName,
// Secondary windows can only show the main screen
mainScreenVisible: state.route.routeName === 'Main' || secondaryWindowFocused,

selectedFolderId: state.selectedFolderId,
layoutButtonSequence: state.settings.layoutButtonSequence,
['notes.sortOrder.field']: state.settings['notes.sortOrder.field'],
Expand All @@ -1126,7 +1151,7 @@ const mapStateToProps = (state: AppState) => {
['spellChecker.languages']: state.settings['spellChecker.languages'],
['spellChecker.enabled']: state.settings['spellChecker.enabled'],
plugins: state.pluginService.plugins,
customCss: state.customCss,
customCss: state.customViewerCss,
profileConfig: state.profileConfig,
noteListRendererIds: state.noteListRendererIds,
noteListRendererId: state.settings['notes.listRendererId'],
Expand Down
4 changes: 2 additions & 2 deletions packages/app-desktop/gui/MultiNoteActions.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import { Dispatch } from 'redux';
import { ThemeStyle } from '@joplin/lib/theme';

import { buildStyle } from '@joplin/lib/theme';
const bridge = require('@electron/remote').require('./bridge').default;
import bridge from '../services/bridge';

interface MultiNoteActionsProps {
themeId: number;
Expand Down Expand Up @@ -46,7 +46,7 @@ export default function MultiNoteActions(props: MultiNoteActionsProps) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
const multiNotesButton_click = (item: any) => {
if (item.submenu) {
item.submenu.popup({ window: bridge().window() });
item.submenu.popup({ window: bridge().activeWindow() });
} else {
item.click();
}
Expand Down
86 changes: 49 additions & 37 deletions packages/app-desktop/gui/Navigator.tsx
Original file line number Diff line number Diff line change
@@ -1,55 +1,67 @@
const React = require('react');
import * as React from 'react';
const { connect } = require('react-redux');
import Setting from '@joplin/lib/models/Setting';
import { AppState } from '../app.reducer';
const bridge = require('@electron/remote').require('./bridge').default;
import { AppState, AppStateRoute } from '../app.reducer';
import bridge from '../services/bridge';
import { useContext, useEffect, useRef } from 'react';
import { WindowIdContext } from './NewWindowOrIFrame';

interface Props {
route: AppStateRoute;
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
route: any;
screens: Record<string, any>;

style: React.CSSProperties;
className?: string;
}

class NavigatorComponent extends React.Component<Props> {
public UNSAFE_componentWillReceiveProps(newProps: Props) {
if (newProps.route) {
const screenInfo = this.props.screens[newProps.route.routeName];
const NavigatorComponent: React.FC<Props> = props => {
const windowId = useContext(WindowIdContext);

const route = props.route;
const screenInfo = props.screens[route?.routeName];

const screensRef = useRef(props.screens);
screensRef.current = props.screens;

const prevRoute = useRef<AppStateRoute|null>(null);
useEffect(() => {
const routeName = route?.routeName;
if (route) {
const devMarker = Setting.value('env') === 'dev' ? ` (DEV - ${Setting.value('profileDir')})` : '';
const windowTitle = [`Joplin${devMarker}`];
if (screenInfo.title) {
windowTitle.push(screenInfo.title());
}
this.updateWindowTitle(windowTitle.join(' - '));
bridge().windowById(windowId)?.setTitle(windowTitle.join(' - '));
}
}

public updateWindowTitle(title: string) {
try {
if (bridge().window()) bridge().window().setTitle(title);
} catch (error) {
console.warn('updateWindowTitle', error);
// When a navigation happens in an unfocused window, show the window to the user.
// This might happen if, for example, a secondary window triggers a navigation in
// the main window.
if (routeName && routeName !== prevRoute.current?.routeName) {
bridge().switchToWindow(windowId);
}
}

public render() {
if (!this.props.route) throw new Error('Route must not be null');

const route = this.props.route;
const screenProps = route.props ? route.props : {};
const screenInfo = this.props.screens[route.routeName];
const Screen = screenInfo.screen;

const screenStyle = {
width: this.props.style.width,
height: this.props.style.height,
};

return (
<div style={this.props.style} className={this.props.className}>
<Screen style={screenStyle} {...screenProps} />
</div>
);
}
}

prevRoute.current = route;
}, [route, screenInfo, windowId]);

if (!route) throw new Error('Route must not be null');

const screenProps = route.props ? route.props : {};
const Screen = screenInfo.screen;

const screenStyle = {
width: props.style.width,
height: props.style.height,
};

return (
<div style={props.style} className={props.className}>
<Screen style={screenStyle} {...screenProps} />
</div>
);
};

const Navigator = connect((state: AppState) => {
return {
Expand Down
172 changes: 172 additions & 0 deletions packages/app-desktop/gui/NewWindowOrIFrame.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,172 @@
import { defaultWindowId } from '@joplin/lib/reducer';
import shim from '@joplin/lib/shim';
import * as React from 'react';
import { useState, useEffect, useRef, createContext } from 'react';
import { createPortal } from 'react-dom';
import { SecondaryWindowApi } from '../utils/window/types';

// This component uses react-dom's Portals to render its children in a different HTML
// document. As children are rendered in a different Window/Document, they should avoid
// referencing the `window` and `document` globals. Instead, HTMLElement.ownerDocument
// and refs can be used to access the child component's DOM.

export const WindowIdContext = createContext(defaultWindowId);

type OnCloseCallback = ()=> void;
type OnFocusCallback = ()=> void;

export enum WindowMode {
Iframe, NewWindow,
}

interface Props {
// Note: children will be rendered in a different DOM from this node. Avoid using document.* methods
// in child components.
children: React.ReactNode[]|React.ReactNode;
title: string;
mode: WindowMode;
windowId: string;
onClose: OnCloseCallback;
onFocus?: OnFocusCallback;
}

const useDocument = (
mode: WindowMode,
iframeElement: HTMLIFrameElement|null,
onClose: OnCloseCallback,
) => {
const [doc, setDoc] = useState<Document>(null);

const onCloseRef = useRef(onClose);
onCloseRef.current = onClose;

useEffect(() => {
let openedWindow: Window|null = null;
const unmounted = false;
if (iframeElement) {
setDoc(iframeElement?.contentWindow?.document);
} else if (mode === WindowMode.NewWindow) {
openedWindow = window.open('about:blank');
setDoc(openedWindow.document);

// .onbeforeunload and .onclose events don't seem to fire when closed by a user -- rely on polling
// instead:
void (async () => {
while (!unmounted) {
await new Promise<void>(resolve => {
shim.setTimeout(() => resolve(), 2000);
});

if (openedWindow?.closed) {
onCloseRef.current?.();
openedWindow = null;
break;
}
}
})();
}

return () => {
// Delay: Closing immediately causes Electron to crash
setTimeout(() => {
if (!openedWindow?.closed) {
openedWindow?.close();
onCloseRef.current?.();
openedWindow = null;
}
}, 200);

if (iframeElement && !openedWindow) {
onCloseRef.current?.();
}
};
}, [iframeElement, mode]);

return doc;
};

type OnSetLoaded = (loaded: boolean)=> void;
const useDocumentSetup = (doc: Document|null, setLoaded: OnSetLoaded, onFocus?: OnFocusCallback) => {
const onFocusRef = useRef(onFocus);
onFocusRef.current = onFocus;

useEffect(() => {
if (!doc) return;

doc.open();
doc.write('<!DOCTYPE html><html><head></head><body></body></html>');
doc.close();

const cssUrls = [
'style.min.css',
];

for (const url of cssUrls) {
const style = doc.createElement('link');
style.rel = 'stylesheet';
style.href = url;
doc.head.appendChild(style);
}

const jsUrls = [
'vendor/lib/smalltalk/dist/smalltalk.min.js',
'./utils/window/eventHandlerOverrides.js',
];
for (const url of jsUrls) {
const script = doc.createElement('script');
script.src = url;
doc.head.appendChild(script);
}

doc.body.style.height = '100vh';

const containerWindow = doc.defaultView;
containerWindow.addEventListener('focus', () => {
onFocusRef.current?.();
});
if (doc.hasFocus()) {
onFocusRef.current?.();
}

setLoaded(true);
}, [doc, setLoaded]);
};

const NewWindowOrIFrame: React.FC<Props> = props => {
const [iframeRef, setIframeRef] = useState<HTMLIFrameElement|null>(null);
const [loaded, setLoaded] = useState(false);

const doc = useDocument(props.mode, iframeRef, props.onClose);
useDocumentSetup(doc, setLoaded, props.onFocus);

useEffect(() => {
if (!doc) return;
doc.title = props.title;
}, [doc, props.title]);

useEffect(() => {
const win = doc?.defaultView;
if (win && 'electronWindow' in win && typeof win.electronWindow === 'object') {
const electronWindow = win.electronWindow as SecondaryWindowApi;
electronWindow.onSetWindowId(props.windowId);
}
}, [doc, props.windowId]);

const parentNode = loaded ? doc?.body : null;
const wrappedChildren = <WindowIdContext.Provider value={props.windowId}>{props.children}</WindowIdContext.Provider>;

// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Needed to allow adding the portal to the DOM
const contentPortal = parentNode && createPortal(wrappedChildren, parentNode) as any;
if (props.mode === WindowMode.NewWindow) {
return <div style={{ display: 'none' }}>{contentPortal}</div>;
} else {
return <iframe
ref={setIframeRef}
style={{ flexGrow: 1, width: '100%', height: '100%', border: 'none' }}
>
{contentPortal}
</iframe>;
}
};

export default NewWindowOrIFrame;
125 changes: 125 additions & 0 deletions packages/app-desktop/gui/NoteEditor/EditorWindow.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
import * as React from 'react';
import { useCallback, useMemo, useRef, useState } from 'react';
import NoteEditor from './NoteEditor';
import StyleSheetContainer from '../StyleSheets/StyleSheetContainer';
import { connect } from 'react-redux';
import { AppState } from '../../app.reducer';
import { Dispatch } from 'redux';
import NewWindowOrIFrame, { WindowMode } from '../NewWindowOrIFrame';
import WindowCommandsAndDialogs from '../WindowCommandsAndDialogs/WindowCommandsAndDialogs';

const { StyleSheetManager } = require('styled-components');
// Note: Transitive dependencies used only by react-select. Remove if react-select is removed.
import createCache from '@emotion/cache';
import { CacheProvider } from '@emotion/react';
import { stateUtils } from '@joplin/lib/reducer';

interface Props {
dispatch: Dispatch;
themeId: number;

newWindow: boolean;
windowId: string;
activeWindowId: string;
}

const emptyCallback = () => {};
const useWindowTitle = (isNewWindow: boolean) => {
const [title, setTitle] = useState('Untitled');

if (!isNewWindow) {
return {
windowTitle: 'Editor',
onNoteTitleChange: emptyCallback,
};
}

return { windowTitle: `Joplin - ${title}`, onNoteTitleChange: setTitle };
};

const SecondaryWindow: React.FC<Props> = props => {
const containerRef = useRef<HTMLDivElement>(null);

const { windowTitle, onNoteTitleChange } = useWindowTitle(props.newWindow);
const editor = <div className='note-editor-wrapper' ref={containerRef}>
<NoteEditor
windowId={props.windowId}
onTitleChange={onNoteTitleChange}
/>
</div>;

const newWindow = props.newWindow;
const onWindowClose = useCallback(() => {
if (newWindow) {
props.dispatch({ type: 'WINDOW_CLOSE', windowId: props.windowId });
}
}, [props.dispatch, props.windowId, newWindow]);

const onWindowFocus = useCallback(() => {
// Verify that the window still has focus (e.g. to handle the case where the event was delayed).
if (containerRef.current?.ownerDocument.hasFocus()) {
props.dispatch({
type: 'WINDOW_FOCUS',
windowId: props.windowId,
lastWindowId: props.activeWindowId,
});
}
}, [props.dispatch, props.windowId, props.activeWindowId]);

return <NewWindowOrIFrame
mode={newWindow ? WindowMode.NewWindow : WindowMode.Iframe}
windowId={props.windowId}
onClose={onWindowClose}
onFocus={onWindowFocus}
title={windowTitle}
>
<LibraryStyleRoot>
<WindowCommandsAndDialogs windowId={props.windowId} />
{editor}
</LibraryStyleRoot>
<StyleSheetContainer />
</NewWindowOrIFrame>;
};

interface StyleProviderProps {
children: React.ReactNode[]|React.ReactNode;
}

// Sets the root style container for libraries. At present, this is needed by react-select (which uses @emotion/...)
// and styled-components.
// See: https://github.com/JedWatson/react-select/issues/3680 and https://github.com/styled-components/styled-components/issues/659
const LibraryStyleRoot: React.FC<StyleProviderProps> = props => {
const [dependencyStyleContainer, setDependencyStyleContainer] = useState<HTMLDivElement|null>(null);
const cache = useMemo(() => {
return createCache({
key: 'new-window-cache',
container: dependencyStyleContainer,
});
}, [dependencyStyleContainer]);

return <>
<div ref={setDependencyStyleContainer}></div>
<StyleSheetManager target={dependencyStyleContainer}>
<CacheProvider value={cache}>
{props.children}
</CacheProvider>
</StyleSheetManager>
</>;
};

interface ConnectProps {
windowId: string;
}

export default connect((state: AppState, ownProps: ConnectProps) => {
// May be undefined if the window hasn't opened
const windowState = stateUtils.windowStateById(state, ownProps.windowId);

return {
themeId: state.settings.theme,
isSafeMode: state.settings.isSafeMode,
codeView: windowState?.editorCodeView ?? state.settings['editor.codeView'],
legacyMarkdown: state.settings['editor.legacyMarkdown'],
activeWindowId: stateUtils.activeWindowId(state),
};
})(SecondaryWindow);
Original file line number Diff line number Diff line change
Expand Up @@ -40,8 +40,12 @@ function Toolbar(props: ToolbarProps) {
);
}

const mapStateToProps = (state: AppState) => {
const whenClauseContext = stateToWhenClauseContext(state);
interface ConnectProps {
windowId: string;
}

const mapStateToProps = (state: AppState, connectProps: ConnectProps) => {
const whenClauseContext = stateToWhenClauseContext(state, { windowId: connectProps.windowId });

const commandNames = [
'historyBackward',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ interface ContextMenuProps {
editorPaste: ()=> void;
editorRef: RefObject<CodeMirrorControl>;
editorClassName: string;
containerRef: RefObject<HTMLDivElement|null>;
}

const useContextMenu = (props: ContextMenuProps) => {
Expand All @@ -51,12 +52,13 @@ const useContextMenu = (props: ContextMenuProps) => {

function pointerInsideEditor(params: ContextMenuParams) {
const x = params.x, y = params.y, isEditable = params.isEditable;
const elements = document.getElementsByClassName(props.editorClassName);
const containerDoc = props.containerRef.current?.ownerDocument;
const elements = containerDoc?.getElementsByClassName(props.editorClassName);

// Note: We can't check inputFieldType here. When spellcheck is enabled,
// params.inputFieldType is "none". When spellcheck is disabled,
// params.inputFieldType is "plainText". Thus, such a check would be inconsistent.
if (!elements.length || !isEditable) return false;
if (!elements?.length || !isEditable) return false;

// Checks whether the element the pointer clicked on is inside the editor.
// This logic will need to be changed if the editor is eventually wrapped
Expand All @@ -65,7 +67,7 @@ const useContextMenu = (props: ContextMenuProps) => {
const zoom = Setting.value('windowContentZoomFactor');
const xScreen = convertFromScreenCoordinates(zoom, x);
const yScreen = convertFromScreenCoordinates(zoom, y);
const intersectingElement = document.elementFromPoint(xScreen, yScreen);
const intersectingElement = containerDoc.elementFromPoint(xScreen, yScreen);
return intersectingElement && isAncestorOfCodeMirrorEditor(intersectingElement);
}

Expand Down Expand Up @@ -150,18 +152,21 @@ const useContextMenu = (props: ContextMenuProps) => {
menu.append(new MenuItem(item));
});

menu.popup();
menu.popup({ window: bridge().activeWindow() });
}

// Prepend the event listener so that it gets called before
// the listener that shows the default menu.
bridge().window().webContents.prependListener('context-menu', onContextMenu);
const targetWindow = bridge().activeWindow();
targetWindow.webContents.prependListener('context-menu', onContextMenu);

return () => {
bridge().window().webContents.off('context-menu', onContextMenu);
if (!targetWindow.isDestroyed()) {
targetWindow.webContents.off('context-menu', onContextMenu);
}
};
}, [
props.plugins, props.editorClassName, editorRef,
props.plugins, props.editorClassName, editorRef, props.containerRef,
props.editorCutText, props.editorCopyText, props.editorPaste,
]);
};
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import * as React from 'react';
import { useState, useEffect, useRef, forwardRef, useCallback, useImperativeHandle, useMemo, ForwardedRef } from 'react';
import { useState, useEffect, useRef, forwardRef, useCallback, useImperativeHandle, useMemo, ForwardedRef, useContext } from 'react';

// eslint-disable-next-line no-unused-vars
import { EditorCommand, MarkupToHtmlOptions, NoteBodyEditorProps, NoteBodyEditorRef } from '../../../utils/types';
Expand Down Expand Up @@ -33,6 +33,7 @@ import useContextMenu from '../utils/useContextMenu';
import useWebviewIpcMessage from '../utils/useWebviewIpcMessage';
import useEditorSearchHandler from '../utils/useEditorSearchHandler';
import { focus } from '@joplin/lib/utils/focusHandler';
import { WindowIdContext } from '../../../../NewWindowOrIFrame';

function markupRenderOptions(override: MarkupToHtmlOptions = null): MarkupToHtmlOptions {
return { ...override };
Expand Down Expand Up @@ -728,6 +729,7 @@ function CodeMirror(props: NoteBodyEditorProps, ref: ForwardedRef<NoteBodyEditor
editorCutText, editorCopyText, editorPaste,
editorRef,
editorClassName: 'codeMirrorEditor',
containerRef: rootRef,
});

function renderEditor() {
Expand Down Expand Up @@ -773,11 +775,13 @@ function CodeMirror(props: NoteBodyEditorProps, ref: ForwardedRef<NoteBodyEditor
);
}

const windowId = useContext(WindowIdContext);

return (
<ErrorBoundary message="The text editor encountered a fatal error and could not continue. The error might be due to a plugin, so please try to disable some of them and try again.">
<div style={styles.root} ref={rootRef}>
<div style={styles.rowToolbar}>
<Toolbar themeId={props.themeId}/>
<Toolbar themeId={props.themeId} windowId={windowId}/>
{props.noteToolbar}
</div>
<div style={styles.rowEditorViewer}>
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import * as React from 'react';
import { useState, useEffect, useRef, forwardRef, useCallback, useImperativeHandle, useMemo, ForwardedRef } from 'react';
import { useState, useEffect, useRef, forwardRef, useCallback, useImperativeHandle, useMemo, ForwardedRef, useContext } from 'react';

import { EditorCommand, MarkupToHtmlOptions, NoteBodyEditorProps, NoteBodyEditorRef, OnChangeEvent } from '../../../utils/types';
import { getResourcesFromPasteEvent } from '../../../utils/resourceHandling';
Expand Down Expand Up @@ -29,6 +29,7 @@ import Toolbar from '../Toolbar';
import useEditorSearchHandler from '../utils/useEditorSearchHandler';
import CommandService from '@joplin/lib/services/CommandService';
import useRefocusOnVisiblePaneChange from './utils/useRefocusOnVisiblePaneChange';
import { WindowIdContext } from '../../../../NewWindowOrIFrame';

const logger = Logger.create('CodeMirror6');
const logDebug = (message: string) => logger.debug(message);
Expand Down Expand Up @@ -336,6 +337,7 @@ const CodeMirror = (props: NoteBodyEditorProps, ref: ForwardedRef<NoteBodyEditor
editorCutText, editorCopyText, editorPaste,
editorRef,
editorClassName: 'cm-editor',
containerRef: rootRef,
});

const lastSearchState = useRef<SearchState|null>(null);
Expand Down Expand Up @@ -437,11 +439,13 @@ const CodeMirror = (props: NoteBodyEditorProps, ref: ForwardedRef<NoteBodyEditor
);
};

const windowId = useContext(WindowIdContext);

return (
<ErrorBoundary message="The text editor encountered a fatal error and could not continue. The error might be due to a plugin, so please try to disable some of them and try again.">
<div style={styles.root} ref={rootRef}>
<div style={styles.rowToolbar}>
<Toolbar themeId={props.themeId}/>
<Toolbar themeId={props.themeId} windowId={windowId}/>
{props.noteToolbar}
</div>
<div style={styles.rowEditorViewer}>
Expand Down
Original file line number Diff line number Diff line change
@@ -1,18 +1,20 @@
import { RefObject, useRef, useEffect } from 'react';
import { focus } from '@joplin/lib/utils/focusHandler';
import CodeMirrorControl from '@joplin/editor/CodeMirror/CodeMirrorControl';
import NoteTextViewer from '../../../../../NoteTextViewer';
import { NoteViewerControl } from '../../../../../NoteTextViewer';

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

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

const lastHadViewer = lastVisiblePanes.current.includes('viewer');
Expand Down
43 changes: 26 additions & 17 deletions packages/app-desktop/gui/NoteEditor/NoteBody/TinyMCE/TinyMCE.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import * as React from 'react';
import { useState, useEffect, useCallback, useRef, forwardRef, useImperativeHandle } from 'react';
import { useState, useEffect, useCallback, useRef, forwardRef, useImperativeHandle, useMemo } from 'react';
import { ScrollOptions, ScrollOptionTypes, EditorCommand, NoteBodyEditorProps, ResourceInfos, HtmlToMarkdownHandler } from '../../utils/types';
import { resourcesStatus, commandAttachFileToBody, getResourcesFromPasteEvent, processPastedHtml } from '../../utils/resourceHandling';
import attachedResources from '@joplin/lib/utils/attachedResources';
Expand Down Expand Up @@ -41,6 +41,7 @@ const supportedLocales = require('./supportedLocales');
import { hasProtocol } from '@joplin/utils/url';
import useTabIndenter from './utils/useTabIndenter';
import useKeyboardRefocusHandler from './utils/useKeyboardRefocusHandler';
import useDocument from '../../../hooks/useDocument';

const logger = Logger.create('TinyMCE');

Expand Down Expand Up @@ -99,6 +100,8 @@ let changeId_ = 1;

// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
const TinyMCE = (props: NoteBodyEditorProps, ref: any) => {
const [editorContainer, setEditorContainer] = useState<HTMLDivElement|null>(null);
const editorContainerDom = useDocument(editorContainer);
const [editor, setEditor] = useState<Editor|null>(null);
const [scriptLoaded, setScriptLoaded] = useState(false);
const [editorReady, setEditorReady] = useState(false);
Expand All @@ -119,9 +122,7 @@ const TinyMCE = (props: NoteBodyEditorProps, ref: any) => {
contentKey: null,
});

const rootIdRef = useRef<string>(`tinymce-${Date.now()}${Math.round(Math.random() * 10000)}`);
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
const editorRef = useRef<any>(null);
const editorRef = useRef<Editor>(null);
editorRef.current = editor;

const styles = styles_(props);
Expand Down Expand Up @@ -333,6 +334,8 @@ const TinyMCE = (props: NoteBodyEditorProps, ref: any) => {
// };

useEffect(() => {
if (!editorContainerDom) return () => {};

let cancelled = false;

async function loadScripts() {
Expand All @@ -351,15 +354,15 @@ const TinyMCE = (props: NoteBodyEditorProps, ref: any) => {
];

for (const s of scriptsToLoad) {
if (document.getElementById(s.id)) {
if (editorContainerDom.getElementById(s.id)) {
s.loaded = true;
continue;
}

// eslint-disable-next-line no-console
console.info('Loading script', s.src);

await loadScript(s);
await loadScript(s, editorContainerDom);
if (cancelled) return;

s.loaded = true;
Expand All @@ -373,19 +376,20 @@ const TinyMCE = (props: NoteBodyEditorProps, ref: any) => {
return () => {
cancelled = true;
};
}, []);
}, [editorContainerDom]);

useWebViewApi(editor);
useWebViewApi(editor, editorContainerDom?.defaultView);
const { resetModifiedTitles: resetLinkTooltips } = useLinkTooltips(editor);

useEffect(() => {
if (!editorContainerDom) return () => {};
const theme = themeStyle(props.themeId);
const backgroundColor = props.whiteBackgroundNoteRendering ? lightTheme.backgroundColor : theme.backgroundColor;

const element = document.createElement('style');
const element = editorContainerDom.createElement('style');
element.setAttribute('id', 'tinyMceStyle');
document.head.appendChild(element);
element.appendChild(document.createTextNode(`
editorContainerDom.head.appendChild(element);
element.appendChild(editorContainerDom.createTextNode(`
.joplin-tinymce .tox-editor-header {
padding-left: ${styles.leftExtraToolbarContainer.width + styles.leftExtraToolbarContainer.padding * 2}px;
padding-right: ${styles.rightExtraToolbarContainer.width + styles.rightExtraToolbarContainer.padding * 2}px;
Expand Down Expand Up @@ -582,7 +586,7 @@ const TinyMCE = (props: NoteBodyEditorProps, ref: any) => {
`));

return () => {
document.head.removeChild(element);
editorContainerDom.head.removeChild(element);
};
// editorReady is here because TinyMCE starts by initializing a blank iframe, which needs to be
// styled by us, otherwise users in dark mode get a bright white flash. During initialization
Expand All @@ -594,7 +598,7 @@ const TinyMCE = (props: NoteBodyEditorProps, ref: any) => {
//
// tl;dr: editorReady is used here because the css needs to be re-applied after TinyMCE init
// eslint-disable-next-line @seiyab/react-hooks/exhaustive-deps -- Old code before rule was applied
}, [editorReady, props.themeId, lightTheme, props.whiteBackgroundNoteRendering, props.watchedNoteFiles]);
}, [editorReady, editorContainerDom, props.themeId, lightTheme, props.whiteBackgroundNoteRendering, props.watchedNoteFiles]);

// -----------------------------------------------------------------------------------------
// Enable or disable the editor
Expand All @@ -611,6 +615,7 @@ const TinyMCE = (props: NoteBodyEditorProps, ref: any) => {

useEffect(() => {
if (!scriptLoaded) return;
if (!editorContainer) return;

const loadEditor = async () => {
const language = closestSupportedLocale(props.locale, true, supportedLocales);
Expand Down Expand Up @@ -645,8 +650,9 @@ const TinyMCE = (props: NoteBodyEditorProps, ref: any) => {
];

// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
const editors = await (window as any).tinymce.init({
selector: `#${rootIdRef.current}`,
const containerWindow = editorContainerDom.defaultView as any;
const editors = await containerWindow.tinymce.init({
selector: `#${editorContainer.id}`,
width: '100%',
body_class: 'jop-tinymce',
height: '100%',
Expand Down Expand Up @@ -831,7 +837,7 @@ const TinyMCE = (props: NoteBodyEditorProps, ref: any) => {

void loadEditor();
// eslint-disable-next-line @seiyab/react-hooks/exhaustive-deps -- Old code before rule was applied
}, [scriptLoaded]);
}, [scriptLoaded, editorContainer]);

// -----------------------------------------------------------------------------------------
// Set the initial content and load the plugin CSS and JS files
Expand Down Expand Up @@ -1421,12 +1427,15 @@ const TinyMCE = (props: NoteBodyEditorProps, ref: any) => {
);
}

const containerId = useMemo(() => {
return `tinymce-container-${Math.ceil(Math.random() * 1000)}-${Date.now()}`;
}, []);
return (
<div style={styles.rootStyle} className="joplin-tinymce">
{renderDisabledOverlay()}
{renderLeftExtraToolbarButtons()}
{renderRightExtraToolbarButtons()}
<div style={{ width: '100%', height: '100%' }} id={rootIdRef.current}/>
<div style={{ width: '100%', height: '100%' }} id={containerId} ref={setEditorContainer}/>
</div>
);
};
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,21 +8,25 @@ import { menuItems } from '../../../utils/contextMenu';
import MenuUtils from '@joplin/lib/services/commands/MenuUtils';
import CommandService from '@joplin/lib/services/CommandService';
import Setting from '@joplin/lib/models/Setting';
import type { Event as ElectronEvent } from 'electron';

import Resource from '@joplin/lib/models/Resource';
import { TinyMceEditorEvents } from './types';
import { HtmlToMarkdownHandler, MarkupToHtmlHandler } from '../../../utils/types';
import { Editor } from 'tinymce';

const Menu = bridge().Menu;
const MenuItem = bridge().MenuItem;
const menuUtils = new MenuUtils(CommandService.instance());

// x and y are the absolute coordinates, as returned by the context-menu event
// handler on the webContent. This function will return null if the point is
// not within the TinyMCE editor.
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
function contextMenuElement(editor: any, x: number, y: number) {
function contextMenuElement(editor: Editor, x: number, y: number) {
if (!editor || !editor.getDoc()) return null;

const iframes = document.getElementsByClassName('tox-edit-area__iframe');
const containerDoc = editor.getContainer().ownerDocument;
const iframes = containerDoc.getElementsByClassName('tox-edit-area__iframe');
if (!iframes.length) return null;

const zoom = Setting.value('windowContentZoomFactor') / 100;
Expand All @@ -31,7 +35,7 @@ function contextMenuElement(editor: any, x: number, y: number) {

// We use .elementFromPoint to handle the case where a dialog is covering
// part of the editor.
const targetElement = document.elementFromPoint(xScreen, yScreen);
const targetElement = containerDoc.elementFromPoint(xScreen, yScreen);
if (targetElement !== iframes[0]) {
return null;
}
Expand All @@ -49,26 +53,31 @@ interface ContextMenuActionOptions {
const contextMenuActionOptions: ContextMenuActionOptions = { current: null };

// eslint-disable-next-line @typescript-eslint/ban-types, @typescript-eslint/no-explicit-any -- Old code before rule was applied, Old code before rule was applied
export default function(editor: any, plugins: PluginStates, dispatch: Function, htmlToMd: HtmlToMarkdownHandler, mdToHtml: MarkupToHtmlHandler) {
export default function(editor: Editor, plugins: PluginStates, dispatch: Function, htmlToMd: HtmlToMarkdownHandler, mdToHtml: MarkupToHtmlHandler) {
useEffect(() => {
if (!editor) return () => {};

const contextMenuItems = menuItems(dispatch, htmlToMd, mdToHtml);
const targetWindow = bridge().activeWindow();

// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
function onContextMenu(_event: any, params: any) {
function onContextMenu(event: ElectronEvent, params: any) {
const element = contextMenuElement(editor, params.x, params.y);
if (!element) return;

event.preventDefault();

const menu = new Menu();

let itemType: ContextMenuItemType = ContextMenuItemType.None;
let resourceId = '';
let linkToCopy = null;

if (element.nodeName === 'IMG') {
itemType = ContextMenuItemType.Image;
resourceId = Resource.pathToId(element.src);
resourceId = Resource.pathToId((element as HTMLImageElement).src);
} else if (element.nodeName === 'A') {
resourceId = Resource.pathToId(element.href);
resourceId = Resource.pathToId((element as HTMLAnchorElement).href);
itemType = resourceId ? ContextMenuItemType.Resource : ContextMenuItemType.Link;
linkToCopy = element.getAttribute('href') || '';
} else {
Expand All @@ -94,38 +103,37 @@ export default function(editor: any, plugins: PluginStates, dispatch: Function,
mdToHtml,
};

let template = [];

for (const itemName in contextMenuItems) {
const item = contextMenuItems[itemName];

if (!item.isActive(itemType, contextMenuActionOptions.current)) continue;

template.push({
menu.append(new MenuItem({
label: item.label,
click: () => {
item.onAction(contextMenuActionOptions.current);
},
});
}));
}

const spellCheckerMenuItems = SpellCheckerService.instance().contextMenuItems(params.misspelledWord, params.dictionarySuggestions);

for (const item of spellCheckerMenuItems) {
template.push(item);
menu.append(new MenuItem(item));
}

template = template.concat(menuUtils.pluginContextMenuItems(plugins, MenuItemLocation.EditorContextMenu));
for (const item of menuUtils.pluginContextMenuItems(plugins, MenuItemLocation.EditorContextMenu)) {
menu.append(new MenuItem(item));
}

const menu = bridge().Menu.buildFromTemplate(template);
menu.popup({ window: bridge().window() });
menu.popup({ window: targetWindow });
}

bridge().window().webContents.on('context-menu', onContextMenu);
targetWindow.webContents.prependListener('context-menu', onContextMenu);

return () => {
if (bridge().window()?.webContents?.off) {
bridge().window().webContents.off('context-menu', onContextMenu);
if (!targetWindow.isDestroyed() && targetWindow?.webContents?.off) {
targetWindow.webContents.off('context-menu', onContextMenu);
}
};
}, [editor, plugins, dispatch, htmlToMd, mdToHtml]);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,14 @@ import PluginService from '@joplin/lib/services/plugins/PluginService';
import { useEffect } from 'react';
import { Editor } from 'tinymce';

const useWebViewApi = (editor: Editor) => {
const useWebViewApi = (editor: Editor, window: Window) => {
useEffect(() => {
if (!editor) return ()=>{};
if (!window) return ()=>{};

const scriptElement = document.createElement('script');
const scriptElement = window.document.createElement('script');
const channelId = `plugin-post-message-${Math.random()}`;
scriptElement.appendChild(document.createTextNode(`
scriptElement.appendChild(window.document.createTextNode(`
window.webviewApi = {
postMessage: (contentScriptId, message) => {
const channelId = ${JSON.stringify(channelId)};
Expand Down Expand Up @@ -66,7 +67,7 @@ const useWebViewApi = (editor: Editor) => {
window.removeEventListener('message', onMessageHandler);
scriptElement.remove();
};
}, [editor]);
}, [editor, window]);
};

export default useWebViewApi;
91 changes: 61 additions & 30 deletions packages/app-desktop/gui/NoteEditor/NoteEditor.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import * as React from 'react';
import { useState, useEffect, useCallback, useRef, useMemo } from 'react';
import { useState, useEffect, useCallback, useRef, useMemo, useContext } from 'react';
import TinyMCE from './NoteBody/TinyMCE/TinyMCE';
import { connect } from 'react-redux';
import MultiNoteActions from '../MultiNoteActions';
Expand Down Expand Up @@ -51,6 +51,8 @@ import { MarkupLanguage } from '@joplin/renderer';
import useScrollWhenReadyOptions from './utils/useScrollWhenReadyOptions';
import useScheduleSaveCallbacks from './utils/useScheduleSaveCallbacks';
import WarningBanner from './WarningBanner/WarningBanner';
import { stateUtils } from '@joplin/lib/reducer';
import { WindowIdContext } from '../NewWindowOrIFrame';
const debounce = require('debounce');

const commands = [
Expand All @@ -59,7 +61,10 @@ const commands = [

const toolbarButtonUtils = new ToolbarButtonUtils(CommandService.instance());

function NoteEditor(props: NoteEditorProps) {
const onDragOver: React.DragEventHandler = event => event.preventDefault();
let editorIdCounter = 0;

function NoteEditorContent(props: NoteEditorProps) {
const [showRevisions, setShowRevisions] = useState(false);
const [titleHasBeenManuallyChanged, setTitleHasBeenManuallyChanged] = useState(false);
const [isReadOnly, setIsReadOnly] = useState<boolean>(false);
Expand All @@ -69,9 +74,14 @@ function NoteEditor(props: NoteEditorProps) {
const isMountedRef = useRef(true);
const noteSearchBarRef = useRef(null);

// Should be constant and unique to this instance of the editor.
const editorId = useMemo(() => {
return `editor-${editorIdCounter++}`;
}, []);

const setFormNoteRef = useRef<OnSetFormNote>();
const { saveNoteIfWillChange, scheduleSaveNote } = useScheduleSaveCallbacks({
setFormNote: setFormNoteRef, dispatch: props.dispatch, editorRef,
setFormNote: setFormNoteRef, dispatch: props.dispatch, editorRef, editorId,
});
const formNote_beforeLoad = useCallback(async (event: OnLoadEvent) => {
await saveNoteIfWillChange(event.formNote);
Expand All @@ -85,14 +95,13 @@ function NoteEditor(props: NoteEditorProps) {
const effectiveNoteId = useEffectiveNoteId(props);

const { formNote, setFormNote, isNewNote, resourceInfos } = useFormNote({
syncStarted: props.syncStarted,
decryptionStarted: props.decryptionStarted,
noteId: effectiveNoteId,
isProvisional: props.isProvisional,
titleInputRef: titleInputRef,
editorRef: editorRef,
onBeforeLoad: formNote_beforeLoad,
onAfterLoad: formNote_afterLoad,
editorId,
});
setFormNoteRef.current = setFormNote;
const formNoteRef = useRef<FormNote>();
Expand Down Expand Up @@ -166,6 +175,10 @@ function NoteEditor(props: NoteEditorProps) {
}, 100);
}, [props.dispatch]);

useEffect(() => {
props.onTitleChange?.(formNote.title);
}, [formNote.title, props.onTitleChange]);

// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
const onFieldChange = useCallback(async (field: string, value: any, changeId = 0) => {
if (!isMountedRef.current) {
Expand Down Expand Up @@ -225,13 +238,15 @@ function NoteEditor(props: NoteEditorProps) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
const onTitleChange = useCallback((event: any) => onFieldChange('title', event.target.value), [onFieldChange]);

const containerRef = useRef<HTMLDivElement>(null);
useWindowCommandHandler({
dispatch: props.dispatch,
setShowLocalSearch,
noteSearchBarRef,
editorRef,
titleInputRef,
onBodyChange,
containerRef,
});

// const onTitleKeydown = useCallback((event:any) => {
Expand Down Expand Up @@ -295,7 +310,8 @@ function NoteEditor(props: NoteEditorProps) {
lastEditorScrollPercents: props.lastEditorScrollPercents,
editorRef,
});
const onMessage = useMessageHandler(scrollWhenReady, clearScrollWhenReady, editorRef, setLocalSearchResultCount, props.dispatch, formNote, htmlToMarkdown, markupToHtml);
const windowId = useContext(WindowIdContext);
const onMessage = useMessageHandler(scrollWhenReady, clearScrollWhenReady, windowId, editorRef, setLocalSearchResultCount, props.dispatch, formNote, htmlToMarkdown, markupToHtml);

// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
const externalEditWatcher_noteChange = useCallback((event: any) => {
Expand Down Expand Up @@ -340,12 +356,19 @@ function NoteEditor(props: NoteEditorProps) {
useEffect(() => {
const dependencies = {
setShowRevisions,
isInFocusedDocument: () => {
return containerRef.current?.ownerDocument?.hasFocus();
},
};

CommandService.instance().componentRegisterCommands(dependencies, commands);
const registeredCommands = CommandService.instance().componentRegisterCommands(
dependencies,
commands,
true,
);

return () => {
CommandService.instance().componentUnregisterCommands(commands);
registeredCommands.deregister();
};
}, [setShowRevisions]);

Expand All @@ -366,7 +389,7 @@ function NoteEditor(props: NoteEditorProps) {
opacity: 0.1,
...rootStyle,
};
return <div style={emptyDivStyle}></div>;
return <div style={emptyDivStyle} ref={containerRef}></div>;
}

function renderTagButton() {
Expand Down Expand Up @@ -464,10 +487,11 @@ function NoteEditor(props: NoteEditorProps) {
padding: theme.margin,
verticalAlign: 'top',
boxSizing: 'border-box',
flex: 1,
};

return (
<div style={revStyle}>
<div style={revStyle} ref={containerRef}>
<NoteRevisionViewer customCss={props.customCss} noteId={formNote.id} onBack={noteRevisionViewer_onBack} />
</div>
);
Expand Down Expand Up @@ -575,7 +599,7 @@ function NoteEditor(props: NoteEditorProps) {
const theme = themeStyle(props.themeId);

return (
<div style={styles.root} onDrop={onDrop}>
<div style={styles.root} onDragOver={onDragOver} onDrop={onDrop} ref={containerRef}>
<div style={{ display: 'flex', flexDirection: 'column', height: '100%' }}>
{renderResourceWatchingNotification()}
{renderResourceInSearchResultsNotification()}
Expand Down Expand Up @@ -606,33 +630,40 @@ function NoteEditor(props: NoteEditorProps) {
);
}

export {
NoteEditor as NoteEditorComponent,
};
interface ConnectProps {
windowId: string;
}

const mapStateToProps = (state: AppState) => {
const noteId = state.selectedNoteIds.length === 1 ? state.selectedNoteIds[0] : null;
const whenClauseContext = stateToWhenClauseContext(state);
const mapStateToProps = (state: AppState, ownProps: ConnectProps) => {
const whenClauseContext = stateToWhenClauseContext(state, { windowId: ownProps.windowId });
const windowState = stateUtils.windowStateById(state, ownProps.windowId);
const noteId = stateUtils.selectedNoteId(windowState);

let bodyEditor = windowState.editorCodeView ? 'CodeMirror6' : 'TinyMCE';
if (state.settings.isSafeMode) {
bodyEditor = 'PlainText';
} else if (windowState.editorCodeView && state.settings['editor.legacyMarkdown']) {
bodyEditor = 'CodeMirror5';
}

return {
noteId: noteId,
notes: state.notes,
selectedNoteIds: state.selectedNoteIds,
selectedFolderId: state.selectedFolderId,
noteId,
bodyEditor,
isProvisional: state.provisionalNoteIds.includes(noteId),
notes: windowState.notes,
selectedNoteIds: windowState.selectedNoteIds,
selectedFolderId: windowState.selectedFolderId,
editorNoteStatuses: state.editorNoteStatuses,
syncStarted: state.syncStarted,
decryptionStarted: state.decryptionWorker?.state !== 'idle',
themeId: state.settings.theme,
watchedNoteFiles: state.watchedNoteFiles,
notesParentType: state.notesParentType,
selectedNoteTags: state.selectedNoteTags,
notesParentType: windowState.notesParentType,
selectedNoteTags: windowState.selectedNoteTags,
lastEditorScrollPercents: state.lastEditorScrollPercents,
selectedNoteHash: state.selectedNoteHash,
selectedNoteHash: windowState.selectedNoteHash,
searches: state.searches,
selectedSearchId: state.selectedSearchId,
customCss: state.customCss,
noteVisiblePanes: state.noteVisiblePanes,
selectedSearchId: windowState.selectedSearchId,
customCss: state.customViewerCss,
noteVisiblePanes: windowState.noteVisiblePanes,
watchedResources: state.watchedResources,
highlightedWords: state.highlightedWords,
plugins: state.pluginService.plugins,
Expand All @@ -654,4 +685,4 @@ const mapStateToProps = (state: AppState) => {
};
};

export default connect(mapStateToProps)(NoteEditor);
export default connect(mapStateToProps)(NoteEditorContent);
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
import * as React from 'react';
import { _ } from '@joplin/lib/locale';
import CommandService from '@joplin/lib/services/CommandService';
import { ChangeEvent, useCallback, useRef } from 'react';
import { ChangeEvent, useCallback, useContext, useRef } from 'react';
import NoteToolbar from '../../NoteToolbar/NoteToolbar';
import { buildStyle } from '@joplin/lib/theme';
import time from '@joplin/lib/time';
import { WindowIdContext } from '../../NewWindowOrIFrame';

interface Props {
themeId: number;
Expand Down Expand Up @@ -97,11 +98,14 @@ export default function NoteTitleBar(props: Props) {
return <span className="updated-time-label" style={styles.titleDate}>{time.formatMsToLocal(props.noteUserUpdatedTime)}</span>;
}

const windowId = useContext(WindowIdContext);

function renderNoteToolbar() {
return <NoteToolbar
themeId={props.themeId}
style={styles.toolbarStyle}
disabled={props.disabled}
windowId={windowId}
/>;
}

Expand Down
3 changes: 3 additions & 0 deletions packages/app-desktop/gui/NoteEditor/commands/showRevisions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,5 +10,8 @@ export const runtime = (comp: any): CommandRuntime => {
execute: async () => {
comp.setShowRevisions(true);
},
getPriority: () => {
return comp.isInFocusedDocument() ? 1 : 0;
},
};
};
1 change: 1 addition & 0 deletions packages/app-desktop/gui/NoteEditor/style.scss
Original file line number Diff line number Diff line change
Expand Up @@ -3,3 +3,4 @@
@use "./styles/warning-banner-link.scss";
@use "./styles/note-title-info-group.scss";
@use "./styles/note-title-wrapper.scss";
@use "./styles/note-editor-wrapper.scss";
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@

.note-editor-wrapper {
display: flex;
flex-grow: 1;
flex-shrink: 1;
width: 100%;
height: 100%;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import { RefObject } from 'react';

const getWindowCommandPriority = <T extends HTMLElement> (contentContainer: RefObject<T>) => {
if (!contentContainer.current) return 0;
const containerDocument = contentContainer.current.getRootNode() as Document;
if (!containerDocument || !containerDocument.hasFocus()) return 0;

if (contentContainer.current.contains(containerDocument.activeElement)) {
return 2;
}

// Container document has focus, but not this editor.
return 1;
};
export default getWindowCommandPriority;
6 changes: 3 additions & 3 deletions packages/app-desktop/gui/NoteEditor/utils/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,9 +30,6 @@ export interface NoteEditorProps {
isProvisional: boolean;
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
editorNoteStatuses: any;
syncStarted: boolean;
decryptionStarted: boolean;
bodyEditor: string;
notesParentType: string;
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
selectedNoteTags: any[];
Expand All @@ -57,6 +54,9 @@ export interface NoteEditorProps {
shareCacheSetting: string;
syncUserId: string;
searchResults: ProcessResultsRow[];

onTitleChange?: (title: string)=> void;
Copy link
Collaborator Author

Choose a reason for hiding this comment

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

This callback is used to update secondary windows' titlebars.

bodyEditor: string;
}

export interface NoteBodyEditorRef {
Expand Down
114 changes: 53 additions & 61 deletions packages/app-desktop/gui/NoteEditor/utils/useFormNote.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,14 +8,13 @@ import { join } from 'path';
import { formNoteToNote } from '.';

const defaultFormNoteProps: HookDependencies = {
syncStarted: false,
decryptionStarted: false,
noteId: '',
isProvisional: false,
titleInputRef: null,
editorRef: null,
onBeforeLoad: ()=>{},
onAfterLoad: ()=>{},
onBeforeLoad: () => { },
onAfterLoad: () => { },
editorId: 'editor',
};

describe('useFormNote', () => {
Expand All @@ -27,58 +26,57 @@ describe('useFormNote', () => {
it('should update note when decryption completes', async () => {
const testNote = await Note.save({ title: 'Test Note!' });

const makeFormNoteProps = (syncStarted: boolean, decryptionStarted: boolean): HookDependencies => {
const makeFormNoteProps = (): HookDependencies => {
return {
...defaultFormNoteProps,
syncStarted,
decryptionStarted,
noteId: testNote.id,
};
};

const formNote = renderHook(props => useFormNote(props), {
initialProps: makeFormNoteProps(true, false),
initialProps: makeFormNoteProps(),
});
await formNote.waitFor(() => {
expect(formNote.result.current.formNote).toMatchObject({
encryption_applied: 0,
title: testNote.title,
});
// id is falsy until after the first load of the form note.
expect(formNote.result.current.formNote.id).not.toBeFalsy();
});

await Note.save({
id: testNote.id,
encryption_cipher_text: 'cipher_text',
encryption_applied: 1,
expect(formNote.result.current.formNote).toMatchObject({
encryption_applied: 0,
title: testNote.title,
});

// Sync starting should cause a re-render
formNote.rerender(makeFormNoteProps(false, false));

await formNote.waitFor(() => {
expect(formNote.result.current.formNote).toMatchObject({
await act(async () => {
await Note.save({
id: testNote.id,
encryption_cipher_text: 'cipher_text',
encryption_applied: 1,
});
});

// Changing encryption_applied should cause a re-render
await act(async () => {
await formNote.waitFor(() => {
expect(formNote.result.current.formNote).toMatchObject({
encryption_applied: 1,
});
});
});

formNote.rerender(makeFormNoteProps(false, true));

await Note.save({
id: testNote.id,
encryption_applied: 0,
title: 'Test Note!',
await act(async () => {
await Note.save({
id: testNote.id,
encryption_applied: 0,
title: 'Test Note!',
});
});

// Ending decryption should also cause a re-render
formNote.rerender(makeFormNoteProps(false, false));

await formNote.waitFor(() => {
expect(formNote.result.current.formNote).toMatchObject({
encryption_applied: 0,
title: 'Test Note!',
});
});
// A larger-than-default timeout is needed to prevent CI failures:
}, { timeout: 5_000 });

formNote.unmount();
});
Expand Down Expand Up @@ -116,55 +114,49 @@ describe('useFormNote', () => {
formNote.unmount();
});

// It seems this test is crashing the worker on CI (out of memory), so disabling it for now.
it('should reload the note when it is changed outside of the editor', async () => {
const note = await Note.save({ title: 'Test Note!', body: '...' });

// it('should reload the note when it is changed outside of the editor', async () => {
// const note = await Note.save({ title: 'Test Note!' });

// const makeFormNoteProps = (dbNote: DbNote): HookDependencies => {
// return {
// ...defaultFormNoteProps,
// noteId: note.id,
// dbNote,
// };
// };
const props = {
...defaultFormNoteProps,
noteId: note.id,
};

// const formNote = renderHook(props => useFormNote(props), {
// initialProps: makeFormNoteProps({ id: note.id, updated_time: note.updated_time }),
// });
const formNote = renderHook(props => useFormNote(props), {
initialProps: props,
});

// await formNote.waitFor(() => {
// expect(formNote.result.current.formNote.title).toBe('Test Note!');
// });
await formNote.waitFor(() => {
expect(formNote.result.current.formNote.title).toBe('Test Note!');
});

// // Simulate the note being modified outside the editor
// const modifiedNote = await Note.save({ id: note.id, title: 'Modified' });
// Simulate the note being modified outside the editor
await act(async () => {
await Note.save({ id: note.id, title: 'Modified' });
});

// // NoteEditor then would update `dbNote`
// formNote.rerender(makeFormNoteProps({ id: note.id, updated_time: modifiedNote.updated_time }));
await formNote.waitFor(() => {
expect(formNote.result.current.formNote.title).toBe('Modified');
});

// await formNote.waitFor(() => {
// expect(formNote.result.current.formNote.title).toBe('Modified');
// });
// });
formNote.unmount();
});

test('should refresh resource infos when changed outside the editor', async () => {
let note = await Note.save({});
note = await shim.attachFileToNote(note, join(supportDir, 'sample.txt'));
const resourceIds = Note.linkedItemIds(note.body);
const resource = await Resource.load(resourceIds[0]);

const makeFormNoteProps = (syncStarted: boolean, decryptionStarted: boolean): HookDependencies => {
const makeFormNoteProps = (): HookDependencies => {
return {
...defaultFormNoteProps,
syncStarted,
decryptionStarted,
noteId: note.id,
};
};

const formNote = renderHook(props => useFormNote(props), {
initialProps: makeFormNoteProps(true, false),
initialProps: makeFormNoteProps(),
});

await formNote.waitFor(() => {
Expand Down
Loading