Skip to content

Commit

Permalink
Desktop: Add dialog to select a note and link to it (#11891)
Browse files Browse the repository at this point in the history
  • Loading branch information
laurent22 authored Feb 27, 2025
1 parent 9cbd1b8 commit 8bdb6c5
Show file tree
Hide file tree
Showing 10 changed files with 129 additions and 10 deletions.
1 change: 1 addition & 0 deletions .eslintignore
Original file line number Diff line number Diff line change
Expand Up @@ -436,6 +436,7 @@ 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/linkToNote.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
Expand Down
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -411,6 +411,7 @@ 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/linkToNote.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
Expand Down
1 change: 1 addition & 0 deletions packages/app-desktop/gui/MenuBar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -999,6 +999,7 @@ function useMenu(props: Props) {

rootMenus.go.submenu.push(menuItemDic.gotoAnything);
rootMenus.tools.submenu.push(menuItemDic.commandPalette);
rootMenus.tools.submenu.push(menuItemDic.linkToNote);
rootMenus.tools.submenu.push(menuItemDic.openMasterPasswordDialog);

for (const view of props.pluginMenuItems) {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { CommandRuntime, CommandDeclaration, CommandContext } from '@joplin/lib/services/CommandService';
import { _ } from '@joplin/lib/locale';
import { GotoAnythingUserData, Mode, UserDataCallbackReject, UserDataCallbackResolve } from '../../../plugins/GotoAnything';
const PluginManager = require('@joplin/lib/services/PluginManager');

export enum UiType {
Expand All @@ -8,6 +9,10 @@ export enum UiType {
ControlledApi = 'controlledApi',
}

export interface GotoAnythingOptions {
mode?: Mode;
}

export const declaration: CommandDeclaration = {
name: 'gotoAnything',
label: () => _('Goto Anything...'),
Expand All @@ -24,19 +29,26 @@ function menuItemById(id: string) {
// calling the click() handler.
export const runtime = (): CommandRuntime => {
return {
execute: async (_context: CommandContext, uiType: UiType = UiType.GotoAnything) => {
execute: async (_context: CommandContext, uiType: UiType = UiType.GotoAnything, options: GotoAnythingOptions = null) => {
options = {
mode: Mode.Default,
...options,
};

if (uiType === UiType.GotoAnything) {
menuItemById('gotoAnything').click();
} else if (uiType === UiType.CommandPalette) {
menuItemById('commandPalette').click();
} else if (uiType === UiType.ControlledApi) {
// eslint-disable-next-line @typescript-eslint/ban-types -- Old code before rule was applied
return new Promise((resolve: Function, reject: Function) => {
return new Promise((resolve: UserDataCallbackResolve, reject: UserDataCallbackReject) => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
const menuItem = PluginManager.instance().menuItems().find((i: any) => i.id === 'controlledApi');
menuItem.userData = {
const userData: GotoAnythingUserData = {
callback: { resolve, reject },
mode: options.mode,
};
menuItem.userData = userData;
menuItem.click();
});
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import * as exportPdf from './exportPdf';
import * as gotoAnything from './gotoAnything';
import * as hideModalMessage from './hideModalMessage';
import * as leaveSharedFolder from './leaveSharedFolder';
import * as linkToNote from './linkToNote';
import * as moveToFolder from './moveToFolder';
import * as newFolder from './newFolder';
import * as newNote from './newNote';
Expand Down Expand Up @@ -56,6 +57,7 @@ const index: any[] = [
gotoAnything,
hideModalMessage,
leaveSharedFolder,
linkToNote,
moveToFolder,
newFolder,
newNote,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import CommandService, { CommandRuntime, CommandDeclaration, CommandContext } from '@joplin/lib/services/CommandService';
import { _ } from '@joplin/lib/locale';
import { Mode } from '../../../plugins/GotoAnything';
import { GotoAnythingOptions, UiType } from './gotoAnything';
import { ModelType } from '@joplin/lib/BaseModel';
import Logger from '@joplin/utils/Logger';
import markdownUtils from '@joplin/lib/markdownUtils';

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

export const declaration: CommandDeclaration = {
name: 'linkToNote',
label: () => _('Link to note...'),
};

export const runtime = (): CommandRuntime => {
return {
execute: async (_context: CommandContext) => {
const options: GotoAnythingOptions = {
mode: Mode.TitleOnly,
};
const result = await CommandService.instance().execute('gotoAnything', UiType.ControlledApi, options);
if (!result) return result;

if (result.type !== ModelType.Note) {
logger.warn('Retrieved item is not a note:', result);
return null;
}

const link = `[${markdownUtils.escapeTitleText(result.item.title)}](:/${markdownUtils.escapeLinkUrl(result.item.id)})`;
await CommandService.instance().execute('insertText', link);
return result;
},

enabledCondition: 'markdownEditorPaneVisible || richTextEditorVisible',
};
};
1 change: 1 addition & 0 deletions packages/app-desktop/gui/menuCommandNames.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,7 @@ export default function() {
'editor.sortSelectedLines',
'editor.swapLineUp',
'editor.swapLineDown',
'linkToNote',
'exportDeletionLog',
'toggleSafeMode',
'showShareNoteDialog',
Expand Down
63 changes: 56 additions & 7 deletions packages/app-desktop/plugins/GotoAnything.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -40,15 +40,47 @@ interface GotoAnythingSearchResult {
item_type?: ModelType;
}

// GotoAnything supports several modes:
//
// - Default: Search in note title, body. Can search for folders, tags, etc. This is the full
// featured GotoAnything.
//
// - TitleOnly: Search in note titles only.
//
// These different modes can be set from the `gotoAnything` command.

export enum Mode {
Default = 0,
TitleOnly,
}

export interface UserDataCallbackEvent {
type: ModelType;
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
item: any;
}

export type UserDataCallbackResolve = (event: UserDataCallbackEvent)=> void;
export type UserDataCallbackReject = (error: Error)=> void;
export interface UserDataCallback {
resolve: UserDataCallbackResolve;
reject: UserDataCallbackReject;
}

export interface GotoAnythingUserData {
startString?: string;
mode?: Mode;
callback?: UserDataCallback;
}

interface Props {
themeId: number;
// eslint-disable-next-line @typescript-eslint/ban-types -- Old code before rule was applied
dispatch: Function;
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
folders: any[];
showCompletedTodos: boolean;
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
userData: any;
userData: GotoAnythingUserData;
}

interface State {
Expand Down Expand Up @@ -131,8 +163,8 @@ class DialogComponent extends React.PureComponent<Props, State> {
private itemListRef: any;
private listUpdateQueue_: AsyncActionQueue;
private markupToHtml_: MarkupToHtml;
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
private userCallback_: any = null;
private userCallback_: UserDataCallback|null = null;
private mode_: Mode;

public constructor(props: Props) {
super(props);
Expand All @@ -142,6 +174,8 @@ class DialogComponent extends React.PureComponent<Props, State> {
this.userCallback_ = props?.userData?.callback;
this.listUpdateQueue_ = new AsyncActionQueue(100);

this.mode_ = props?.userData?.mode ? props.userData.mode : Mode.Default;

this.state = {
query: startString,
results: [],
Expand Down Expand Up @@ -341,6 +375,13 @@ class DialogComponent extends React.PureComponent<Props, State> {
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
resultsInBody = !!results.find((row: any) => row.fields.includes('body'));

if (this.mode_ === Mode.TitleOnly) {
resultsInBody = false;
results = results.filter(r => {
return r.fields.includes('title');
});
}

const resourceIds = results.filter(r => r.item_type === ModelType.Resource).map(r => r.item_id);
const resources = await Resource.resourceOcrTextsByIds(resourceIds);

Expand Down Expand Up @@ -584,8 +625,8 @@ class DialogComponent extends React.PureComponent<Props, State> {
aria-posinset={index + 1}
>
<div style={style.rowTitle} dangerouslySetInnerHTML={{ __html: titleHtml }}></div>
{fragmentComp}
{pathComp}
{this.mode_ === Mode.TitleOnly ? null : fragmentComp}
{this.mode_ === Mode.TitleOnly ? null : pathComp}
</div>
);
}
Expand Down Expand Up @@ -668,6 +709,14 @@ class DialogComponent extends React.PureComponent<Props, State> {
);
}

private helpText() {
if (this.mode_ === Mode.TitleOnly) {
return _('Type a note title to search for it.');
} else {
return _('Type a note title or part of its content to jump to it. Or type # followed by a tag name, or @ followed by a notebook name. Or type : to search for commands.');
}
}

public render() {
const style = this.style();
const helpTextId = 'goto-anything-help-text';
Expand All @@ -678,7 +727,7 @@ class DialogComponent extends React.PureComponent<Props, State> {
id={helpTextId}
style={style.help}
hidden={!this.state.showHelp}
>{_('Type a note title or part of its content to jump to it. Or type # followed by a tag name, or @ followed by a notebook name. Or type : to search for commands.')}</div>
>{this.helpText()}</div>
);

return (
Expand Down
2 changes: 2 additions & 0 deletions packages/lib/services/KeymapService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,7 @@ const defaultKeymapItems = {
{ accelerator: 'Option+Cmd+Backspace', command: 'permanentlyDeleteNote' },
{ accelerator: 'Option+Cmd+N', command: 'openNoteInNewWindow' },
{ accelerator: 'Ctrl+M', command: 'toggleTabMovesFocus' },
{ accelerator: 'Shift+Option+L', command: 'linkToNote' },
],
default: [
{ accelerator: 'Ctrl+N', command: 'newNote' },
Expand Down Expand Up @@ -114,6 +115,7 @@ const defaultKeymapItems = {
{ accelerator: 'Ctrl+Alt+3', command: 'switchProfile3' },
{ accelerator: 'Ctrl+Alt+N', command: 'openNoteInNewWindow' },
{ accelerator: 'Ctrl+M', command: 'toggleTabMovesFocus' },
{ accelerator: 'Shift+Alt+L', command: 'linkToNote' },
],
};

Expand Down
13 changes: 13 additions & 0 deletions readme/apps/link_to_note.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
# Link to note

To create a link to a note, you have two options:

## Create a Markdown link

Simply create the link in Markdown, as described in the [Markdown guide](https://joplinapp.org/help/apps/markdown/#links-to-other-notes).

## Use the "Link to note" dialog

An easier way is to use the "Link to note" dialog - to do so open the dialog from **Tools => Link to note...**. Then type the note you would like to link to and press <kbd>Enter</kbd> when done.

This will create a new link and insert it into your current note.

0 comments on commit 8bdb6c5

Please sign in to comment.