Skip to content
New issue

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

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

Already on GitHub? Sign in to your account

Desktop: Multiple window support #11181

Merged

Conversation

personalizedrefrigerator
Copy link
Collaborator

@personalizedrefrigerator personalizedrefrigerator commented Oct 5, 2024

Summary

This pull request allows opening Joplin's editor in a new window using React's portal API.

See background_windows.md for how this is handled by Joplin's reducer logic.

Known issues

Show resolved
  • Notes need to be reloaded to show updates. (Fixed)

  • Editor commands (e.g. bold with ctrl-b) are always applied to the last-created editor. (Fixed)

  • All dialogs (e.g. go to anything, note info) open in the main window. (Fixed)

  • The editor sometimes incorrectly reloads content — it seems to detect changes from recent saves as edits made by a different editor.

  • If editing in a secondary window, dialogs (e.g. note statistics) will report information for the note in the main window.

  • Commands that rely on selectedNoteId always act on the note displayed in the main window.

    • For example, clicking "Note", then "Permanently delete note" in a secondary window prompts to delete whichever note is open in the main Joplin window.
  • Commands that rely on selectedNoteId for their enabled condition can be incorrectly enabled/disabled in new windows.

  • Toolbar buttons are enabled/disabled based on the current window, rather than the window that they're in.

  • The tags list always reflects the tags associated with the currently focused window (even if the list is displayed in a background window).

  • state.notes is refreshed unnecessarily when a window gains focus and it isn't a just-created window.

  • Context menus only work in the main window.

  • All windows must have the same editor (Markdown/Rich Text).

  • All windows have the same layout.

    all-windows-same-layout.webm

  • Opening settings closes all secondary windows.

  • Drag-and-drop doesn't work between windows.

  • Plugin dialogs are shown on all windows, and (depending on the plugin) may only work correctly in one of the windows.

    • Plugin dialogs are now shown only on one window by default. However, if a different window is focused, it's possible for two plugin dialogs with the same ID to be opened. This can cause issues for certain plugins.
  • Enabling the Rich Text Editor in one of the windows disables certain commands (e.g. header, checklist) for all windows.

  • Adding a new tag in a new window causes the new window to navigate to a different note.

  • When notes are deleted and in the navigation history, they are only deleted from the navigation history for the last focused window.

  • The legacy Markdown editor is styled incorrectly (fixed size, Markdown preview hidden) in secondary windows.

  • Plugin IPC issues when multiple views exist with the same ID.

  • To allow editors to update when a note is edited in a different window, useFormNote was updated. In particular,

    • Whenever the note changes in the database, a note refresh is scheduled.
    • Whenever the note is changed by the editor, its content is added to a Set. (Possible performance issue!)
    • When a note refresh happens, the set is cleared. If the new note content was previously in the set, the refresh is cancelled (and the set is still cleared).
      • This prevents unnecessary note refreshes if the note was saved just before await Note.load resolves. In this case, Note.load may return an outdated copy of the note (and not match the last saved content).
  • Plugin API global CSS applies only to the main window.

  • When "show tray icon" is disabled, closing the main window also closes all secondary windows.

    • This might be difficult to fix. At present, all secondary windows are controlled by scripts running in the main window.
  • For IPC with plugin dialogs, some plugins assume that there is only one window.

    • Planned fix: Only allow one instance of each plugin dialog to be open at the same time; add an API to determine the current window ID.
  • Edit link dialog is only shown in the main window.

  • (Minor/Difficult to fix?) If 1) settings is open in the main window and 2) a non-settings window is open in a background window, then the non-settings window is shown with the same limited menubar as the settings window on Windows and Linux.
    • I've tried fixing this by setting different menus for each window with BrowserWindow.setMenu (which is Linux & Windows only), however:
      • This causes issues with existing code that uses Menu.getApplicationMenu to update the enabled/checked/disabled state of the app menu. There doesn't seem to be a per-window equivalent of Menu.getApplicationMenu.
      • There doesn't seem to be a way to access menus set in this way from Playwright.
      • Using both Menu.getApplicationMenu and BrowserWindow.setMenu overwrites secondary window menus with the main window menu whenever Menu.getApplicationMenu is called.
    • Ideas for fixing this:
      • Only show the menubar on the main window.
      • Use per-window menubars on Windows and Linux. Refactor the menu logic to save a copy of the last menu. Create logic to 1) send this saved menu copy to Playwright and 2) change this saved menu (and re-apply it to the window) instead of Menu.getApplicationMenu when changing the enabled/disabled state of menu items.
  • (Not an issue?) Warning banners (e.g. "the synchronization password is missing") are only shown on the main window.
  • (Not an issue?) Secondary windows are just the editor. While they can show dialogs, they don't include panels and can't show other screens.

Screen recording

MacOS:

multi-window.webm

Linux (Fedora 40):

Screencast.from.2024-11-01.14-16-11.webm

Notes

const parentNode = loaded ? doc?.body : null;

// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Needed to allow adding the portal to the DOM
const contentPortal = parentNode && createPortal(props.children, parentNode) as any;
Copy link
Collaborator Author

Choose a reason for hiding this comment

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

Related documentation/explanation:

personalizedrefrigerator and others added 12 commits October 5, 2024 12:23
…en and into separate directory

This pull request previously moved the main window command registration logic
from MainScreen.tsx to a different component that could manage showing
dialogs, etc for different windows. This, however, could cause errors
when closing settings (attempts to use commands with unregistered
runtimes). This pull request ensures that command runtimes are
registered by loading them in Root.tsx, rather than in MainScreen.tsx.
return <>
<div ref={setDependencyStyleContainer}></div>
<StyleSheetManager target={dependencyStyleContainer}>
<CacheProvider value={cache}>
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 code uses CacheProvider from @emotion/react to cause react-select to insert its styles into the new window (see comment). @emotion/react is a dependency of react-select and, at present, is not a direct dependency of Joplin.

Adding @emotion/react to app-desktop/package.json could be problematic if react-select is updated without also updating the @emotion/react version — having multiple versions of @emotion/react may cause styling issues (or perhaps break styling just in new windows).

See this unmerged upstream pull request for a possible future solution that doesn't involve importing transitive dependencies.

@personalizedrefrigerator personalizedrefrigerator marked this pull request as draft October 11, 2024 21:06
@personalizedrefrigerator personalizedrefrigerator changed the title Proof of concept: Desktop: Multiple window support Desktop: Multiple window support Oct 14, 2024
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.

@laurent22
Copy link
Owner

It's quite difficult to review this PR since it's very large, but what I've seen looks good. The description in the readme also makes sense.

@laurent22 laurent22 merged commit 4a88d6f into laurent22:dev Nov 8, 2024
7 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
desktop All desktop platforms enhancement Feature requests and code enhancements
Projects
None yet
Development

Successfully merging this pull request may close these issues.

2 participants