Skip to content

Desktop: Resolves #11687: Plugins: Allow editor plugins to support multiple windows #12041

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

Open
wants to merge 63 commits into
base: dev
Choose a base branch
from

Conversation

personalizedrefrigerator
Copy link
Collaborator

@personalizedrefrigerator personalizedrefrigerator commented Apr 1, 2025

Summary

This pull request makes it possible to resolve joplin/plugin-yesyoukan#41.

Resolves #11687.

Notes

  • This pull request extends the plugin API to allow:
    • Detecting when a secondary window is opened.
    • Detecting when a secondary window is closed.
    • Getting the selected note in any window (not just the focused window).
    • Specifying the window an editor plugin can be shown in.
      • This prevents IPC issues related to multiple instances of the same editor plugin.
    • Specifying an editor plugin activation check callback in joplin.views.editor.create. This works around a race condition in which the activation check event was fired before the activation check callback was registered.

Updated plugin API

Summary of changes

This pull request makes changes to the editor plugin API to add support for multiple windows:

  • Replaced editor.create with editor.register: Editor view handles are now created and managed by Joplin. Required callbacks are passed directly to editor.register.
    • editor.create is now deprecated, but still exists.
  • Deprecated editors.onActivationCheck: onActivationCheck is a required callback — it should be specified for all editor views. It should now be specified in the options provided to editor.register.
  • Changed editors.onUpdate: editors.onUpdate is now called more frequently. onUpdate is now called whenever the content of the current note is changed by a different editor or source.
    • For example, editors.onUpdate is called when the current note is changed from an external editor, sync, or editor in a different window.
    • Previously, editors.onUpdate was called only when switching notes.
    • The update event provided to the update callback is now provided with the new content of the note and the note ID. Previously, it was suggested that plugins use workspace.selectedNote. However, workspace.selectedNote only returns information about the note in the currently active window. As a result, editor plugins in background windows previously could load an incorrect note. Information about the selected note should now be obtained from the event passed to onUpdate.
  • Added editors.saveNote: Previously, plugins saved note content through the data API. This was problematic because Joplin couldn't tell which editor saved the note. Without this information, Joplin can't determine whether onUpdate should be called for the current editor or not.

Using the new API

Registering an editor plugin

Use joplin.views.editors.register, providing onSetup and onActivationCheck callbacks:

