1
0
mirror of https://github.com/laurent22/joplin.git synced 2025-10-31 00:07:48 +02:00

Desktop: Resolves ##5389: Add support for note list plugins (#8897)

This commit is contained in:
Laurent Cozic
2023-09-18 17:40:36 +01:00
committed by GitHub
parent c3c5612dc5
commit fa0740338d
180 changed files with 20691 additions and 221 deletions

View File

@@ -89,6 +89,14 @@ interface DatabaseTables {
@@ -555,6 +563,4 @@ export const databaseSchema: DatabaseTables = {
updated_time: { type: 'number' },
type_: { type: 'number' },
},
};
export type ItemRendererDatabaseDependency = 'folder.created_time' | 'folder.encryption_applied' | 'folder.encryption_cipher_text' | 'folder.icon' | 'folder.id' | 'folder.is_shared' | 'folder.master_key_id' | 'folder.parent_id' | 'folder.share_id' | 'folder.title' | 'folder.updated_time' | 'folder.user_created_time' | 'folder.user_updated_time' | 'folder.type_' | 'note.altitude' | 'note.application_data' | 'note.author' | 'note.body' | 'note.conflict_original_id' | 'note.created_time' | 'note.encryption_applied' | 'note.encryption_cipher_text' | 'note.id' | 'note.is_conflict' | 'note.is_shared' | 'note.is_todo' | 'note.latitude' | 'note.longitude' | 'note.markup_language' | 'note.master_key_id' | 'note.order' | 'note.parent_id' | 'note.share_id' | 'note.source' | 'note.source_application' | 'note.source_url' | 'note.title' | 'note.todo_completed' | 'note.todo_due' | 'note.updated_time' | 'note.user_created_time' | 'note.user_data' | 'note.user_updated_time' | 'note.type_';
};

View File

@@ -0,0 +1,170 @@
import { _ } from '../../locale';
import { MarkupLanguage, MarkupToHtml } from '@joplin/renderer';
import { ItemFlow, ListRenderer } from '../plugins/api/noteListType';
interface Props {
note: {
id: string;
title: string;
is_todo: number;
todo_completed: number;
body: string;
};
item: {
size: {
width: number;
height: number;
};
selected: boolean;
};
}
const defaultLeftToRightItemRenderer: ListRenderer = {
id: 'detailed',
label: async () => _('Detailed'),
flow: ItemFlow.LeftToRight,
itemSize: {
width: 150,
height: 150,
},
dependencies: [
'item.selected',
'item.size.width',
'item.size.height',
'note.body',
'note.id',
'note.is_shared',
'note.is_todo',
'note.isWatched',
'note.titleHtml',
'note.todo_completed',
],
itemCss: // css
`
&:before {
content: '';
border-bottom: 1px solid var(--joplin-divider-color);
width: 90%;
position: absolute;
bottom: 0;
left: 5%;
}
> .content.-selected {
background-color: var(--joplin-selected-color);
}
&:hover {
background-color: var(--joplin-background-color-hover3);
}
> .content {
display: flex;
box-sizing: border-box;
position: relative;
width: 100%;
padding: 16px;
align-items: flex-start;
overflow-y: hidden;
flex-direction: column;
user-select: none;
> .checkbox {
display: flex;
align-items: center;
> input {
margin: 0px 10px 1px 0px;
}
}
> .title {
font-family: var(--joplin-font-family);
font-size: var(--joplin-font-size);
color: var(--joplin-color);
cursor: default;
flex: 0;
display: flex;
align-items: flex-start;
margin-bottom: 8px;
> .checkbox {
margin: 0 6px 0 0;
}
> .watchedicon {
display: none;
padding-right: 4px;
color: var(--joplin-color);
}
> .titlecontent {
word-break: break-all;
overflow: hidden;
text-overflow: ellipsis;
text-wrap: nowrap;
}
}
> .preview {
overflow-y: hidden;
font-family: var(--joplin-font-family);
font-size: var(--joplin-font-size);
color: var(--joplin-color);
cursor: default;
}
}
> .content.-shared {
> .title {
color: var(--joplin-color-warn3);
}
}
> .content.-completed {
> .title {
opacity: 0.5;
text-decoration: line-through;
}
}
> .content.-watched {
> .title {
> .watchedicon {
display: inline;
}
}
}
`,
itemTemplate: // html
`
<div class="content {{#item.selected}}-selected{{/item.selected}} {{#note.is_shared}}-shared{{/note.is_shared}} {{#note.todo_completed}}-completed{{/note.todo_completed}} {{#note.isWatched}}-watched{{/note.isWatched}}">
<div style="width: {{titleWidth}}px;" class="title" data-id="{{note.id}}">
{{#note.is_todo}}
<input class="checkbox" data-id="todo-checkbox" type="checkbox" {{#note.todo_completed}}checked="checked"{{/note.todo_completed}}>
{{/note.is_todo}}
<i class="watchedicon fa fa-share-square"></i>
<div class="titlecontent">{{{note.titleHtml}}}</div>
</div>
<div class="preview">{{notePreview}}</div>
</div>
`,
onRenderNote: async (props: Props) => {
const markupToHtml_ = new MarkupToHtml();
return {
...props,
notePreview: markupToHtml_.stripMarkup(MarkupLanguage.Markdown, props.note.body).substring(0, 200),
titleWidth: props.item.size.width - 32,
};
},
};
export default defaultLeftToRightItemRenderer;

