From 5e93e82dd5a9b1507fc36b620e4961e84f2f6d61 Mon Sep 17 00:00:00 2001 From: benji300 <54955848+benji300@users.noreply.github.com> Date: Wed, 17 Feb 2021 23:11:13 +0100 Subject: [PATCH] Release v1.2.0 (#6) * Improved drag&drop behavior * Add title to type icons * Add ability to rename favorites directly in panel * Add hover buttons to rename/delete in vertical layout * Scroll horizontal without holding `Shift` key + style improvements * Minor style improvements and fixes * Try to fix Search favorites with phrases in query cannot be opened (#4) Closes #4 * Prepare release v1.2.0 * Rename plugin command labels --- CHANGELOG.md | 19 + README.md | 66 +- package-lock.json | 2 +- package.json | 2 +- .../fontawesome/webfonts/fa-regular-400.svg | 801 ++++++++++++++++++ .../fontawesome/webfonts/fa-regular-400.ttf | Bin 0 -> 34052 bytes .../fontawesome/webfonts/fa-regular-400.woff | Bin 0 -> 16776 bytes .../fontawesome/webfonts/fa-regular-400.woff2 | Bin 0 -> 13588 bytes src/dialog.ts | 97 +++ src/favorites.ts | 231 +++++ src/helpers.ts | 215 ----- src/index.ts | 605 ++++--------- src/manifest.json | 20 +- src/panel.ts | 143 ++++ src/settings.ts | 287 +++++++ src/webview.css | 82 +- src/webview.js | 216 +++-- 17 files changed, 1986 insertions(+), 800 deletions(-) create mode 100644 src/assets/fontawesome/webfonts/fa-regular-400.svg create mode 100644 src/assets/fontawesome/webfonts/fa-regular-400.ttf create mode 100644 src/assets/fontawesome/webfonts/fa-regular-400.woff create mode 100644 src/assets/fontawesome/webfonts/fa-regular-400.woff2 create mode 100644 src/dialog.ts create mode 100644 src/favorites.ts delete mode 100644 src/helpers.ts create mode 100644 src/panel.ts create mode 100644 src/settings.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index ddeacd7..aa8a911 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,25 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - None +## [1.2.0] - 2021-02-17 + +### Added + +- Ability to drag & drop notes from [note-tabs plugin](https://github.com/benji300/joplin-note-tabs) to add as favorite +- Ability to rename and delete favorites directly in panel (vertical layout only) + - Via new hover buttons on the right side + +### Changed + +- Drag & drop behavior to add notebooks, notes or to-dos + - Move them onto the panel to add new favorite at the dropped position +- Scroll horizontally without holding `Shift` key +- plugin command labels (Removed `Favs:` prefix) + +### Fixed + +- Search favorites with phrases in query cannot be opened, edited or deleted ([#4](https://github.com/benji300/joplin-favorites/issues/4)) + ## [1.1.0] - 2021-01-21 ### Changed diff --git a/README.md b/README.md index b8f7075..28b69f9 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # Joplin Favorites -Joplin Favorites is a plugin to extend the UX and UI of [Joplin's](https://joplinapp.org/) desktop application. +Favorites is a plugin to extend the UX and UI of [Joplin's](https://joplinapp.org/) desktop application. It allows to save any notebook, note, to-do, tag, or search as favorite in an extra panel view for quick access. @@ -35,7 +35,7 @@ It allows to save any notebook, note, to-do, tag, or search as favorite in an ex - Search - Not fully supported right now - see [here](#open-saved-search) for details - Set and edit user defined names for the favorites -- Right-click on favorites to edit or remove +- Right-click on favorites to open edit dialog - Change position of favorites within the panel via drag & drop - Drag notebooks and notes from sidebar or note list directly to favorites - Configurable style attributes @@ -48,7 +48,7 @@ It allows to save any notebook, note, to-do, tag, or search as favorite in an ex ![favorites-top-horizontal](./assets/favorites-top-horizontal.png) -### Favorites in sidebar (vertical layout) +#### Favorites in sidebar (vertical layout) ![favorites-sidebar-vertical](./assets/favorites-sidebar-vertical.png) @@ -65,7 +65,7 @@ It allows to save any notebook, note, to-do, tag, or search as favorite in an ex ### Manual -- Download the latest released JPL package (`joplin.plugin.benji.favorites.jpl`) from [here](https://github.com/benji300/joplin-favorites/releases) +- Download the latest released JPL package (`*.jpl`) from [here](https://github.com/benji300/joplin-favorites/releases) - Open Joplin and navigate to `Tools > Options > Plugins` - Press `Install plugin` and select the previously downloaded `jpl` file - Confirm selection @@ -74,8 +74,7 @@ It allows to save any notebook, note, to-do, tag, or search as favorite in an ex ### Uninstall -- Open Joplin -- Navigate to `Tools > Options > Plugins` +- Open Joplin and navigate to `Tools > Options > Plugins` - Search for the `Favorites` plugin - Press `Delete` to remove the plugin completely - Alternatively you can also disable the plugin by clicking on the toggle button @@ -90,47 +89,52 @@ By default the panel will be on the right side of the screen, this can be adjust - `View > Change application layout` - Use the arrow keys (the displayed ones, not keyboard keys) to move the panel at the desired position - Move the splitter to reach the desired height/width of the panel + - As soon as the width of the panel goes below `400px`, it automatically switches from horizontal to vertical layout - Press `ESC` to save the layout and return to normal mode ### Add favorite - To add a new favorite to the panel, you have to trigger the corresponding [command](#commands) - - In the table you can see also from which menu context the commands can be triggered -- Notebooks, notes and to-dos can also be added via drag & drop the selected entries onto the `FAVORITES` title of the panel - - To enable this feature, the option `Show favorites panel title` must be enabled -The `Edit favorite before add` option lets you choose whether or not to edit the name before adding a new favorite. + - In the table you can see from which menu context the commands can be triggered + +- Selected notebooks, notes or to-dos can also be added via drag & drop into the panel + + - This will add new favorites at the dropped position -- This is not supported when adding multiple selected notes -- For searches the dialog is always opened to enter the search query +- The `Edit favorite before add` option lets you choose whether or not to edit the name before adding a new favorite -![add-dialog](./assets/add-dialog.png) + - This is not supported when adding multiple selected notes + - For searches the dialog is always opened to enter the search query + + ![add-dialog](./assets/add-dialog.png) ### Edit favorite - Right click on one of the favorites to open the edit dialog -In the edit dialog you can change the name of any favorite. + ![edit-dialog](./assets/edit-dialog.png) -![edit-dialog](./assets/edit-dialog.png) +- For searches, you can also edit the search query in the dialog -For searches, you can also edit the search query. + ![edit-search-dialog](./assets/edit-search-dialog.png) -![edit-search-dialog](./assets/edit-search-dialog.png) +- Rename favorite by clicking the rename icon on the right side in the vertical layout ### Remove favorite -- Right click on one of the favorites to open the edit dialog (see screenshots above) -- Press `Delete` to remove the favorite +- Right click on a favorite to open the edit dialog (see screenshots above) and press `Delete` to remove it + +- Remove favorite by clicking the delete icon on the right side in the vertical layout -Alternatively you can remove all favorites at once via the `Favorites: Remove all favorites` command. +- Remove all favorites at once via the `Remove all Favorites` command ### Open saved search Currently favorites for searches are not fully supported. Due to restrictions of the App it is not possible to open the global search with a handled search query. To open a saved search follow this workaround: -- Save your search via the `Favorites: Add search` command +- Save your search via the `Add search to Favorites` command - You can enter a name and the search query in the dialog - Click on the search favorite to copy its query to the clipboard - This will also set the focus to the global search bar @@ -140,14 +144,14 @@ To open a saved search follow this workaround: This plugin provides additional commands as described in the following table. -| Command Label | Command ID | Description | Menu contexts | -| ------------------------------- | ---------------------- | -------------------------------------- | ------------------------------------------------------------------------ | -| Favorites: Add notebook | `favsAddFolder` | Add favorite for selected notebook | `Tools>Favorites`, `FolderContext`, `Command palette` | -| Favorites: Add note | `favsAddNote` | Add favorite for selected note(s) | `Tools>Favorites`, `NoteListContext`, `EditorContext`, `Command palette` | -| Favorites: Add tag | `favsAddTag` | Add favorite for selected tag | `TagContext` | -| Favorites: Add search | `favsAddSearch` | Add favorite with entered search query | `Tools>Favorites`, `Command palette` | -| Favorites: Remove all favorites | `favsClear` | Remove all favorites | `Tools>Favorites`, `Command palette` | -| Favorites: Toggle visibility | `favsToggleVisibility` | Toggle panel visibility | `Tools>Favorites`, `Command palette` | +| Command Label | Command ID | Description | Menu contexts | +| --------------------------------- | ---------------------- | -------------------------------------- | ------------------------------------------------------------------------ | +| Add notebook to Favorites | `favsAddFolder` | Add favorite for selected notebook | `Tools>Favorites`, `FolderContext`, `Command palette` | +| Add note to Favorites | `favsAddNote` | Add favorite for selected note(s) | `Tools>Favorites`, `NoteListContext`, `EditorContext`, `Command palette` | +| Add tag to Favorites | `favsAddTag` | Add favorite for selected tag | `TagContext` | +| Add search to Favorites | `favsAddSearch` | Add favorite with entered search query | `Tools>Favorites`, `Command palette` | +| Remove all Favorites | `favsClear` | Remove all favorites | `Tools>Favorites`, `Command palette` | +| Toggle Favorites panel visibility | `favsToggleVisibility` | Toggle panel visibility | `Tools>Favorites`, `Command palette` | ### Keyboard shortcuts @@ -173,9 +177,9 @@ This plugin adds provides user options which can be changed via `Tools > Options ## Support -You like this plugin as much as I do and it helps you in your daily work with Joplin? +You like this plugin as much as I do and it improves your daily work with Joplin? -Then I would be very happy if you would buy me a beer via [PayPal](https://www.paypal.com/donate?hosted_button_id=6FHDGK3PTNU22) :wink::beer: +Then I would be very happy if you buy me a beer via [PayPal](https://www.paypal.com/donate?hosted_button_id=6FHDGK3PTNU22) :wink::beer: ## Development diff --git a/package-lock.json b/package-lock.json index d0ad14b..4af2a2f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,6 +1,6 @@ { "name": "joplin-plugin-benji-favorites", - "version": "1.1.0", + "version": "1.2.0", "lockfileVersion": 1, "requires": true, "dependencies": { diff --git a/package.json b/package.json index 613d895..606fd51 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "joplin-plugin-benji-favorites", - "version": "1.1.0", + "version": "1.2.0", "description": "Save any notebook, note, to-do, tag, or search as favorite in an extra panel view for quick access.", "author": "Benji300", "homepage": "https://github.com/benji300/joplin-favorites", diff --git a/src/assets/fontawesome/webfonts/fa-regular-400.svg b/src/assets/fontawesome/webfonts/fa-regular-400.svg new file mode 100644 index 0000000..60414e1 --- /dev/null +++ b/src/assets/fontawesome/webfonts/fa-regular-400.svg @@ -0,0 +1,801 @@ + + + + +Created by FontForge 20200314 at Wed Jan 13 11:57:54 2021 + By Robert Madole +Copyright (c) Font Awesome + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/assets/fontawesome/webfonts/fa-regular-400.ttf b/src/assets/fontawesome/webfonts/fa-regular-400.ttf new file mode 100644 index 0000000000000000000000000000000000000000..2775fa1e16f5acde9c37852ed01f009bf1ca2792 GIT binary patch literal 34052 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 S`M)Di_+IM|C>n~+>HiDY3xA9N literal 0 HcmV?d00001 diff --git a/src/assets/fontawesome/webfonts/fa-regular-400.woff b/src/assets/fontawesome/webfonts/fa-regular-400.woff new file mode 100644 index 0000000000000000000000000000000000000000..e4acf9193fc37803cd524cb2e6a57bcea8caca11 GIT binary patch literal 16776 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 aueZU3d1dPmCN { + const result: number = await joplin.views.dialogs.showMessageBox(message); + return result; + } + + /** + * Gets the full path, tag name or search query for the favorite. + */ + private async getFullPath(value: string, type: FavoriteType): Promise { + switch (type) { + case FavoriteType.Folder: + case FavoriteType.Note: + case FavoriteType.Todo: + const item = await joplin.data.get([FavoriteDesc[type].dataType, value], { fields: ['title', 'parent_id'] }); + if (item) { + let parents: any[] = new Array(); + let parent_id: string = item.parent_id; + + while (parent_id) { + const parent: any = await joplin.data.get(['folders', parent_id], { fields: ['title', 'parent_id'] }); + if (!parent) break; + parent_id = parent.parent_id; + parents.push(parent.title); + } + parents.reverse().push(item.title); + return parents.join('/'); + } + + case FavoriteType.Tag: + const tag = await joplin.data.get([FavoriteDesc[type].dataType, value], { fields: ['title'] }); + if (tag) { + return tag.title; + } + + case FavoriteType.Search: + return value; + + default: + break; + } + return ''; + } + + /** + * Prepare dialog html content. + */ + private async prepareDialogHtml(value: string, title: string, type: FavoriteType): Promise { + const path: string = await this.getFullPath(value, type); + const disabled: string = (type === FavoriteType.Search) ? '' : 'disabled'; + + return ` +
+

${this._title} ${FavoriteDesc[type].name} Favorite

+
+ + + + +
+
+ `; + } + + /** + * Register the dialog. + */ + async register(buttons?: ButtonSpec[]) { + this._dialog = await joplin.views.dialogs.create('dialog' + this._title); + await joplin.views.dialogs.addScript(this._dialog, './assets/fontawesome/css/all.min.css'); + await joplin.views.dialogs.addScript(this._dialog, './webview_dialog.css'); + if (buttons) { + await joplin.views.dialogs.setButtons(this._dialog, buttons); + } + } + + /** + * Open the dialog width the handled values and return result. + */ + async open(value: string, title: string, type: FavoriteType): Promise { + const dialogHtml: string = await this.prepareDialogHtml(value, title, type); + await joplin.views.dialogs.setHtml(this._dialog, dialogHtml); + const result: DialogResult = await joplin.views.dialogs.open(this._dialog); + return result; + } +} \ No newline at end of file diff --git a/src/favorites.ts b/src/favorites.ts new file mode 100644 index 0000000..a08aade --- /dev/null +++ b/src/favorites.ts @@ -0,0 +1,231 @@ +/** + * Favorite type definitions. + */ +export enum FavoriteType { + Folder = 0, + Note = 1, + Todo = 2, + Tag = 3, + Search = 4 +} + +/** + * Definition of favorite entries. + */ +export interface IFavorite { + // Favorite value = folderId|noteId|tagId|searchQuery + value: string, + // User configured title + title: string, + // Type of the favorite + type: FavoriteType +} + +/** + * Definition of the favorite descriptions. + */ +interface IFavoriteDesc { + name: string, + icon: string, + dataType: string, + label: string +} + +/** + * Array of favorite descriptions. Order must match with FavoriteType enum. + */ +export const FavoriteDesc: IFavoriteDesc[] = [ + { name: 'Notebook', icon: 'fa-book', dataType: 'folders', label: 'Full path' }, // Folder + { name: 'Note', icon: 'fa-file-alt', dataType: 'notes', label: 'Full path' }, // Note + { name: 'To-do', icon: 'fa-check-square', dataType: 'notes', label: 'Full path' }, // Todo + { name: 'Tag', icon: 'fa-tag', dataType: 'tags', label: 'Tag' }, // Tag + { name: 'Search', icon: 'fa-search', dataType: 'searches', label: 'Search query' } // Search +]; + +/** + * Helper class to work with favorites array. + * - Read settings array once at startup. + * - Then work on this._tabs array. + */ +export class Favorites { + /** + * Temporary array to work with favorites. + */ + private _store: IFavorite[]; + + /** + * Init with stored values from settings array. + */ + constructor(settingsArray: IFavorite[]) { + this._store = settingsArray; + } + + //#region GETTER + + /** + * All entries. + */ + get all(): IFavorite[] { + return this._store; + } + + /** + * Number of entries. + */ + get length(): number { + return this._store.length; + } + + //#endregion + + /** + * Inserts handled favorite at specified index. + */ + private async insertAtIndex(index: number, favorite: IFavorite) { + if (index < 0 || favorite === undefined) return; + + this._store.splice(index, 0, favorite); + } + + /** + * Gets a value whether the handled index would lead to out of bound access. + */ + private indexOutOfBounds(index: number): boolean { + return (index < 0 || index >= this.length); + } + + /** + * Escapes HTML special characters. + * From https://github.com/laurent22/joplin/tree/dev/packages/app-cli/tests/support/plugins/toc/src/index.ts + */ + private encodeHtml(unsafe: string): string { + return unsafe + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """) + .replace(/'/g, "'") + .trim(); + } + + /** + * Decodes escaped HTML characters back. + */ + private decodeHtml(unsafe: string): string { + return unsafe + .replace(/&/g, "&") + .replace(/</g, "<") + .replace(/>/g, ">") + .replace(/"/g, '"') + .replace(/'/g, "'") + .trim(); + } + + /** + * Gets the favorites with the handled value. Null if not exist. + */ + get(index: number): IFavorite { + if (this.indexOutOfBounds(index)) return; + return this._store[index]; + } + + /** + * Gets the HTML decoded value of the handled favorite. + * Workaround to copy search strings to clipboard. + */ + getDecodedValue(favorite: IFavorite): string { + if (favorite === undefined) return; + return this.decodeHtml(favorite.value); + } + + /** + * Gets index of favorite with handled value. -1 if not exist. + */ + indexOf(value: string): number { + if (value) { + for (let i: number = 0; i < this.length; i++) { + if (this._store[i]['value'] === value) return i; + } + } + return -1; + } + + /** + * Gets a value whether a favorite with the handled value exists or not. + */ + hasFavorite(value: string): boolean { + return this.indexOf(value) < 0 ? false : true; + } + + /** + * Adds note as new favorite at the handled index or at the end. + */ + async add(newValue: string, newTitle: string, newType: FavoriteType, targetIdx?: number) { + if (newValue === undefined || newTitle === undefined || newType === undefined) return; + + const newFavorite = { value: this.encodeHtml(newValue), title: this.encodeHtml(newTitle), type: newType }; + if (targetIdx) { + await this.insertAtIndex(targetIdx, newFavorite); + } else { + this._store.push(newFavorite); + } + } + + /** + * Changes the title of the handled favorite. + */ + async changeValue(index: number, newValue: string) { + if (index < 0 || newValue === undefined || newValue === '') return; + this._store[index].value = this.encodeHtml(newValue); + } + + /** + * Changes the title of the handled favorite. + */ + async changeTitle(index: number, newTitle: string) { + if (index < 0 || newTitle === undefined || newTitle === '') return; + this._store[index].title = this.encodeHtml(newTitle); + } + + /** + * Changes the type of the handled favorite. + */ + async changeType(index: number, newType: FavoriteType) { + if (index < 0 || newType === undefined) return; + this._store[index].type = newType; + } + + /** + * Moves the favorite from source index to the target index. + */ + async moveWithIndex(sourceIdx: number, targetIdx?: number) { + if (this.indexOutOfBounds(sourceIdx)) return; + if (targetIdx && this.indexOutOfBounds(targetIdx)) return; + + // undefined targetIdx => move to the end + let target: number = this.length - 1; + if (targetIdx) { + // else move at desired index + target = targetIdx; + } + const favorite: IFavorite = this._store[sourceIdx]; + this._store.splice(sourceIdx, 1); + this._store.splice(target, 0, favorite); + } + + /** + * Removes favorite with handled index. + */ + async delete(index: number) { + if (index >= 0) { + this._store.splice(index, 1); + } + } + + /** + * Clears the stored array. + */ + async clearAll() { + this._store = []; + } +} diff --git a/src/helpers.ts b/src/helpers.ts deleted file mode 100644 index 426f87c..0000000 --- a/src/helpers.ts +++ /dev/null @@ -1,215 +0,0 @@ -import joplin from 'api'; - -/** - * Advanced style setting default values. - * Used when setting is set to 'default'. - */ -export enum SettingDefaults { - Default = 'default', - FontFamily = 'Roboto', - FontSize = 'var(--joplin-font-size)', - Background = 'var(--joplin-background-color3)', - HoverBackground = 'var(--joplin-background-color-hover3)', // var(--joplin-background-hover) - Foreground = 'var(--joplin-color-faded)', - DividerColor = 'var(--joplin-divider-color)' -} - -/** - * Favorite type definition. - */ -export enum FavoriteType { - Folder = 0, - Note = 1, - Todo = 2, - Tag = 3, - Search = 4 -} - -/** - * Definition of the favorite descriptions. - */ -interface IFavoriteDesc { - name: string, - icon: string, - dataType: string, - label -} - -/** - * Array of favorite descriptions. Order must match with FavoriteType enum. - */ -export const FavoriteDesc: IFavoriteDesc[] = [ - { name: 'Notebook', icon: 'fa-book', dataType: 'folders', label: 'Full path' }, // Folder - { name: 'Note', icon: 'fa-file-alt', dataType: 'notes', label: 'Full path' }, // Note - { name: 'To-do', icon: 'fa-check-square', dataType: 'notes', label: 'Full path' }, // Todo - { name: 'Tag', icon: 'fa-tag', dataType: 'tags', label: 'Tag' }, // Tag - { name: 'Search', icon: 'fa-search', dataType: 'searches', label: 'Search query' } // Search -]; - -/** - * Helper class to work with favorites array. - */ -export class Favorites { - // [ - // { - // "value": "folderId|noteId|tagId|searchQuery", - // "title": "userConfiguredTitle", - // "type": FavoriteType - // } - // ] - private _favs: any[]; - - constructor() { - this._favs = new Array(); - } - - /** - * Reads the favorites settings array. - */ - async read() { - this._favs = await joplin.settings.value('favorites'); - } - - /** - * Writes the temporay tabs store back to the settings array. - */ - private async store() { - await joplin.settings.setValue('favorites', this._favs); - } - - /** - * Gets a value whether the handled index would lead to out of bound access. - */ - private indexOutOfBounds(index: number): boolean { - return (index < 0 || index >= this.length()); - } - - /** - * Gets the number of favorites. - */ - length(): number { - return this._favs.length; - } - - /** - * Gets all favorites. - */ - getAll(): any[] { - return this._favs; - } - - /** - * Gets the favorites with the handled value. Null if not exist. - */ - get(value: string): any { - if (value == null) return; - - for (let i: number = 0; i < this.length(); i++) { - if (this._favs[i]['value'] === value) return this._favs[i]; - } - return null; - } - - /** - * Gets index of favorite with handled value. -1 if not exist. - */ - indexOf(value: string): number { - if (value) { - for (let i: number = 0; i < this.length(); i++) { - if (this._favs[i]['value'] === value) return i; - } - } - return -1; - } - - /** - * Gets a value whether a favorite with the handled value exists or not. - */ - hasFavorite(value: string): boolean { - return this.indexOf(value) < 0 ? false : true; - } - - /** - * Adds new favorite at the end. - */ - async add(newValue: string, newTitle: string, newType: FavoriteType) { - if (newValue == null || newTitle == null || newType == null) return; - - this._favs.push({ value: newValue, title: newTitle, type: newType }); - await this.store(); - } - - /** - * Changes the title of the handled favorite. - */ - async changeValue(value: string, newValue: string) { - if (!newValue) return; - const index: number = this.indexOf(value); - if (index < 0) return; - this._favs[index].value = newValue; - await this.store(); - } - - /** - * Changes the title of the handled favorite. - */ - async changeTitle(value: string, newTitle: string) { - if (!newTitle) return; - const index: number = this.indexOf(value); - if (index < 0) return; - this._favs[index].title = newTitle; - await this.store(); - } - - /** - * Changes the type of the handled favorite. - */ - async changeType(value: string, newType: FavoriteType) { - const index: number = this.indexOf(value); - if (index < 0) return; - - this._favs[index].type = newType; - await this.store(); - } - - /** - * Moves the favorite from source index to the target index. - */ - async moveWithIndex(sourceIdx: number, targetIdx: number) { - if (this.indexOutOfBounds(sourceIdx)) return; - if (this.indexOutOfBounds(targetIdx)) return; - - const favorite: any = this._favs[sourceIdx]; - this._favs.splice(sourceIdx, 1); - this._favs.splice((targetIdx == 0 ? 0 : targetIdx), 0, favorite); - await this.store(); - } - - /** - * Moves the source favorite to the index of the target favorite. - */ - async moveWithValue(sourceValue: string, targetValue: string) { - if (sourceValue == null || targetValue == null) return; - - await this.moveWithIndex(this.indexOf(sourceValue), this.indexOf(targetValue)); - } - - /** - * Removes favorite with handled value. - */ - async delete(value: string) { - const index = this.indexOf(value); - if (index >= 0) { - this._favs.splice(index, 1); - } - await this.store(); - } - - /** - * Clears the stored favorites array. - */ - async clearAll() { - this._favs = []; - await this.store(); - } -} diff --git a/src/index.ts b/src/index.ts index 0f691ff..459494b 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,224 +1,48 @@ import joplin from 'api'; -import { MenuItem, MenuItemLocation, SettingItemType } from 'api/types'; +import { MenuItem, MenuItemLocation } from 'api/types'; import { ChangeEvent } from 'api/JoplinSettings'; -import { FavoriteType, FavoriteDesc, Favorites } from './helpers'; -import { SettingDefaults, } from './helpers'; +import { FavoriteType, IFavorite, FavoriteDesc, Favorites } from './favorites'; +import { Settings } from './settings'; +import { Panel } from './panel'; +import { Dialog } from './dialog'; joplin.plugins.register({ onStart: async function () { const COMMANDS = joplin.commands; const DATA = joplin.data; - const DIALOGS = joplin.views.dialogs; - const PANELS = joplin.views.panels; const SETTINGS = joplin.settings; const WORKSPACE = joplin.workspace; - - //#region SETTINGS - - await SETTINGS.registerSection('favorites.settings', { - label: 'Favorites', - iconName: 'fas fa-star' - }); - - // private settings - let favorites = new Favorites(); - await SETTINGS.registerSetting('favorites', { - value: [], - type: SettingItemType.Array, - section: 'favorites.settings', - public: false, - label: 'Favorites' - }); - await favorites.read(); - - // general settings - let editBeforeAdd: boolean; - await SETTINGS.registerSetting('editBeforeAdd', { - value: true, - type: SettingItemType.Bool, - section: 'favorites.settings', - public: true, - label: 'Edit favorite before add', - description: 'Opens a dialog to edit the favorite before adding it. If disabled, the name can still be changed later.' - }); - - let enableDragAndDrop: boolean; - await SETTINGS.registerSetting('enableDragAndDrop', { - value: true, - type: SettingItemType.Bool, - section: 'favorites.settings', - public: true, - label: 'Enable drag & drop of favorites', - description: 'If enabled, the position of favorites can be change via drag & drop.' - }); - - let showPanelTitle: boolean; - await SETTINGS.registerSetting('showPanelTitle', { - value: true, - type: SettingItemType.Bool, - section: 'favorites.settings', - public: true, - label: 'Show favorites panel title', - description: "Display 'FAVORITES' title in front of the favorites." - }); - - let showTypeIcons: boolean; - await SETTINGS.registerSetting('showTypeIcons', { - value: true, - type: SettingItemType.Bool, - section: 'favorites.settings', - public: true, - label: 'Show type icons for favorites', - description: 'Display icons before favorite titles representing the types (notebook, note, tag, etc.).' - }); - - let lineHeight: number; - await SETTINGS.registerSetting('lineHeight', { - value: "30", - type: SettingItemType.Int, - section: 'favorites.settings', - public: true, - label: 'Line height (px)', - description: 'Line height of the favorites panel.' - }); - - let minWidth: number; - await SETTINGS.registerSetting('minFavoriteWidth', { - value: "15", - type: SettingItemType.Int, - section: 'favorites.settings', - public: true, - label: 'Minimum favorite width (px)', - description: 'Minimum width of one favorite in pixel.' - }); - - let maxWidth: number; - await SETTINGS.registerSetting('maxFavoriteWidth', { - value: "100", - type: 1, - section: 'favorites.settings', - public: true, - label: 'Maximum favorite width (px)', - description: 'Maximum width of one favorite in pixel.' - }); - - // Advanced settings - let fontFamily: string; - await SETTINGS.registerSetting('fontFamily', { - value: SettingDefaults.Default, - type: SettingItemType.String, - section: 'favorites.settings', - public: true, - advanced: true, - label: 'Font family', - description: "Font family used in the panel. Font families other than 'default' must be installed on the system. If the font is incorrect or empty, it might default to a generic sans-serif font. (default: Roboto)" - }); - - let fontSize: string; - await SETTINGS.registerSetting('fontSize', { - value: SettingDefaults.Default, - type: SettingItemType.String, - section: 'favorites.settings', - public: true, - advanced: true, - label: 'Font size', - description: "Font size used in the panel. Values other than 'default' must be specified in valid CSS syntax, e.g. '13px'. (default: App default font size)" - }); - - let background: string; - await SETTINGS.registerSetting('mainBackground', { - value: SettingDefaults.Default, - type: SettingItemType.String, - section: 'favorites.settings', - public: true, - advanced: true, - label: 'Background color', - description: "Main background color of the panel. (default: Note list background color)" - }); - - let hoverBackground: string; - await SETTINGS.registerSetting('hoverBackground', { - value: SettingDefaults.Default, - type: SettingItemType.String, - section: 'favorites.settings', - public: true, - advanced: true, - label: 'Hover Background color', - description: "Background color used when hovering a favorite. (default: Note list hover color)" - }); - - let foreground: string; - await SETTINGS.registerSetting('mainForeground', { - value: SettingDefaults.Default, - type: SettingItemType.String, - section: 'favorites.settings', - public: true, - advanced: true, - label: 'Foreground color', - description: "Foreground color used for text and icons. (default: App faded color)" - }); - - let dividerColor: string; - await SETTINGS.registerSetting('dividerColor', { - value: SettingDefaults.Default, - type: SettingItemType.String, - section: 'favorites.settings', - public: true, - advanced: true, - label: 'Divider color', - description: "Color of the divider between the favorites. (default: App default border color)" - }); - - const regexp: RegExp = new RegExp(SettingDefaults.Default, "i"); - async function getSettingOrDefault(event: ChangeEvent, localVar: any, setting: string, defaultValue?: string): Promise { - const read: boolean = (!event || event.keys.includes(setting)); - if (read) { - const value: string = await SETTINGS.value(setting); - if (defaultValue && value.match(regexp)) { - return defaultValue; - } else { - return value; - } - } - return localVar; - } - - async function readSettingsAndUpdate(event?: ChangeEvent) { - enableDragAndDrop = await getSettingOrDefault(event, enableDragAndDrop, 'enableDragAndDrop'); - showPanelTitle = await getSettingOrDefault(event, showPanelTitle, 'showPanelTitle'); - showTypeIcons = await getSettingOrDefault(event, showTypeIcons, 'showTypeIcons'); - editBeforeAdd = await getSettingOrDefault(event, editBeforeAdd, 'editBeforeAdd'); - lineHeight = await getSettingOrDefault(event, lineHeight, 'lineHeight'); - maxWidth = await getSettingOrDefault(event, maxWidth, 'maxFavoriteWidth'); - minWidth = await getSettingOrDefault(event, minWidth, 'minFavoriteWidth'); - fontFamily = await getSettingOrDefault(event, fontFamily, 'fontFamily', SettingDefaults.FontFamily); - fontSize = await getSettingOrDefault(event, fontSize, 'fontSize', SettingDefaults.FontSize); - background = await getSettingOrDefault(event, background, 'mainBackground', SettingDefaults.Background); - hoverBackground = await getSettingOrDefault(event, hoverBackground, 'hoverBackground', SettingDefaults.HoverBackground); - foreground = await getSettingOrDefault(event, foreground, 'mainForeground', SettingDefaults.Foreground); - dividerColor = await getSettingOrDefault(event, dividerColor, 'dividerColor', SettingDefaults.DividerColor); - await updatePanelView(); - } - - SETTINGS.onChange(async (event: ChangeEvent) => { - await readSettingsAndUpdate(event); - }); - - //#endregion + // settings + const settings: Settings = new Settings(); + await settings.register(); + // favorites + const favorites = new Favorites(settings.favorites); + // panel + const panel = new Panel(favorites, settings); + await panel.register(); + // dialogs + const addDialog = new Dialog('Add'); + await addDialog.register(); + const editDialog = new Dialog('Edit'); + await editDialog.register([ + { id: 'delete', title: 'Delete', }, + { id: 'ok', title: 'OK' }, + { id: 'cancel', title: 'Cancel' } + ]); //#region HELPERS /** - * Check if favorite target still exists - otherwise ask to remove favorite - */ - async function checkAndRemoveFavorite(favorite: any): Promise { + * Check if favorite target still exists - otherwise ask to remove favorite + */ + async function checkAndRemoveFavorite(favorite: IFavorite, index: number): Promise { try { await DATA.get([FavoriteDesc[favorite.type].dataType, favorite.value], { fields: ['id'] }); } catch (err) { - const result: number = await DIALOGS.showMessageBox(`Cannot open favorite. Seems that the target ${FavoriteDesc[favorite.type].name.toLocaleLowerCase()} was deleted.\n\nDo you want to delete the favorite also?`); + const result: number = await Dialog.showMessage(`Cannot open favorite. Seems that the target ${FavoriteDesc[favorite.type].name.toLocaleLowerCase()} was deleted.\n\nDo you want to delete the favorite also?`); if (!result) { - await favorites.delete(favorite.value); - await updatePanelView(); + await favorites.delete(index); + await panel.updateWebview(); return true; } } @@ -228,114 +52,37 @@ joplin.plugins.register({ /** * Check if note/todo is still of the same type - otherwise change type */ - async function checkAndUpdateType(favorite: any) { + async function checkAndUpdateType(favorite: IFavorite, index: number) { let newType: FavoriteType; const note: any = await DATA.get([FavoriteDesc[favorite.type].dataType, favorite.value], { fields: ['id', 'is_todo'] }); if (favorite.type === FavoriteType.Note && note.is_todo) newType = FavoriteType.Todo; if (favorite.type === FavoriteType.Todo && (!note.is_todo)) newType = FavoriteType.Note; if (newType) { - await favorites.changeType(favorite.value, newType); - await updatePanelView(); + await favorites.changeType(index, newType); + await panel.updateWebview(); } } /** - * Gets the full path, tag name or search query for the favorite. + * Add new favorite entry */ - async function getFavoritePath(value: string, type: FavoriteType): Promise { - switch (type) { - case FavoriteType.Folder: - case FavoriteType.Note: - case FavoriteType.Todo: - const item = await DATA.get([FavoriteDesc[type].dataType, value], { fields: ['title', 'parent_id'] }); - if (item) { - let parents: any[] = new Array(); - let parent_id: string = item.parent_id; - - while (parent_id) { - const parent: any = await DATA.get(['folders', parent_id], { fields: ['title', 'parent_id'] }); - if (!parent) break; - parent_id = parent.parent_id; - parents.push(parent.title); - } - parents.reverse().push(item.title); - return parents.join('/'); - } - - case FavoriteType.Tag: - const tag = await DATA.get([FavoriteDesc[type].dataType, value], { fields: ['title'] }); - if (tag) return tag.title; - - case FavoriteType.Search: - return value; - - default: - break; - } - return ''; - } - - async function openFavorite(value: string) { - const favorite: any = await favorites.get(value); - if (!favorite) return; - - switch (favorite.type) { - case FavoriteType.Folder: - if (await checkAndRemoveFavorite(favorite)) return; - COMMANDS.execute('openFolder', value); - break; - - case FavoriteType.Note: - case FavoriteType.Todo: - if (await checkAndRemoveFavorite(favorite)) return; - await checkAndUpdateType(favorite); - COMMANDS.execute('openNote', value); - break; - - case FavoriteType.Tag: - if (await checkAndRemoveFavorite(favorite)) return; - COMMANDS.execute('openTag', value); - break; - - case FavoriteType.Search: - // TODO there is a command `~\app-desktop\gui\MainScreen\commands\search.ts` avaiable, but currently empty - // use this once it is implemented - - // currently there's no command to trigger a global search, so the following workaround is used - // 1. copy saved search to clipboard - const copy = require('../node_modules/copy-to-clipboard'); - copy(favorite.value as string); - // 2. focus global search bar via command - await COMMANDS.execute('focusSearch'); - // 3. paste clipboard content to current cursor position (should be search bar now) - // TODO how? - break; - - default: - break; - } - } - - async function addFavorite(value: string, title: string, type: FavoriteType, showDialog: boolean) { + async function addFavorite(value: string, title: string, type: FavoriteType, showDialog: boolean, targetIdx?: number) { let newValue: string = value; let newTitle: string = title; // check whether a favorite with handled value already exists - if (favorites.hasFavorite(value)) { + const index: number = favorites.indexOf(value); + if (index >= 0) { // if so... open editFavorite dialog - await editFavorite(value); + await COMMANDS.execute('favsEditFavorite', index); } else { // otherwise create new favorite, with or without user interaction if (showDialog) { - // prepare and open dialog - const dialogHtml: string = await prepareDialogHtml('Add', value, newTitle, type); - await DIALOGS.setHtml(dialogAdd, dialogHtml); - const result: any = await DIALOGS.open(dialogAdd); - - // handle result + // open dialog and handle result + const result: any = await addDialog.open(value, newTitle, type); if (result.id == 'ok' && result.formData != null) { newTitle = result.formData.inputForm.title; if (result.formData.inputForm.value) @@ -344,33 +91,10 @@ joplin.plugins.register({ return; } - if (newValue === '' || newTitle === '') - return; - - await favorites.add(newValue, newTitle, type); - await updatePanelView(); - } - } + if (newValue === '' || newTitle === '') return; - async function editFavorite(value: string) { - const favorite: any = await favorites.get(value); - if (!favorite) return; - - // prepare and open dialog - const dialogHtml: string = await prepareDialogHtml('Edit', favorite.value, favorite.title, favorite.type); - await DIALOGS.setHtml(dialogEdit, dialogHtml); - const result: any = await DIALOGS.open(dialogEdit); - - // handle result - if (result.id == "ok" && result.formData != null) { - await favorites.changeTitle(value, result.formData.inputForm.title); - await favorites.changeValue(value, result.formData.inputForm.value); - await updatePanelView(); - } else if (result.id == "delete") { - await favorites.delete(value); - await updatePanelView(); - } else { - return; + await favorites.add(newValue, newTitle, type, targetIdx); + await panel.updateWebview(); } } @@ -378,24 +102,95 @@ joplin.plugins.register({ //#region COMMANDS + // Command: favsOpenFavorite (INTERNAL) + // Desc: Internal command to open a favorite + await COMMANDS.register({ + name: 'favsOpenFavorite', + execute: async (index: number) => { + const favorite: IFavorite = favorites.get(index); + if (!favorite) return; + + switch (favorite.type) { + case FavoriteType.Folder: + if (await checkAndRemoveFavorite(favorite, index)) return; + COMMANDS.execute('openFolder', favorite.value); + break; + + case FavoriteType.Note: + case FavoriteType.Todo: + if (await checkAndRemoveFavorite(favorite, index)) return; + await checkAndUpdateType(favorite, index); + COMMANDS.execute('openNote', favorite.value); + break; + + case FavoriteType.Tag: + if (await checkAndRemoveFavorite(favorite, index)) return; + COMMANDS.execute('openTag', favorite.value); + break; + + case FavoriteType.Search: + // TODO there is a command `~\app-desktop\gui\MainScreen\commands\search.ts` avaiable, but currently empty + // use this once it is implemented + + // currently there's no command to trigger a global search, so the following workaround is used + // 1. copy saved search to clipboard + const copy = require('../node_modules/copy-to-clipboard'); + copy(favorites.getDecodedValue(favorite)); + // 2. focus global search bar via command + await COMMANDS.execute('focusSearch'); + // 3. paste clipboard content to current cursor position (should be search bar now) + // TODO how? + break; + + default: + break; + } + + await panel.updateWebview(); + } + }); + + // Command: favsEditFavorite (INTERNAL) + // Desc: Internal command to edit a favorite + await COMMANDS.register({ + name: 'favsEditFavorite', + execute: async (index: number) => { + const favorite: IFavorite = favorites.get(index); + if (!favorite) return; + + // open dialog and handle result + const result: any = await editDialog.open(favorite.value, favorite.title, favorite.type); + if (result.id == "ok" && result.formData != null) { + await favorites.changeTitle(index, result.formData.inputForm.title); + await favorites.changeValue(index, result.formData.inputForm.value); + } else if (result.id == "delete") { + await favorites.delete(index); + } else { + return; + } + + await panel.updateWebview(); + } + }); + // Command: favsAddFolder // Desc: Add selected folder to favorites await COMMANDS.register({ name: 'favsAddFolder', - label: 'Favorites: Add notebook', + label: 'Add notebook to Favorites', iconName: 'fas fa-book', enabledCondition: 'oneFolderSelected', - execute: async (folderId: string) => { + execute: async (folderId: string, targetIdx?: number) => { if (folderId) { const folder = await DATA.get(['folders', folderId], { fields: ['id', 'title'] }); if (!folder) return; - await addFavorite(folder.id, folder.title, FavoriteType.Folder, editBeforeAdd); + await addFavorite(folder.id, folder.title, FavoriteType.Folder, settings.editBeforeAdd, targetIdx); } else { const selectedFolder: any = await WORKSPACE.selectedFolder(); if (!selectedFolder) return; - await addFavorite(selectedFolder.id, selectedFolder.title, FavoriteType.Folder, editBeforeAdd); + await addFavorite(selectedFolder.id, selectedFolder.title, FavoriteType.Folder, settings.editBeforeAdd, targetIdx); } } }); @@ -404,10 +199,10 @@ joplin.plugins.register({ // Desc: Add selected note to favorites await COMMANDS.register({ name: 'favsAddNote', - label: 'Favorites: Add note', + label: 'Add note to Favorites', iconName: 'fas fa-sticky-note', enabledCondition: "someNotesSelected", - execute: async (noteIds: string[]) => { + execute: async (noteIds: string[], targetIdx?: number) => { if (noteIds) { // in case multiple notes are selected - add them directly without user interaction @@ -418,14 +213,14 @@ joplin.plugins.register({ if (!note) return; // never show dialog for multiple notes - const showDialog: boolean = (editBeforeAdd && noteIds.length == 1); - await addFavorite(note.id, note.title, note.is_todo ? FavoriteType.Todo : FavoriteType.Note, showDialog); + const showDialog: boolean = (settings.editBeforeAdd && noteIds.length == 1); + await addFavorite(note.id, note.title, note.is_todo ? FavoriteType.Todo : FavoriteType.Note, showDialog, targetIdx); } } else { const selectedNote: any = await WORKSPACE.selectedNote(); if (!selectedNote) return; - await addFavorite(selectedNote.id, selectedNote.title, selectedNote.is_todo ? FavoriteType.Todo : FavoriteType.Note, editBeforeAdd); + await addFavorite(selectedNote.id, selectedNote.title, selectedNote.is_todo ? FavoriteType.Todo : FavoriteType.Note, settings.editBeforeAdd, targetIdx); } } }); @@ -434,14 +229,14 @@ joplin.plugins.register({ // Desc: Add tag to favorites await COMMANDS.register({ name: 'favsAddTag', - label: 'Favorites: Add tag', + label: 'Add tag to Favorites', iconName: 'fas fa-tag', execute: async (tagId: string) => { if (tagId) { const tag = await DATA.get(['tags', tagId], { fields: ['id', 'title'] }); if (!tag) return; - await addFavorite(tag.id, tag.title, FavoriteType.Tag, editBeforeAdd); + await addFavorite(tag.id, tag.title, FavoriteType.Tag, settings.editBeforeAdd); } } }); @@ -450,7 +245,7 @@ joplin.plugins.register({ // Desc: Add entered search query to favorites await COMMANDS.register({ name: 'favsAddSearch', - label: 'Favorites: Add Search', + label: 'Add new search to Favorites', iconName: 'fas fa-search', execute: async () => { await addFavorite('', 'New Search', FavoriteType.Search, true); // always add with dialog @@ -461,15 +256,15 @@ joplin.plugins.register({ // Desc: Remove all favorites await COMMANDS.register({ name: 'favsClear', - label: 'Favorites: Remove all favorites', + label: 'Remove all Favorites', iconName: 'fas fa-times', execute: async () => { // ask user before removing favorites - const result: number = await DIALOGS.showMessageBox(`Remove all favorites?`); + const result: number = await Dialog.showMessage('Do you really want to remove all Favorites?'); if (result) return; await favorites.clearAll(); - await updatePanelView(); + await panel.updateWebview(); } }); @@ -477,38 +272,37 @@ joplin.plugins.register({ // Desc: Toggle panel visibility await COMMANDS.register({ name: 'favsToggleVisibility', - label: 'Favorites: Toggle visibility', + label: 'Toggle Favorites panel visibility', iconName: 'fas fa-eye-slash', execute: async () => { - const isVisible: boolean = await PANELS.visible(panel); - await PANELS.show(panel, (!isVisible)); + await panel.toggleVisibility(); } }); - // prepare Tools > Favorites menu + // prepare commands menu const commandsSubMenu: MenuItem[] = [ { - commandName: "favsAddFolder", - label: 'Add selected notebook' + commandName: 'favsAddFolder', + label: 'Add active notebook' }, { - commandName: "favsAddNote", - label: 'Add selected note' + commandName: 'favsAddNote', + label: 'Add selected note(s)' }, { - commandName: "favsAddSearch", - label: 'Add search' + commandName: 'favsAddSearch', + label: 'Add new search' }, // { // commandName: "favsAddActiveSearch", // label: 'Add current active search' // }, { - commandName: "favsClear", - label: 'Remove all favorites' + commandName: 'favsClear', + label: 'Remove all Favorites' }, { - commandName: "favsToggleVisibility", + commandName: 'favsToggleVisibility', label: 'Toggle panel visibility' } ]; @@ -528,125 +322,14 @@ joplin.plugins.register({ //#endregion - //#region DIALOGS - - // prepare dialog objects - const dialogAdd = await DIALOGS.create('dialogAdd'); - await DIALOGS.addScript(dialogAdd, './assets/fontawesome/css/all.min.css'); - await DIALOGS.addScript(dialogAdd, './webview_dialog.css'); - - const dialogEdit = await DIALOGS.create('dialogEdit'); - await DIALOGS.addScript(dialogEdit, './assets/fontawesome/css/all.min.css'); - await DIALOGS.addScript(dialogEdit, './webview_dialog.css'); - await DIALOGS.setButtons(dialogEdit, [ - { id: 'delete', title: 'Delete', }, - { id: 'ok', title: 'OK' }, - { id: 'cancel', title: 'Cancel' } - ]); - - // prepare dialog HTML content - async function prepareDialogHtml(header: string, value: string, title: string, type: FavoriteType): Promise { - const path: string = await getFavoritePath(value, type); - const disabled: string = (type === FavoriteType.Search) ? '' : 'disabled'; - - return ` -
-

${header} ${FavoriteDesc[type].name} Favorite

-
- - - - -
-
- `; - } - - //#endregion - - //#region PANEL VIEW + //#region EVENTS - // prepare panel object - const panel = await PANELS.create('favorites.panel'); - await PANELS.addScript(panel, './assets/fontawesome/css/all.min.css'); - await PANELS.addScript(panel, './webview.css'); - await PANELS.addScript(panel, './webview.js'); - await PANELS.onMessage(panel, async (message: any) => { - if (message.name === 'favsAddFolder') { - await COMMANDS.execute('favsAddFolder', message.id); - } - if (message.name === 'favsAddNote') { - await COMMANDS.execute('favsAddNote', message.id); - } - if (message.name === 'favsEdit') { - editFavorite(message.id); - } - if (message.name === 'favsOpen') { - openFavorite(message.id); - } - if (message.name === 'favsDrag') { - await favorites.moveWithValue(message.sourceId, message.targetId); - await updatePanelView(); - } + SETTINGS.onChange(async (event: ChangeEvent) => { + await settings.read(event); + await panel.updateWebview(); }); - // set init message - await PANELS.setHtml(panel, ` -
-
-

Loading panel...

-
-
- `); - - // update HTML content - async function updatePanelView() { - const favsHtml: any = []; - - // prepare panel title if enabled - let panelTitleHtml: string = ''; - if (showPanelTitle) { - panelTitleHtml = ` -
- - FAVORITES -
- `; - } - - // create HTML for each favorite - for (const favorite of favorites.getAll()) { - const typeIconHtml: string = showTypeIcons ? `` : ''; - - favsHtml.push(` -
- - ${typeIconHtml} - - ${favorite.title} - - -
- `); - } - - // add entries to container and push to panel - await PANELS.setHtml(panel, ` -
-
- ${panelTitleHtml} - ${favsHtml.join('\n')} -
-
- `); - } - //#endregion - await readSettingsAndUpdate(); - }, + } }); diff --git a/src/manifest.json b/src/manifest.json index ed17485..3832d12 100644 --- a/src/manifest.json +++ b/src/manifest.json @@ -2,10 +2,24 @@ "manifest_version": 1, "id": "joplin.plugin.benji.favorites", "app_min_version": "1.6.5", - "version": "1.1.0", + "version": "1.2.0", "name": "Favorites", - "description": "Save any notebook, note, to-do, tag, or search as favorite in an extra panel view for quick access. (v1.1.0)", + "description": "Save any notebook, note, to-do, tag, or search as favorite in an extra panel view for quick access.", "author": "Benji300", "homepage_url": "https://github.com/benji300/joplin-favorites", - "repository_url": "https://github.com/benji300/joplin-favorites" + "repository_url": "https://github.com/benji300/joplin-favorites", + "keywords": [ + "favorite", + "shortcut", + "saved", + "quick", + "folder", + "notebook", + "note", + "todo", + "tag", + "search", + "panel", + "view" + ] } \ No newline at end of file diff --git a/src/panel.ts b/src/panel.ts new file mode 100644 index 0000000..652709f --- /dev/null +++ b/src/panel.ts @@ -0,0 +1,143 @@ +import joplin from 'api'; +import { FavoriteDesc, Favorites, FavoriteType } from './favorites'; +import { Settings } from './settings'; + +export class Panel { + private _panel: any; + private _favs: Favorites; + private _settings: Settings; + + constructor(favs: Favorites, settings: Settings) { + this._favs = favs; + this._settings = settings; + } + + /** + * Register plugin panel and update webview for the first time. + */ + async register() { + this._panel = await joplin.views.panels.create('favorites.panel'); + await joplin.views.panels.addScript(this._panel, './assets/fontawesome/css/all.min.css'); + await joplin.views.panels.addScript(this._panel, './webview.css'); + await joplin.views.panels.addScript(this._panel, './webview.js'); + await joplin.views.panels.onMessage(this._panel, async (message: any) => { + if (message.name === 'favsAddFolder') { + await joplin.commands.execute('favsAddFolder', message.id, message.targetIdx); + } + if (message.name === 'favsAddNote') { + await joplin.commands.execute('favsAddNote', message.id, message.targetIdx); + } + if (message.name === 'favsEdit') { + await joplin.commands.execute('favsEditFavorite', message.index); + } + if (message.name === 'favsOpen') { + await joplin.commands.execute('favsOpenFavorite', message.index); + } + if (message.name === 'favsRename') { + await this._favs.changeTitle(message.index, message.newTitle); + await this.updateWebview(); + } + if (message.name === 'favsDrag') { + await this._favs.moveWithIndex(message.index, message.targetIdx); + await this.updateWebview(); + } + if (message.name === 'favsDelete') { + await this._favs.delete(message.index); + await this.updateWebview(); + } + }); + + // set init message + await joplin.views.panels.setHtml(this._panel, ` +
+
+

Loading panel...

+
+
+ `); + + await this.updateWebview(); + } + + private getPanelTitleHtml(): string { + let panelTitleHtml: string = ''; + + if (this._settings.showPanelTitle) { + const fg = this._settings.foreground; + + panelTitleHtml = ` +
+ + FAVORITES +
+ `; + } + return panelTitleHtml; + } + + // create HTML for each favorite + private getFavoritesHtml(): string { + const favsHtml: any = []; + let index: number = 0; + + for (const favorite of this._favs.all) { + const fg = this._settings.foreground; + const bg = this._settings.background; + const hoverBg = this._settings.hoverBackground; + const dividerColor = this._settings.dividerColor; + + let typeIconHtml: string = ''; + if (this._settings.showTypeIcons) { + typeIconHtml = ``; + } + + favsHtml.push(` +
+ + ${typeIconHtml} + + + + + + +
+ `); + } + return favsHtml.join('\n'); + } + + async updateWebview() { + const panelTitleHtml: string = this.getPanelTitleHtml(); + const favsHtml: string = this.getFavoritesHtml(); + + // add entries to container and push to panel + await joplin.views.panels.setHtml(this._panel, ` +
+ ${panelTitleHtml} +
+ ${favsHtml} +
+
+
+ `); + + // store the current favorites array back to the settings + // - Currently there's no "event" to call store() only on App closing + // - Which would be preferred + await this._settings.storeFavorites(this._favs.all); + } + + /** + * Toggle visibility of the panel. + */ + async toggleVisibility() { + const isVisible: boolean = await joplin.views.panels.visible(this._panel); + await joplin.views.panels.show(this._panel, (!isVisible)); + } +} \ No newline at end of file diff --git a/src/settings.ts b/src/settings.ts new file mode 100644 index 0000000..e9b17a0 --- /dev/null +++ b/src/settings.ts @@ -0,0 +1,287 @@ +import joplin from 'api'; +import { SettingItemType } from 'api/types'; +import { ChangeEvent } from 'api/JoplinSettings'; + +/** + * Advanced style setting default values. + * Used when setting is set to 'default'. + */ +enum SettingDefaults { + Default = 'default', + FontFamily = 'Roboto', + FontSize = 'var(--joplin-font-size)', + Background = 'var(--joplin-background-color3)', + HoverBackground = 'var(--joplin-background-color-hover3)', // var(--joplin-background-hover) + Foreground = 'var(--joplin-color-faded)', + DividerColor = 'var(--joplin-divider-color)' +} + +/** + * Definitions of plugin settings. + */ +export class Settings { + // private settings + private _store: any[] = new Array(); + // general settings + private _enableDragAndDrop: boolean = true; + private _editBeforeAdd: boolean = true; + private _showPanelTitle: boolean = true; + private _showTypeIcons: boolean = true; + private _lineHeight: number = 30; + private _minFavoriteWidth: number = 15; + private _maxFavoriteWidth: number = 100; + // advanced settings + private _fontFamily: string = SettingDefaults.Default; + private _fontSize: string = SettingDefaults.Default; + private _background: string = SettingDefaults.Default; + private _hoverBackground: string = SettingDefaults.Default; + private _foreground: string = SettingDefaults.Default; + private _dividerColor: string = SettingDefaults.Default; + // internals + private _defaultRegExp: RegExp = new RegExp(SettingDefaults.Default, "i"); + + constructor() { + } + + //#region GETTER + + get favorites(): any[] { + return this._store; + } + + get enableDragAndDrop(): boolean { + return this._enableDragAndDrop; + } + + get editBeforeAdd(): boolean { + return this._editBeforeAdd; + } + + get showPanelTitle(): boolean { + return this._showPanelTitle; + } + + get showTypeIcons(): boolean { + return this._showTypeIcons; + } + + get lineHeight(): number { + return this._lineHeight; + } + + get minFavWidth(): number { + return this._minFavoriteWidth; + } + + get maxFavWidth(): number { + return this._maxFavoriteWidth; + } + + get fontFamily(): string { + return this._fontFamily; + } + + get fontSize(): string { + return this._fontSize; + } + + get background(): string { + return this._background; + } + + get hoverBackground(): string { + return this._hoverBackground; + } + + get foreground(): string { + return this._foreground; + } + + get dividerColor(): string { + return this._dividerColor; + } + + //#endregion + + //#region GLOBAL VALUES + + //#endregion + + /** + * Register settings section with all options and intially read them at the end. + */ + async register() { + // settings section + await joplin.settings.registerSection('favorites.settings', { + label: 'Favorites', + iconName: 'fas fa-star' + }); + + // private settings + await joplin.settings.registerSetting('favorites', { + value: [], + type: SettingItemType.Array, + section: 'favorites.settings', + public: false, + label: 'Favorites' + }); + this._store = await joplin.settings.value('favorites'); + + // general settings + await joplin.settings.registerSetting('enableDragAndDrop', { + value: this._enableDragAndDrop, + type: SettingItemType.Bool, + section: 'favorites.settings', + public: true, + label: 'Enable drag & drop of favorites', + description: 'If enabled, the position of favorites can be change via drag & drop.' + }); + await joplin.settings.registerSetting('editBeforeAdd', { + value: this._editBeforeAdd, + type: SettingItemType.Bool, + section: 'favorites.settings', + public: true, + label: 'Edit favorite before add', + description: 'Opens a dialog to edit the favorite before adding it. If disabled, the name can still be changed later.' + }); + await joplin.settings.registerSetting('showPanelTitle', { + value: this._showPanelTitle, + type: SettingItemType.Bool, + section: 'favorites.settings', + public: true, + label: 'Show favorites panel title', + description: "Display 'FAVORITES' title in front of the favorites." + }); + await joplin.settings.registerSetting('showTypeIcons', { + value: this._showTypeIcons, + type: SettingItemType.Bool, + section: 'favorites.settings', + public: true, + label: 'Show type icons for favorites', + description: 'Display icons before favorite titles representing the types (notebook, note, tag, etc.).' + }); + await joplin.settings.registerSetting('lineHeight', { + value: this._lineHeight, + type: SettingItemType.Int, + section: 'favorites.settings', + public: true, + minimum: 20, + label: 'Line height (px)', + description: 'Line height of the favorites panel.' + }); + await joplin.settings.registerSetting('minFavoriteWidth', { + value: this._minFavoriteWidth, + type: SettingItemType.Int, + section: 'favorites.settings', + public: true, + label: 'Minimum favorite width (px)', + description: 'Minimum width of one favorite in pixel.' + }); + await joplin.settings.registerSetting('maxFavoriteWidth', { + value: this._maxFavoriteWidth, + type: SettingItemType.Int, + section: 'favorites.settings', + public: true, + label: 'Maximum favorite width (px)', + description: 'Maximum width of one favorite in pixel.' + }); + + // advanced settings + await joplin.settings.registerSetting('fontFamily', { + value: this._fontFamily, + type: SettingItemType.String, + section: 'favorites.settings', + public: true, + advanced: true, + label: 'Font family', + description: "Font family used in the panel. Font families other than 'default' must be installed on the system. If the font is incorrect or empty, it might default to a generic sans-serif font. (default: Roboto)" + }); + await joplin.settings.registerSetting('fontSize', { + value: this._fontSize, + type: SettingItemType.String, + section: 'favorites.settings', + public: true, + advanced: true, + label: 'Font size', + description: "Font size used in the panel. Values other than 'default' must be specified in valid CSS syntax, e.g. '13px'. (default: App default font size)" + }); + await joplin.settings.registerSetting('mainBackground', { + value: this._background, + type: SettingItemType.String, + section: 'favorites.settings', + public: true, + advanced: true, + label: 'Background color', + description: 'Main background color of the panel. (default: Note list background color)' + }); + await joplin.settings.registerSetting('hoverBackground', { + value: this._hoverBackground, + type: SettingItemType.String, + section: 'favorites.settings', + public: true, + advanced: true, + label: 'Hover Background color', + description: 'Background color used when hovering a favorite. (default: Note list hover color)' + }); + await joplin.settings.registerSetting('mainForeground', { + value: this._foreground, + type: SettingItemType.String, + section: 'favorites.settings', + public: true, + advanced: true, + label: 'Foreground color', + description: 'Foreground color used for text and icons. (default: App faded color)' + }); + await joplin.settings.registerSetting('dividerColor', { + value: this._dividerColor, + type: SettingItemType.String, + section: 'favorites.settings', + public: true, + advanced: true, + label: 'Divider color', + description: 'Color of the divider between the favorites. (default: App default border color)' + }); + + // initially read settings + await this.read(); + } + + private async getOrDefault(event: ChangeEvent, localVar: any, setting: string, defaultValue?: string): Promise { + const read: boolean = (!event || event.keys.includes(setting)); + if (read) { + const value: string = await joplin.settings.value(setting); + if (defaultValue && value.match(this._defaultRegExp)) { + return defaultValue; + } else { + return value; + } + } + return localVar; + } + + /** + * Update settings. Either all or only changed ones. + */ + async read(event?: ChangeEvent) { + this._enableDragAndDrop = await this.getOrDefault(event, this._enableDragAndDrop, 'enableDragAndDrop'); + this._editBeforeAdd = await this.getOrDefault(event, this._editBeforeAdd, 'editBeforeAdd'); + this._showPanelTitle = await this.getOrDefault(event, this._showPanelTitle, 'showPanelTitle'); + this._showTypeIcons = await this.getOrDefault(event, this._showTypeIcons, 'showTypeIcons'); + this._lineHeight = await this.getOrDefault(event, this._lineHeight, 'lineHeight'); + this._minFavoriteWidth = await this.getOrDefault(event, this._minFavoriteWidth, 'minFavoriteWidth'); + this._maxFavoriteWidth = await this.getOrDefault(event, this._maxFavoriteWidth, 'maxFavoriteWidth'); + this._fontFamily = await this.getOrDefault(event, this._fontFamily, 'fontFamily', SettingDefaults.FontFamily); + this._fontSize = await this.getOrDefault(event, this._fontSize, 'fontSize', SettingDefaults.FontSize); + this._background = await this.getOrDefault(event, this._background, 'mainBackground', SettingDefaults.Background); + this._hoverBackground = await this.getOrDefault(event, this._hoverBackground, 'hoverBackground', SettingDefaults.HoverBackground); + this._foreground = await this.getOrDefault(event, this._foreground, 'mainForeground', SettingDefaults.Foreground); + this._dividerColor = await this.getOrDefault(event, this._dividerColor, 'dividerColor', SettingDefaults.DividerColor); + } + + /** + * Store the handled favorites array back to the settings. + */ + async storeFavorites(favorites: any[]) { + await joplin.settings.setValue('favorites', favorites); + } +} diff --git a/src/webview.css b/src/webview.css index c3d5913..642d987 100644 --- a/src/webview.css +++ b/src/webview.css @@ -26,18 +26,14 @@ span { } .fas { overflow: initial; + /* Regular does not support all required icons */ + /* font-weight: 400; */ } /* HORIZONTAL LAYOUT */ #container { - height: 100%; - overflow-x: auto; - overflow-y: hidden; - width: 100%; -} -#container-inner { display: flex; - float: left; + height: 100%; width: 100%; } @@ -47,17 +43,28 @@ span { padding: 0 5px; } +#favs-container { + display: flex; + float: left; + overflow-x: overlay; + overflow-y: overlay; + width: 100%; +} +#favs-container:empty { + min-width: 100%; +} #favorite { align-items: center; border-style: solid; border-width: 0; display: flex; - opacity: 0.8; } .favorite-inner { + align-items: center; border-width: 0; border-right-width: 1px; border-style: solid; + display: flex; padding: 0 3px; width: 100%; } @@ -65,6 +72,43 @@ span { border-style: none; } +input.title { + background: none; + border: none; + display: flex; + font-family: inherit; + font-size: inherit; + text-overflow: ellipsis; + width: 100%; +} +input.title:hover { + background: none; + cursor: default; +} +input.title:focus { + border-color: var(--joplin-warning-background-color); + border-radius: 3px; + border-style: solid; + border-width: 1px; + margin-right: 8px; + outline: none; + padding: 3px 1px; +} + +.controls { + border-radius: 3px; + display: none; + opacity: 0; + position: relative; + right: 10px; +} +.controls:hover { + opacity: 1; +} +.controls > .fas { + cursor: pointer; +} + /* DRAG AND DROP */ [draggable="true"] { /* To prevent user selecting inside the drag source */ @@ -73,12 +117,10 @@ span { -webkit-user-select: none; -ms-user-select: none; } -.dragging { - opacity: 0.4 !important; -} ::-webkit-scrollbar { height: 4px; + width: 7px; } ::-webkit-scrollbar-thumb { background: rgba(0, 0, 0, 0.2); @@ -88,19 +130,21 @@ span { /* VERCTICAL LAYOUT OVERWRITES */ @media screen and (max-width: 400px) { #container { - overflow-x: hidden; - overflow-y: auto; - } - - #container-inner { display: block; - width: 100%; } #panel-title { padding-left: 12px; } + #favs-container { + display: block; + height: 100% !important; + width: 100%; + } + #favs-container:empty { + min-height: 100% !important; + } #favorite { border-bottom-width: 1px; max-width: 100% !important; @@ -111,7 +155,7 @@ span { text-align: left; } - ::-webkit-scrollbar { - width: 7px; + .controls { + display: table; } } diff --git a/src/webview.js b/src/webview.js index 7a1bd41..761a250 100644 --- a/src/webview.js +++ b/src/webview.js @@ -1,72 +1,147 @@ +let editStarted = false; +let sourceIdx = ''; -function getDataId(event) { - if (event.currentTarget.id === 'favorite') { - return event.currentTarget.dataset.id; +function cancelDefault(event) { + event.preventDefault(); + event.stopPropagation(); + return false; +} + +function getDataId(currentTarget) { + if (currentTarget && currentTarget.id === 'favorite') { + return currentTarget.dataset.id; + } else { + return; } - return; } -/* RIGHT CLICK EVENT */ -function favsContext(event) { - const dataId = getDataId(event); - if (dataId) { - webviewApi.postMessage({ name: 'favsEdit', id: dataId }); +/* EVENT HANDLER */ + +function openFav(currentTarget) { + const dataIdx = getDataId(currentTarget); + if (dataIdx) { + webviewApi.postMessage({ name: 'favsOpen', index: dataIdx }); } } -/* CLICK EVENT */ -function favsClick(event) { - const dataId = getDataId(event); - if (dataId) { - webviewApi.postMessage({ name: 'favsOpen', id: dataId }); +function deleteFav(currentTarget) { + const dataIdx = getDataId(currentTarget); + if (dataIdx) { + webviewApi.postMessage({ name: 'favsDelete', index: dataIdx }); } } -/* DRAG AND DROP */ -let sourceId = ''; +function openDialog(event) { + if (!editStarted) { + const dataIdx = getDataId(event.currentTarget); + if (dataIdx) { + webviewApi.postMessage({ name: 'favsEdit', index: dataIdx }); + } + } +} -function cancelDefault(event) { - event.preventDefault(); - event.stopPropagation(); - return false; +function enableEdit(element, value) { + editStarted = value; + element.disabled = (!value); + element.focus(); + element.select(); } -function dragStart(event) { - const dataId = getDataId(event); - if (dataId) { - event.currentTarget.classList.add('dragging'); - event.dataTransfer.setData('text/x-plugin-favorites-id', dataId); - sourceId = dataId; +function editFav(currentTarget) { + const input = currentTarget.getElementsByTagName('input')[0]; + if (input) { + enableEdit(input, true); } } -function dragEnd(event) { +// default click handler +function clickFav(event) { cancelDefault(event); - event.currentTarget.classList.remove('dragging'); - document.querySelectorAll('#favorite').forEach(x => { - x.style.background = 'none'; - }); - sourceId = ''; + if (!editStarted) { + if (event.target.classList.contains('rename')) { + editFav(event.currentTarget); + } else if (event.target.classList.contains('delete')) { + deleteFav(event.currentTarget); + } else { + openFav(event.currentTarget); + } + } } -function dragOver(event, hoverColor) { +// rename finished with changes +document.addEventListener('change', event => { cancelDefault(event); - if (sourceId) { - const dataId = getDataId(event); - if (dataId) { - document.querySelectorAll('#favorite').forEach(x => { - if (x.dataset.id !== dataId) x.style.background = 'none'; - }); - - if (sourceId !== dataId) { - event.currentTarget.style.background = hoverColor; - } + const element = event.target; + if (editStarted && element.className === 'title') { + enableEdit(element, false); + const dataIdx = element.parentElement.parentElement.dataset.id; + if (dataIdx && element.value !== '') { + webviewApi.postMessage({ name: 'favsRename', index: dataIdx, newTitle: element.value }); + } else { + element.value = element.title; } } +}); + +// input lost focus (w/o changes) +document.addEventListener('focusout', (event) => { + cancelDefault(event); + const element = event.target; + if (editStarted && element.className === 'title') { + enableEdit(element, false); + element.value = element.title; + } +}); + +// scroll horizontally without 'shift' key +document.addEventListener('wheel', (event) => { + const element = document.getElementById('favs-container'); + if (element) { + element.scrollLeft -= (-event.deltaY); + } +}); + +/* DRAG AND DROP */ + +function setBackground(event, background) { + event.currentTarget.style.background = background; +} + +function resetBackground(element) { + if (element.dataset.bg) { + element.style.background = element.dataset.bg; + } +} + +function resetTabBackgrounds() { + document.querySelectorAll('#favorite').forEach(x => { resetBackground(x); }); + + container = document.querySelector('#favs-container'); + if (container) { + container.style.background = 'none'; + } +} + +function dragStart(event) { + const dataIdx = getDataId(event.currentTarget); + if (dataIdx) { + event.dataTransfer.setData('text/x-plugin-favorites-id', dataIdx); + sourceIdx = dataIdx; + } +} + +function dragEnd(event) { + resetTabBackgrounds(); + cancelDefault(event); + sourceIdx = ''; } -function dragOverTitle(event) { +function dragOver(event, hoverColor) { + resetTabBackgrounds(); cancelDefault(event); + if (sourceIdx !== getDataId(event.currentTarget)) { + setBackground(event, hoverColor); + } } function dragLeave(event) { @@ -74,42 +149,45 @@ function dragLeave(event) { } function drop(event) { + resetTabBackgrounds(); cancelDefault(event); - const dataSourceId = event.dataTransfer.getData('text/x-plugin-favorites-id'); - if (dataSourceId) { - const dataTargetId = getDataId(event); - if (dataTargetId !== sourceId) { - webviewApi.postMessage({ name: 'favsDrag', targetId: dataTargetId, sourceId: dataSourceId }); + const dataTargetIdx = getDataId(event.currentTarget); + + // check whether plugin tab was dragged - trigger favsDrag message + const dataSourceIdx = event.dataTransfer.getData('text/x-plugin-favorites-id'); + if (dataSourceIdx) { + if (dataTargetIdx !== sourceIdx) { + webviewApi.postMessage({ name: 'favsDrag', index: dataSourceIdx, targetIdx: dataTargetIdx, }); + return; } } -} - -function dropOnTitle(event) { - cancelDefault(event); // check whether folder was dragged from app onto the panel - trigger favsAddFolder then - const appDragFolderIds = event.dataTransfer.getData('text/x-jop-folder-ids'); - if (appDragFolderIds) { - const folderIds = JSON.parse(appDragFolderIds); + const joplinFolderIds = event.dataTransfer.getData('text/x-jop-folder-ids'); + if (joplinFolderIds) { + const folderIds = JSON.parse(joplinFolderIds); if (folderIds.length == 1) { - webviewApi.postMessage({ name: 'favsAddFolder', id: folderIds[0] }); + webviewApi.postMessage({ name: 'favsAddFolder', id: folderIds[0], targetIdx: dataTargetIdx }); + return; } } - // check whether note was dragged from app onto the panel - trigger favsAddNote then - const appDragNoteIds = event.dataTransfer.getData('text/x-jop-note-ids'); - if (appDragNoteIds) { - const ids = new Array(); - for (const noteId of JSON.parse(appDragNoteIds)) { - ids.push(noteId); + // check whether note was dragged from app onto the panel - add new favorite at dropped index + const joplinNoteIds = event.dataTransfer.getData('text/x-jop-note-ids'); + if (joplinNoteIds) { + const noteIds = new Array(); + for (const noteId of JSON.parse(joplinNoteIds)) { + noteIds.push(noteId); } - webviewApi.postMessage({ name: 'favsAddNote', id: ids }); + webviewApi.postMessage({ name: 'favsAddNote', id: noteIds, targetIdx: dataTargetIdx }); + return; } - // check whether tab (from joplin.plugin.note.tabs plugin) was dragged onto the panel - trigger favsAddNote then - const appDragTabId = event.dataTransfer.getData('text/x-plugin-note-tabs-id'); // 'text/plain' - if (appDragTabId) { - const ids = new Array(appDragTabId); - webviewApi.postMessage({ name: 'favsAddNote', id: ids }); + // check whether tab (from joplin.plugin.note.tabs plugin) was dragged onto the panel - add new favorite at dropped index + const noteTabsId = event.dataTransfer.getData('text/x-plugin-note-tabs-id'); + if (noteTabsId) { + const noteIds = new Array(noteTabsId); + webviewApi.postMessage({ name: 'favsAddNote', id: noteIds, targetIdx: dataTargetIdx }); + return; } }