1
0
mirror of https://github.com/laurent22/joplin.git synced 2025-11-23 22:36:32 +02:00

Desktop: Add dialog to select a note and link to it (#11891)

This commit is contained in:
Laurent Cozic
2025-02-27 18:24:02 +00:00
committed by GitHub
parent 9cbd1b855c
commit 8bdb6c5d72
10 changed files with 129 additions and 10 deletions

View File

@@ -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/hideModalMessage.js
packages/app-desktop/gui/WindowCommandsAndDialogs/commands/index.js packages/app-desktop/gui/WindowCommandsAndDialogs/commands/index.js
packages/app-desktop/gui/WindowCommandsAndDialogs/commands/leaveSharedFolder.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/moveToFolder.js
packages/app-desktop/gui/WindowCommandsAndDialogs/commands/newFolder.js packages/app-desktop/gui/WindowCommandsAndDialogs/commands/newFolder.js
packages/app-desktop/gui/WindowCommandsAndDialogs/commands/newNote.js packages/app-desktop/gui/WindowCommandsAndDialogs/commands/newNote.js

1
.gitignore vendored
View File

@@ -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/hideModalMessage.js
packages/app-desktop/gui/WindowCommandsAndDialogs/commands/index.js packages/app-desktop/gui/WindowCommandsAndDialogs/commands/index.js
packages/app-desktop/gui/WindowCommandsAndDialogs/commands/leaveSharedFolder.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/moveToFolder.js
packages/app-desktop/gui/WindowCommandsAndDialogs/commands/newFolder.js packages/app-desktop/gui/WindowCommandsAndDialogs/commands/newFolder.js
packages/app-desktop/gui/WindowCommandsAndDialogs/commands/newNote.js packages/app-desktop/gui/WindowCommandsAndDialogs/commands/newNote.js

View File

@@ -999,6 +999,7 @@ function useMenu(props: Props) {
rootMenus.go.submenu.push(menuItemDic.gotoAnything); rootMenus.go.submenu.push(menuItemDic.gotoAnything);
rootMenus.tools.submenu.push(menuItemDic.commandPalette); rootMenus.tools.submenu.push(menuItemDic.commandPalette);
rootMenus.tools.submenu.push(menuItemDic.linkToNote);
rootMenus.tools.submenu.push(menuItemDic.openMasterPasswordDialog); rootMenus.tools.submenu.push(menuItemDic.openMasterPasswordDialog);
for (const view of props.pluginMenuItems) { for (const view of props.pluginMenuItems) {

View File

@@ -1,5 +1,6 @@
import { CommandRuntime, CommandDeclaration, CommandContext } from '@joplin/lib/services/CommandService'; import { CommandRuntime, CommandDeclaration, CommandContext } from '@joplin/lib/services/CommandService';
import { _ } from '@joplin/lib/locale'; import { _ } from '@joplin/lib/locale';
import { GotoAnythingUserData, Mode, UserDataCallbackReject, UserDataCallbackResolve } from '../../../plugins/GotoAnything';
const PluginManager = require('@joplin/lib/services/PluginManager'); const PluginManager = require('@joplin/lib/services/PluginManager');
export enum UiType { export enum UiType {
@@ -8,6 +9,10 @@ export enum UiType {
ControlledApi = 'controlledApi', ControlledApi = 'controlledApi',
} }
export interface GotoAnythingOptions {
mode?: Mode;
}
export const declaration: CommandDeclaration = { export const declaration: CommandDeclaration = {
name: 'gotoAnything', name: 'gotoAnything',
label: () => _('Goto Anything...'), label: () => _('Goto Anything...'),
@@ -24,19 +29,26 @@ function menuItemById(id: string) {
// calling the click() handler. // calling the click() handler.
export const runtime = (): CommandRuntime => { export const runtime = (): CommandRuntime => {
return { 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) { if (uiType === UiType.GotoAnything) {
menuItemById('gotoAnything').click(); menuItemById('gotoAnything').click();
} else if (uiType === UiType.CommandPalette) { } else if (uiType === UiType.CommandPalette) {
menuItemById('commandPalette').click(); menuItemById('commandPalette').click();
} else if (uiType === UiType.ControlledApi) { } else if (uiType === UiType.ControlledApi) {
// eslint-disable-next-line @typescript-eslint/ban-types -- Old code before rule was applied // 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 // 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'); const menuItem = PluginManager.instance().menuItems().find((i: any) => i.id === 'controlledApi');
menuItem.userData = { const userData: GotoAnythingUserData = {
callback: { resolve, reject }, callback: { resolve, reject },
mode: options.mode,
}; };
menuItem.userData = userData;
menuItem.click(); menuItem.click();
}); });
} }

View File

@@ -8,6 +8,7 @@ import * as exportPdf from './exportPdf';
import * as gotoAnything from './gotoAnything'; import * as gotoAnything from './gotoAnything';
import * as hideModalMessage from './hideModalMessage'; import * as hideModalMessage from './hideModalMessage';
import * as leaveSharedFolder from './leaveSharedFolder'; import * as leaveSharedFolder from './leaveSharedFolder';
import * as linkToNote from './linkToNote';
import * as moveToFolder from './moveToFolder'; import * as moveToFolder from './moveToFolder';
import * as newFolder from './newFolder'; import * as newFolder from './newFolder';
import * as newNote from './newNote'; import * as newNote from './newNote';
@@ -56,6 +57,7 @@ const index: any[] = [
gotoAnything, gotoAnything,
hideModalMessage, hideModalMessage,
leaveSharedFolder, leaveSharedFolder,
linkToNote,
moveToFolder, moveToFolder,
newFolder, newFolder,
newNote, newNote,

View File

@@ -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',
};
};

View File

@@ -59,6 +59,7 @@ export default function() {
'editor.sortSelectedLines', 'editor.sortSelectedLines',
'editor.swapLineUp', 'editor.swapLineUp',
'editor.swapLineDown', 'editor.swapLineDown',
'linkToNote',
'exportDeletionLog', 'exportDeletionLog',
'toggleSafeMode', 'toggleSafeMode',
'showShareNoteDialog', 'showShareNoteDialog',

View File

@@ -40,6 +40,39 @@ interface GotoAnythingSearchResult {
item_type?: ModelType; 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 { interface Props {
themeId: number; themeId: number;
// eslint-disable-next-line @typescript-eslint/ban-types -- Old code before rule was applied // eslint-disable-next-line @typescript-eslint/ban-types -- Old code before rule was applied
@@ -47,8 +80,7 @@ interface Props {
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied // eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
folders: any[]; folders: any[];
showCompletedTodos: boolean; showCompletedTodos: boolean;
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied userData: GotoAnythingUserData;
userData: any;
} }
interface State { interface State {
@@ -131,8 +163,8 @@ class DialogComponent extends React.PureComponent<Props, State> {
private itemListRef: any; private itemListRef: any;
private listUpdateQueue_: AsyncActionQueue; private listUpdateQueue_: AsyncActionQueue;
private markupToHtml_: MarkupToHtml; private markupToHtml_: MarkupToHtml;
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied private userCallback_: UserDataCallback|null = null;
private userCallback_: any = null; private mode_: Mode;
public constructor(props: Props) { public constructor(props: Props) {
super(props); super(props);
@@ -142,6 +174,8 @@ class DialogComponent extends React.PureComponent<Props, State> {
this.userCallback_ = props?.userData?.callback; this.userCallback_ = props?.userData?.callback;
this.listUpdateQueue_ = new AsyncActionQueue(100); this.listUpdateQueue_ = new AsyncActionQueue(100);
this.mode_ = props?.userData?.mode ? props.userData.mode : Mode.Default;
this.state = { this.state = {
query: startString, query: startString,
results: [], results: [],
@@ -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 // eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
resultsInBody = !!results.find((row: any) => row.fields.includes('body')); 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 resourceIds = results.filter(r => r.item_type === ModelType.Resource).map(r => r.item_id);
const resources = await Resource.resourceOcrTextsByIds(resourceIds); const resources = await Resource.resourceOcrTextsByIds(resourceIds);
@@ -584,8 +625,8 @@ class DialogComponent extends React.PureComponent<Props, State> {
aria-posinset={index + 1} aria-posinset={index + 1}
> >
<div style={style.rowTitle} dangerouslySetInnerHTML={{ __html: titleHtml }}></div> <div style={style.rowTitle} dangerouslySetInnerHTML={{ __html: titleHtml }}></div>
{fragmentComp} {this.mode_ === Mode.TitleOnly ? null : fragmentComp}
{pathComp} {this.mode_ === Mode.TitleOnly ? null : pathComp}
</div> </div>
); );
} }
@@ -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() { public render() {
const style = this.style(); const style = this.style();
const helpTextId = 'goto-anything-help-text'; const helpTextId = 'goto-anything-help-text';
@@ -678,7 +727,7 @@ class DialogComponent extends React.PureComponent<Props, State> {
id={helpTextId} id={helpTextId}
style={style.help} style={style.help}
hidden={!this.state.showHelp} 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 ( return (

View File

@@ -64,6 +64,7 @@ const defaultKeymapItems = {
{ accelerator: 'Option+Cmd+Backspace', command: 'permanentlyDeleteNote' }, { accelerator: 'Option+Cmd+Backspace', command: 'permanentlyDeleteNote' },
{ accelerator: 'Option+Cmd+N', command: 'openNoteInNewWindow' }, { accelerator: 'Option+Cmd+N', command: 'openNoteInNewWindow' },
{ accelerator: 'Ctrl+M', command: 'toggleTabMovesFocus' }, { accelerator: 'Ctrl+M', command: 'toggleTabMovesFocus' },
{ accelerator: 'Shift+Option+L', command: 'linkToNote' },
], ],
default: [ default: [
{ accelerator: 'Ctrl+N', command: 'newNote' }, { accelerator: 'Ctrl+N', command: 'newNote' },
@@ -114,6 +115,7 @@ const defaultKeymapItems = {
{ accelerator: 'Ctrl+Alt+3', command: 'switchProfile3' }, { accelerator: 'Ctrl+Alt+3', command: 'switchProfile3' },
{ accelerator: 'Ctrl+Alt+N', command: 'openNoteInNewWindow' }, { accelerator: 'Ctrl+Alt+N', command: 'openNoteInNewWindow' },
{ accelerator: 'Ctrl+M', command: 'toggleTabMovesFocus' }, { accelerator: 'Ctrl+M', command: 'toggleTabMovesFocus' },
{ accelerator: 'Shift+Alt+L', command: 'linkToNote' },
], ],
}; };

View File

@@ -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.