View File

@@ -0,0 +1,139 @@
import { _ } from '../../locale';
import { ItemFlow, ListRenderer } from '../plugins/api/noteListType';
interface Props {
note: {
id: string;
title: string;
is_todo: number;
todo_completed: number;
};
item: {
size: {
height: number;
};
selected: boolean;
};
}
const defaultListRenderer: ListRenderer = {
id: 'compact',
label: async () => _('Compact'),
flow: ItemFlow.TopToBottom,
itemSize: {
width: 0,
height: 34,
},
dependencies: [
'item.selected',
'item.size.height',
'note.id',
'note.is_shared',
'note.is_todo',
'note.isWatched',
'note.titleHtml',
'note.todo_completed',
],
itemCss: // css
`
&:before {
content: '';
border-bottom: 1px solid var(--joplin-divider-color);
width: 90%;
position: absolute;
bottom: 0;
left: 5%;
}
> .content.-selected {
background-color: var(--joplin-selected-color);
}
&:hover {
background-color: var(--joplin-background-color-hover3);
}
> .content {
display: flex;
box-sizing: border-box;
position: relative;
width: 100%;
padding-left: 16px;
> .checkbox {
display: flex;
align-items: center;
> input {
margin: 0px 10px 1px 0px;
}
}
> .title {
font-family: var(--joplin-font-family);
font-size: var(--joplin-font-size);
text-decoration: none;
color: var(--joplin-color);
cursor: default;
white-space: nowrap;
flex: 1 1 0%;
display: flex;
align-items: center;
overflow: hidden;
> .watchedicon {
display: none;
padding-right: 4px;
color: var(--joplin-color);
}
}
}
> .content.-shared {
> .title {
color: var(--joplin-color-warn3);
}
}
> .content.-completed {
> .title {
opacity: 0.5;
text-decoration: line-through;
}
}
> .content.-watched {
> .title {
> .watchedicon {
display: inline;
}
}
}
`,
itemTemplate: // html
`
<div class="content {{#item.selected}}-selected{{/item.selected}} {{#note.is_shared}}-shared{{/note.is_shared}} {{#note.todo_completed}}-completed{{/note.todo_completed}} {{#note.isWatched}}-watched{{/note.isWatched}}">
{{#note.is_todo}}
<div class="checkbox">
<input data-id="todo-checkbox" type="checkbox" {{#note.todo_completed}}checked="checked"{{/note.todo_completed}}>
</div>
{{/note.is_todo}}
<div class="title" data-id="{{note.id}}">
<i class="watchedicon fa fa-share-square"></i>
<span>{{{note.titleHtml}}}</span>
</div>
</div>
`,
onRenderNote: async (props: Props) => {
return props;
},
};
export default defaultListRenderer;