// Joplin uses baseViewId to create unique `handle`s for each editor plugin
const baseViewId = 'some-unique-id-here';
joplin.views.editors.register(baseViewId, {
    // Required. Called when Joplin creates a new editor.
    onSetup: async (handle: ViewHandle) => {
        // Setup logic that depends on `handle`.
    },
    // Required. Called to determine whether the editor plugin can be activated
    // for the note with the given ID.
    onActivationCheck: async ({ noteId } : ActivationCheckEvent) => {
        // Return true if the editor supports the given `noteId`.
    },
}

Implementing onSetup

The onSetup callback should:

  • Set up the editor WebView (e.g. add scripts, set initial HTML).
  • (Optional) Add WebView message event listeners.
  • (Optional) Listen for note content changes.

The editor cannot become active until after onSetup completes.

Example 1: Editor plugin that displays a static message

const editors = joplin.views.editors;
editors.register('some-view-id', {
    onSetup: async (handle: ViewHandle) => {
        await editors.setHtml(handle, `
            <p>This uses the editors API to show some text instead of the main note editor!</p>
        `);
    },
    onActivationCheck: async ({ noteId } : ActivationCheckEvent) => {
        return true; // Support **all** notes.
    },
}

Above, Joplin calls onSetup when Joplin creates a new view for the editor. This happens, for example, when a new window with a new editor is created.

Similarly, onActivationCheck is called when the user opens a new window or switches notes. Here, onActivationCheck always returns true to indicate that the plugin supports all notes.

Notice that the setHtml API is identical to the API used to set the initial HTML in dialogs and panels.

Example 2: Editor plugin that allows changing the current note's content (but not reading it)

const editors = joplin.views.editors;
editors.register('example-editor-2', {
    onSetup: async (handle: ViewHandle) => {
        await editors.setHtml(handle, `
            <p>Actions: <button id="clear-button">Clear note content</button></p>
        `);
        // Some script that calls webViewApi.postMessage(...) after clicking "clear"
        await editors.addScript(handle, './path/to/my/script.js');
        
        // Listen for changes in the selected note ID.
        let noteId;
        // In addition to when the selected note changes, onUpdate is always called
        // immediately after the editor plugin is activated.
        await editors.onUpdate(event => {
            noteId = event.noteId;
        });
        
        // Listen to messages from the script registered above.
        await editors.onMessage(handle, message => {
            if (message === 'clearNote') {
                await editors.saveNote(handle, {
                    // Joplin uses `noteId` to prevent race conditions when saving just before/after
                    // switching notes.
                    noteId,
                    // In this case, clear the note's content
                    body: '',
                 });
             }
        });
    },
    onActivationCheck: async ({ noteId } : ActivationCheckEvent) => {
        return true; // Support **all** notes.
    },
}

The above example:

  • Sets the editor WebView's initial HTML with setHtml.
  • Adds a script to run within the WebView with addScript. This script (not included above) is responsible for registering click handlers for the clear <button.
  • Listens for changes to the active note with onUpdate and stores the editor's note ID in a variable.
  • Listens for messages from the script added by addScript. This is done using onMessage.
    • After receiving a clearNote message, clears the note's content with editors.saveNote.
  • It isn't possible for a particular instance of the editor to become active until after onSetup completes.

Note: onSetup can be called multiple times by Joplin with different view handles. As a result, it's important that the noteId variable is local to onSetup.

Implementing onActivationCheck

The onActivationCheck callback should return true when the provided ActivationCheckEvent describes a note supported by the editor.

For example, mark all notes containing "test" as supported (and no others):

const editors = joplin.views.editors;
editors.register('example-editor-3', {
    onSetup: async (handle: ViewHandle) => {
        // Implement this.
    },
    onActivationCheck: async ({ noteId } : ActivationCheckEvent) => {
        const noteContent = await joplin.data.get(['notes', noteId], { fields: ['body'] });
        // This editor plugin only supports notes that contain "test" in the body
        return noteContent.body.includes('test');
    },
}

Screen recording

Screencast.from.2025-04-01.13-30-53.webm

In this screen recording,

  1. The updated YesYouKan plugin is initially loaded in the desktop app.
  2. A card is dragged from an "In progress" column to the "Done" column in a kanban board note.
  3. The note is opened in a new window. The YesYouKan plugin is initially active.
  4. The card from step 2 is dragged back to the "In progress" column. Both windows update.
  5. The undo button is clicked in the second window and the card moves back to the "Done" column.
  6. A new "Test" card is added to the "In progress column" in the first window.
  7. A new window is opened and switched to the Markdown editor.
  8. A description is added to the "Test" card in the Markdown editor and its title is changed.
    • Note: At this point, an issue can be observed in which the current Markdown editor resets to a previous version. (Was a save scheduled by an editor plugin in a background window?)
  9. The "Test" card is renamed from the Markdown editor to "Test 2". This also updates the card in the secondary window.
  10. The main window switches to a different note. The secondary window remains on the Kanban board note.
  11. The secondary window switches to a different note using the ctrl-p dialog. This dismisses the Kanban board view.
  12. Next, the mobile app is opened. A note titled "Board" is open and the "Toggle editor plugin" button is visible.
  13. The "Toggle editor plugin" button is clicked.
  14. A task is moved from one column to another.
  15. The task's color is changed using the "properties" menu.
  16. The note is closed.
  17. The note is re-opened and the task remains in the column it was moved to in step 13.
  18. The editor plugin is disabled.

To-do

  • Fix a race condition involving editors.onActivationCheck without requiring users to specify the activation check callback as an argument to joplin.views.create.
  • Remove associated editor plugin views when a secondary window is closed.
  • Improve how editor plugin visibility is saved/restored in secondary windows. Currently, this is based on the editor plugin view ID, which is based on the window ID.
  • Describe manual testing steps.

Testing

Automated tests

This pull request adds two new automated Playwright regression tests:

  • A test that verifies that it's possible to toggle between multiple editor plugins in the same window.
  • A test that verifies that saving an editor plugin's content with the new editors.saveNote API works. See commit for further details.

…all windows

Note: This pull request currently uses global desktop app reducer state
to resolve the conflict. This will likely need refactoring (as editor
plugins should eventually made to work on mobile).
…or-plugins-with-multi-window-support' into pr/desktop/fix-editor-plugins-with-multi-window-support
Previously, editor plugins visibility was saved based on the view ID of
the plugin. This, however, was problematic because the view ID is based
on the window ID. As a result, editor plugins in secondary windows were
initially shown/hidden in a way that might be unexpected.

With this change, editor plugin visibility is based on the view type ID.
This type ID is the same as the view ID, but excludes the part that's
determined by the window ID. As a result, editor plugins are
shown/hidden based on the last change to that plugin's visibility in any
window.
Fixes an issue where making a change in the current window that matches
the last editor plugin saved note content in another window would cause
the emitUpdate event to be skipped.
@personalizedrefrigerator personalizedrefrigerator marked this pull request as ready for review April 1, 2025 20:49
@personalizedrefrigerator personalizedrefrigerator marked this pull request as draft April 3, 2025 18:13
@personalizedrefrigerator
Copy link
Collaborator Author

personalizedrefrigerator commented Apr 3, 2025

I'm converting this to a draft until the editor plugin API matches something similar to the following:

API redesign suggestion (edit: This particular redesign is not possible with the current plugin IPC logic on desktop. The implemented API is a bit different.)

Edit: See joplin/plugin-yesyoukan#42 (comment).

Possible API redesign:

  • Deprecate joplin.editors.create.
  • New function: Add a new joplin.editors.register. joplin.editors.register accepts an argument of type OnCreateEditor:
    interface EditorCallbacks {
      onActivationCheck: (event: ActivationCheckEvent)=>Promise<boolean>;
      onUpdate: (event: UpdateEvent)=>Promise<void>;
    };
    type OnCreateEditor = (handle: ViewHandle)=>Promise<EditorCallbacks>;
    
    // Type of joplin.editors.register
    joplin.editors.register(viewId: string, callback: OnCreateEditor): Promise<void>;
    Above, callback is called when Joplin creates a new instance of the editor plugin. This will be done, for example, when the user creates a new window.
  • New function: joplin.editors.save(handle: ViewHandle, note: NoteProperties) to save the content of the editor, without triggering onUpdate.
    • On desktop, .editors.save connects to the existing useFormNote.ts logic for better integration with external editors and secondary windows.

Example:

joplin.editors.register('test-plugin', async (handle) => {
   await joplin.editors.setHtml(handle, `...`);
   await joplin.editors.addScript(handle, './path/to/script.js');
   
   await joplin.editors.onMessage(handle, message => {
     if (message.kind === 'save') {
       const editingId = message.id;
       const bodyToSave = message.body;
       joplin.editors.save(handle, { id: editingId, body: bodyToSave });
     }
   });
   
   return {
     onActivationCheck: async (event) => {
        return true; // Always allow users to enable
     },
     onUpdate: async (event) => {
        joplin.editors.postMessage(handle, { type: 'update', content: event.newBody });
     },
   };
});

doesn't support returning functions within objects
It's more convenient to first call onUpdate within the required onSetup
handler -- this allows storing the last information from an onUpdate
event in a local variable that can be shared with the onMessage and
similar callbacks registered within onSetup.
@personalizedrefrigerator personalizedrefrigerator marked this pull request as ready for review April 3, 2025 22:04
});
});
},

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

Add an onSave callback to the main object instead of what's currently here:

Suggested change
onSave(event) {
event.noteId, event.handle
}

Calling editors.saveNote() then calls the onSave callback.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

Opening a note in a window closes the kanban board in another window Kanban Editor is buggy when using secondary note window
1 participant