View File

@@ -0,0 +1,30 @@
import { ListRenderer } from '../plugins/api/noteListType';
// import defaultLeftToRightItemRenderer from '../noteList/defaultLeftToRightListRenderer';
import defaultListRenderer from '../noteList/defaultListRenderer';
import { Store } from 'redux';
const renderers_: ListRenderer[] = [
defaultListRenderer,
// defaultLeftToRightItemRenderer,
];
export const getListRendererIds = () => {
return renderers_.map(r => r.id);
};
export const getDefaultListRenderer = () => {
return renderers_[0];
};
export const getListRendererById = (id: string) => {
return renderers_.find(r => r.id === id);
};
export const registerRenderer = async (store: Store, renderer: ListRenderer) => {
renderers_.push(renderer);
store.dispatch({
type: 'NOTE_LIST_RENDERER_ADD',
value: renderer.id,
});
};

View File

@@ -6,6 +6,7 @@ import JoplinViewsMenuItems from './JoplinViewsMenuItems';
import JoplinViewsMenus from './JoplinViewsMenus';
import JoplinViewsToolbarButtons from './JoplinViewsToolbarButtons';
import JoplinViewsPanels from './JoplinViewsPanels';
import JoplinViewsNoteList from './JoplinViewsNoteList';
/**
* This namespace provides access to view-related services.
@@ -18,11 +19,12 @@ export default class JoplinViews {
private store: any;
private plugin: Plugin;
private dialogs_: JoplinViewsDialogs = null;
private panels_: JoplinViewsPanels = null;
private menuItems_: JoplinViewsMenuItems = null;
private menus_: JoplinViewsMenus = null;
private toolbarButtons_: JoplinViewsToolbarButtons = null;
private dialogs_: JoplinViewsDialogs = null;
private noteList_: JoplinViewsNoteList = null;
private implementation_: any = null;
public constructor(implementation: any, plugin: Plugin, store: any) {
@@ -31,29 +33,34 @@ export default class JoplinViews {
this.implementation_ = implementation;
}
public get dialogs(): JoplinViewsDialogs {
public get dialogs() {
if (!this.dialogs_) this.dialogs_ = new JoplinViewsDialogs(this.implementation_.dialogs, this.plugin, this.store);
return this.dialogs_;
}
public get panels(): JoplinViewsPanels {
public get panels() {
if (!this.panels_) this.panels_ = new JoplinViewsPanels(this.plugin, this.store);
return this.panels_;
}
public get menuItems(): JoplinViewsMenuItems {
public get menuItems() {
if (!this.menuItems_) this.menuItems_ = new JoplinViewsMenuItems(this.plugin, this.store);
return this.menuItems_;
}
public get menus(): JoplinViewsMenus {
public get menus() {
if (!this.menus_) this.menus_ = new JoplinViewsMenus(this.plugin, this.store);
return this.menus_;
}
public get toolbarButtons(): JoplinViewsToolbarButtons {
public get toolbarButtons() {
if (!this.toolbarButtons_) this.toolbarButtons_ = new JoplinViewsToolbarButtons(this.plugin, this.store);
return this.toolbarButtons_;
}
public get noteList() {
if (!this.noteList_) this.noteList_ = new JoplinViewsNoteList(this.plugin, this.store);
return this.noteList_;
}
}

View File

@@ -0,0 +1,40 @@
/* eslint-disable multiline-comment-style */
import { Store } from 'redux';
import { registerRenderer } from '../../noteList/renderers';
import Plugin from '../Plugin';
import { ListRenderer } from './noteListType';
/**
* This API allows you to customise how each note in the note list is rendered.
* The renderer you implement follows a unidirectional data flow.
*
* The app provides the required dependencies whenever a note is updated - you
* process these dependencies, and return some props, which are then passed to
* your template and rendered. See [[[ListRenderer]]] for a detailed description
* of each property of the renderer.
*
* [View the demo plugin](https://github.com/laurent22/joplin/tree/dev/packages/app-cli/tests/support/plugins/note_list_renderer)
*
* The default list renderer is implemented using the same API, so it worth checking it too:
*
* [Default list renderer](https://github.com/laurent22/joplin/tree/dev/packages/lib/services/noteList/defaultListRenderer.ts)
*/
export default class JoplinViewsNoteList {
private plugin_: Plugin;
private store_: Store;
public constructor(plugin: Plugin, store: Store) {
this.plugin_ = plugin;
this.store_ = store;
}
public async registerRenderer(renderer: ListRenderer) {
await registerRenderer(this.store_, {
...renderer,
id: `${this.plugin_.id}:${renderer.id}`,
});
}
}

View File

@@ -0,0 +1,159 @@
import { Size } from './types';
// AUTO-GENERATED by generate-database-type
type ListRendererDatabaseDependency = 'folder.created_time' | 'folder.encryption_applied' | 'folder.encryption_cipher_text' | 'folder.icon' | 'folder.id' | 'folder.is_shared' | 'folder.master_key_id' | 'folder.parent_id' | 'folder.share_id' | 'folder.title' | 'folder.updated_time' | 'folder.user_created_time' | 'folder.user_updated_time' | 'folder.type_' | 'note.altitude' | 'note.application_data' | 'note.author' | 'note.body' | 'note.conflict_original_id' | 'note.created_time' | 'note.encryption_applied' | 'note.encryption_cipher_text' | 'note.id' | 'note.is_conflict' | 'note.is_shared' | 'note.is_todo' | 'note.latitude' | 'note.longitude' | 'note.markup_language' | 'note.master_key_id' | 'note.order' | 'note.parent_id' | 'note.share_id' | 'note.source' | 'note.source_application' | 'note.source_url' | 'note.title' | 'note.todo_completed' | 'note.todo_due' | 'note.updated_time' | 'note.user_created_time' | 'note.user_data' | 'note.user_updated_time' | 'note.type_';
// AUTO-GENERATED by generate-database-type
export enum ItemFlow {
TopToBottom = 'topToBottom',
LeftToRight = 'leftToRight',
}
export type RenderNoteView = Record<string, any>;
export interface OnChangeEvent {
elementId: string;
value: any;
noteId: string;
}
export type OnRenderNoteHandler = (props: any)=> Promise<RenderNoteView>;
export type OnChangeHandler = (event: OnChangeEvent)=> Promise<void>;
// Most of these are the built-in note properties, such as `note.title`,
// `note.todo_completed`, etc.
//
// Additionally, the `item.*` properties are specific to the rendered item. The
// most important being `item.selected`, which you can use to display the
// selected note in a different way.
//
// Finally some special properties are provided to make it easier to render
// notes. In particular, if possible prefer `note.titleHtml` to `note.title`
// since some important processing has already been done on the string, such as
// handling the search highlighter and escaping. Since it's HTML and already
// escaped you would insert it using `{{{titleHtml}}}` (triple-mustache syntax,
// which disables escaping).
//
// `notes.tag` gives you the list of tags associated with the note.
//
// `note.isWatched` tells you if the note is currently opened in an external
// editor. In which case you would generally display some indicator.
export type ListRendererDepependency =
ListRendererDatabaseDependency |
'item.size.width' |
'item.size.height' |
'item.selected' |
'note.titleHtml' |
'note.isWatched' |
'note.tags';
export interface ListRenderer {
// It must be unique to your plugin.
id: string;
// Can be top to bottom or left to right. Left to right gives you more
// option to set the size of the items since you set both its width and
// height.
flow: ItemFlow;
// The size of each item must be specified in advance for performance
// reasons, and cannot be changed afterwards. If the item flow is top to
// bottom, you only need to specificy the item height (the width will be
// ignored).
itemSize: Size;
// The CSS is relative to the list item container. What will appear in the
// page is essentially `.note-list-item { YOUR_CSS; }`. It means you can use
// child combinator with guarantee it will only apply to your own items. In
// this example, the styling will apply to `.note-list-item > .content`:
//
// ```css
// > .content {
// padding: 10px;
// }
// ```
//
// In order to get syntax highlighting working here, it's recommended
// installing an editor extension such as [es6-string-html VSCode
// extension](https://marketplace.visualstudio.com/items?itemName=Tobermory.es6-string-html)
itemCss?: string;
// List the dependencies that your plugin needs to render the note list
// items. Only these will be passed to your `onRenderNote` handler. Ensure
// that you do not add more than what you need since there is a performance
// penalty for each property.
dependencies: ListRendererDepependency[];
// This is the HTML template that will be used to render the note list item.
// This is a [Mustache template](https://github.com/janl/mustache.js) and it
// will receive the variable you return from `onRenderNote` as tags. For
// example, if you return a property named `formattedDate` from
// `onRenderNote`, you can insert it in the template using `Created date:
// {{formattedDate}}`.
//
// In order to get syntax highlighting working here, it's recommended
// installing an editor extension such as [es6-string-html VSCode
// extension](https://marketplace.visualstudio.com/items?itemName=Tobermory.es6-string-html)
itemTemplate: string;
// This user-facing text is used for example in the View menu, so that your
// renderer can be selected.
label: ()=> Promise<string>;
// This is where most of the real-time processing will happen. When a note
// is rendered for the first time and every time it changes, this handler
// receives the properties specified in the `dependencies` property. You can
// then process them, load any additional data you need, and once done you
// need to return the properties that are needed in the `itemTemplate` HTML.
// Again, to use the formatted date example, you could have such a renderer:
//
// ```typescript
// dependencies: [
// 'note.title',
// 'note.created_time',
// ],
//
// itemTemplate: // html
// `
// <div>
// Title: {{note.title}}<br/>
// Date: {{formattedDate}}
// </div>
// `,
//
// onRenderNote: async (props: any) => {
// const formattedDate = dayjs(props.note.created_time).format();
// return {
// // Also return the props, so that note.title is available from the
// // template
// ...props,
// formattedDate,
// }
// },
// ```
onRenderNote: OnRenderNoteHandler;
// This handler allows adding some interacivity to the note renderer -
// whenever an input element within the item is changed (for example, when a
// checkbox is clicked, or a text input is changed), this `onChange` handler
// is going to be called.
//
// You can inspect `event.elementId` to know which element had some changes,
// and `event.value` to know the new value. `event.noteId` also tells you
// what note is affected, so that you can potentially apply changes to it.
//
// You specify the element ID, by setting a `data-id` attribute on the
// input.
//
// For example, if you have such a template:
//
// ```html
// <div>
// <input type="text" value="{{note.title}}" data-id="noteTitleInput"/>
// </div>
// ```
//
// The event handler will receive an event with `elementId` set to
// `noteTitleInput`.
onChange?: OnChangeHandler;
}

View File

@@ -359,6 +359,11 @@ export interface DialogResult {
formData?: any;
}
export interface Size {
width?: number;
height?: number;
}
export interface Rectangle {
x?: number;
y?: number;

View File

@@ -44,6 +44,7 @@ export interface PluginHtmlContents {
export interface State {
plugins: PluginStates;
pluginHtmlContents: PluginHtmlContents;
allPluginsStarted: boolean;
}
export const stateRootKey = 'pluginService';
@@ -51,6 +52,7 @@ export const stateRootKey = 'pluginService';
export const defaultState: State = {
plugins: {},
pluginHtmlContents: {},
allPluginsStarted: false,
};
export const utils = {
@@ -162,6 +164,11 @@ const reducer = (draftRoot: Draft<any>, action: any) => {
(draft.plugins[action.pluginId].views[action.id] as any)[action.name].push(action.value);
break;
case 'PLUGIN_All_STARTED_SET':
draft.allPluginsStarted = action.value;
break;
case 'PLUGIN_CONTENT_SCRIPTS_ADD': {
const type = action.contentScript.type;