diff --git a/package-lock.json b/package-lock.json index a32cc9615..842410e2a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,6 +12,9 @@ "@braintree/sanitize-url": "7.1.1", "@electron/remote": "2.1.2", "@excalidraw/excalidraw": "0.17.6", + "@fullcalendar/core": "6.1.15", + "@fullcalendar/daygrid": "6.1.15", + "@fullcalendar/interaction": "6.1.15", "@highlightjs/cdn-assets": "11.11.1", "@joplin/turndown-plugin-gfm": "1.0.61", "@mermaid-js/layout-elk": "0.1.7", @@ -2124,6 +2127,43 @@ "react-dom": "^17.0.2 || ^18.2.0" } }, + "node_modules/@fullcalendar/core": { + "version": "6.1.15", + "resolved": "https://registry.npmjs.org/@fullcalendar/core/-/core-6.1.15.tgz", + "integrity": "sha512-BuX7o6ALpLb84cMw1FCB9/cSgF4JbVO894cjJZ6kP74jzbUZNjtwffwRdA+Id8rrLjT30d/7TrkW90k4zbXB5Q==", + "license": "MIT", + "dependencies": { + "preact": "~10.12.1" + } + }, + "node_modules/@fullcalendar/core/node_modules/preact": { + "version": "10.12.1", + "resolved": "https://registry.npmjs.org/preact/-/preact-10.12.1.tgz", + "integrity": "sha512-l8386ixSsBdbreOAkqtrwqHwdvR35ID8c3rKPa8lCWuO86dBi32QWHV4vfsZK1utLLFMvw+Z5Ad4XLkZzchscg==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/preact" + } + }, + "node_modules/@fullcalendar/daygrid": { + "version": "6.1.15", + "resolved": "https://registry.npmjs.org/@fullcalendar/daygrid/-/daygrid-6.1.15.tgz", + "integrity": "sha512-j8tL0HhfiVsdtOCLfzK2J0RtSkiad3BYYemwQKq512cx6btz6ZZ2RNc/hVnIxluuWFyvx5sXZwoeTJsFSFTEFA==", + "license": "MIT", + "peerDependencies": { + "@fullcalendar/core": "~6.1.15" + } + }, + "node_modules/@fullcalendar/interaction": { + "version": "6.1.15", + "resolved": "https://registry.npmjs.org/@fullcalendar/interaction/-/interaction-6.1.15.tgz", + "integrity": "sha512-DOTSkofizM7QItjgu7W68TvKKvN9PSEEvDJceyMbQDvlXHa7pm/WAVtAc6xSDZ9xmB1QramYoWGLHkCYbTW1rQ==", + "license": "MIT", + "peerDependencies": { + "@fullcalendar/core": "~6.1.15" + } + }, "node_modules/@gar/promisify": { "version": "1.1.3", "resolved": "https://registry.npmjs.org/@gar/promisify/-/promisify-1.1.3.tgz", diff --git a/package.json b/package.json index bd65afbd8..d48cdaf6e 100644 --- a/package.json +++ b/package.json @@ -70,6 +70,9 @@ "@braintree/sanitize-url": "7.1.1", "@electron/remote": "2.1.2", "@excalidraw/excalidraw": "0.17.6", + "@fullcalendar/core": "6.1.15", + "@fullcalendar/daygrid": "6.1.15", + "@fullcalendar/interaction": "6.1.15", "@highlightjs/cdn-assets": "11.11.1", "@joplin/turndown-plugin-gfm": "1.0.61", "@mermaid-js/layout-elk": "0.1.7", diff --git a/src/public/app/components/note_context.ts b/src/public/app/components/note_context.ts index 7656894c9..6179ac57f 100644 --- a/src/public/app/components/note_context.ts +++ b/src/public/app/components/note_context.ts @@ -290,7 +290,7 @@ class NoteContext extends Component implements EventListener<"entitiesReloaded"> return ( this.note && ["default", "contextual-help"].includes(this.viewScope?.viewMode ?? "") && - this.note.hasChildren() && + (this.note.hasChildren() || this.note.getLabelValue("viewType") === "calendar") && ["book", "text", "code"].includes(this.note.type) && this.note.mime !== "text/x-sqlite;schema=trilium" && !this.note.isLabelTruthy("hideChildrenOverview") diff --git a/src/public/app/doc_notes/en/User Guide/!!!meta.json b/src/public/app/doc_notes/en/User Guide/!!!meta.json index f7ee2b545..b90fd4f50 100644 --- a/src/public/app/doc_notes/en/User Guide/!!!meta.json +++ b/src/public/app/doc_notes/en/User Guide/!!!meta.json @@ -11,7 +11,7 @@ "title": "User Guide", "notePosition": 20, "prefix": null, - "isExpanded": true, + "isExpanded": false, "type": "text", "mime": "text/html", "attributes": [ @@ -166,7 +166,7 @@ "title": "Features", "notePosition": 40, "prefix": null, - "isExpanded": false, + "isExpanded": true, "type": "text", "mime": "text/html", "attributes": [], @@ -314,7 +314,7 @@ "title": "Note Types", "notePosition": 70, "prefix": null, - "isExpanded": false, + "isExpanded": true, "type": "text", "mime": "text/html", "attributes": [], @@ -536,6 +536,208 @@ "dataFileName": "19_Geo map_image.png" } ] + }, + { + "isClone": false, + "noteId": "pSDzQIgLGswQ", + "notePath": [ + "OkOZllzB3fqN", + "wmegHv51MJMd", + "pSDzQIgLGswQ" + ], + "title": "Book", + "notePosition": 30, + "prefix": null, + "isExpanded": true, + "type": "text", + "mime": "text/html", + "attributes": [ + { + "type": "label", + "name": "iconClass", + "value": "bx bx-book-alt", + "isInheritable": false, + "position": 10 + } + ], + "format": "html", + "attachments": [], + "dirFileName": "Book", + "children": [ + { + "isClone": false, + "noteId": "fDGg7QcJg3Xm", + "notePath": [ + "OkOZllzB3fqN", + "wmegHv51MJMd", + "pSDzQIgLGswQ", + "fDGg7QcJg3Xm" + ], + "title": "Calendar View", + "notePosition": 10, + "prefix": null, + "isExpanded": false, + "type": "text", + "mime": "text/html", + "attributes": [ + { + "type": "label", + "name": "iconClass", + "value": "bx bx-calendar", + "isInheritable": false, + "position": 10 + } + ], + "format": "html", + "dataFileName": "Calendar View.html", + "attachments": [ + { + "attachmentId": "j1NIQJvjsFrc", + "title": "image.png", + "role": "image", + "mime": "image/png", + "position": 10, + "dataFileName": "Calendar View_image.png" + }, + { + "attachmentId": "9FxGltAPWr9V", + "title": "image.png", + "role": "image", + "mime": "image/png", + "position": 10, + "dataFileName": "1_Calendar View_image.png" + }, + { + "attachmentId": "8kfaJPGjJ1t5", + "title": "image.png", + "role": "image", + "mime": "image/png", + "position": 10, + "dataFileName": "2_Calendar View_image.png" + }, + { + "attachmentId": "GaH4K6lKfcQe", + "title": "image.png", + "role": "image", + "mime": "image/png", + "position": 10, + "dataFileName": "3_Calendar View_image.png" + }, + { + "attachmentId": "xr4c0Mdf7gPm", + "title": "image.png", + "role": "image", + "mime": "image/png", + "position": 10, + "dataFileName": "4_Calendar View_image.png" + }, + { + "attachmentId": "K8NQktF9sCss", + "title": "image.png", + "role": "image", + "mime": "image/png", + "position": 10, + "dataFileName": "5_Calendar View_image.png" + }, + { + "attachmentId": "fFaq1mWTFlJA", + "title": "image.png", + "role": "image", + "mime": "image/png", + "position": 10, + "dataFileName": "6_Calendar View_image.png" + }, + { + "attachmentId": "2CExLYphNtCd", + "title": "image.png", + "role": "image", + "mime": "image/png", + "position": 10, + "dataFileName": "7_Calendar View_image.png" + }, + { + "attachmentId": "UaXBPb7fINm4", + "title": "image.png", + "role": "image", + "mime": "image/png", + "position": 10, + "dataFileName": "8_Calendar View_image.png" + }, + { + "attachmentId": "TIzqtnGIPlxu", + "title": "image.png", + "role": "image", + "mime": "image/png", + "position": 10, + "dataFileName": "9_Calendar View_image.png" + }, + { + "attachmentId": "p7eRe4TFFdIt", + "title": "image.png", + "role": "image", + "mime": "image/png", + "position": 10, + "dataFileName": "10_Calendar View_image.png" + }, + { + "attachmentId": "bnKESYv4Toa1", + "title": "image.png", + "role": "image", + "mime": "image/png", + "position": 10, + "dataFileName": "11_Calendar View_image.png" + }, + { + "attachmentId": "MwECr6EjQjEE", + "title": "image.png", + "role": "image", + "mime": "image/png", + "position": 10, + "dataFileName": "12_Calendar View_image.png" + }, + { + "attachmentId": "0J8MfQPq7E1H", + "title": "image.png", + "role": "image", + "mime": "image/png", + "position": 10, + "dataFileName": "13_Calendar View_image.png" + }, + { + "attachmentId": "0yGXmgB3yfGg", + "title": "image.png", + "role": "image", + "mime": "image/png", + "position": 10, + "dataFileName": "14_Calendar View_image.png" + }, + { + "attachmentId": "XBOyB2RH28OS", + "title": "image.png", + "role": "image", + "mime": "image/png", + "position": 10, + "dataFileName": "15_Calendar View_image.png" + }, + { + "attachmentId": "BsiAqW51VJOz", + "title": "image.png", + "role": "image", + "mime": "image/png", + "position": 10, + "dataFileName": "16_Calendar View_image.png" + }, + { + "attachmentId": "RTFdV19BHn28", + "title": "image.png", + "role": "image", + "mime": "image/png", + "position": 10, + "dataFileName": "17_Calendar View_image.png" + } + ] + } + ] } ] }, @@ -624,7 +826,7 @@ "title": "Examples", "notePosition": 10, "prefix": null, - "isExpanded": true, + "isExpanded": false, "type": "text", "mime": "text/html", "attributes": [], @@ -895,7 +1097,7 @@ "title": "ETAPI", "notePosition": 10, "prefix": null, - "isExpanded": true, + "isExpanded": false, "type": "text", "mime": "text/html", "attributes": [], @@ -945,7 +1147,7 @@ "title": "Internal API", "notePosition": 20, "prefix": null, - "isExpanded": true, + "isExpanded": false, "type": "text", "mime": "text/html", "attributes": [], diff --git a/src/public/app/doc_notes/en/User Guide/User Guide/Note Types/Book/10_Calendar View_image.png b/src/public/app/doc_notes/en/User Guide/User Guide/Note Types/Book/10_Calendar View_image.png new file mode 100644 index 000000000..f60aa0acc Binary files /dev/null and b/src/public/app/doc_notes/en/User Guide/User Guide/Note Types/Book/10_Calendar View_image.png differ diff --git a/src/public/app/doc_notes/en/User Guide/User Guide/Note Types/Book/11_Calendar View_image.png b/src/public/app/doc_notes/en/User Guide/User Guide/Note Types/Book/11_Calendar View_image.png new file mode 100644 index 000000000..7e97245a6 Binary files /dev/null and b/src/public/app/doc_notes/en/User Guide/User Guide/Note Types/Book/11_Calendar View_image.png differ diff --git a/src/public/app/doc_notes/en/User Guide/User Guide/Note Types/Book/12_Calendar View_image.png b/src/public/app/doc_notes/en/User Guide/User Guide/Note Types/Book/12_Calendar View_image.png new file mode 100644 index 000000000..b594af6a6 Binary files /dev/null and b/src/public/app/doc_notes/en/User Guide/User Guide/Note Types/Book/12_Calendar View_image.png differ diff --git a/src/public/app/doc_notes/en/User Guide/User Guide/Note Types/Book/13_Calendar View_image.png b/src/public/app/doc_notes/en/User Guide/User Guide/Note Types/Book/13_Calendar View_image.png new file mode 100644 index 000000000..d3d64f75c Binary files /dev/null and b/src/public/app/doc_notes/en/User Guide/User Guide/Note Types/Book/13_Calendar View_image.png differ diff --git a/src/public/app/doc_notes/en/User Guide/User Guide/Note Types/Book/14_Calendar View_image.png b/src/public/app/doc_notes/en/User Guide/User Guide/Note Types/Book/14_Calendar View_image.png new file mode 100644 index 000000000..d588ed568 Binary files /dev/null and b/src/public/app/doc_notes/en/User Guide/User Guide/Note Types/Book/14_Calendar View_image.png differ diff --git a/src/public/app/doc_notes/en/User Guide/User Guide/Note Types/Book/15_Calendar View_image.png b/src/public/app/doc_notes/en/User Guide/User Guide/Note Types/Book/15_Calendar View_image.png new file mode 100644 index 000000000..71f7d2dc3 Binary files /dev/null and b/src/public/app/doc_notes/en/User Guide/User Guide/Note Types/Book/15_Calendar View_image.png differ diff --git a/src/public/app/doc_notes/en/User Guide/User Guide/Note Types/Book/16_Calendar View_image.png b/src/public/app/doc_notes/en/User Guide/User Guide/Note Types/Book/16_Calendar View_image.png new file mode 100644 index 000000000..46698faac Binary files /dev/null and b/src/public/app/doc_notes/en/User Guide/User Guide/Note Types/Book/16_Calendar View_image.png differ diff --git a/src/public/app/doc_notes/en/User Guide/User Guide/Note Types/Book/17_Calendar View_image.png b/src/public/app/doc_notes/en/User Guide/User Guide/Note Types/Book/17_Calendar View_image.png new file mode 100644 index 000000000..fceb0563c Binary files /dev/null and b/src/public/app/doc_notes/en/User Guide/User Guide/Note Types/Book/17_Calendar View_image.png differ diff --git a/src/public/app/doc_notes/en/User Guide/User Guide/Note Types/Book/1_Calendar View_image.png b/src/public/app/doc_notes/en/User Guide/User Guide/Note Types/Book/1_Calendar View_image.png new file mode 100644 index 000000000..71d6c38f1 Binary files /dev/null and b/src/public/app/doc_notes/en/User Guide/User Guide/Note Types/Book/1_Calendar View_image.png differ diff --git a/src/public/app/doc_notes/en/User Guide/User Guide/Note Types/Book/2_Calendar View_image.png b/src/public/app/doc_notes/en/User Guide/User Guide/Note Types/Book/2_Calendar View_image.png new file mode 100644 index 000000000..d8d8f87c1 Binary files /dev/null and b/src/public/app/doc_notes/en/User Guide/User Guide/Note Types/Book/2_Calendar View_image.png differ diff --git a/src/public/app/doc_notes/en/User Guide/User Guide/Note Types/Book/3_Calendar View_image.png b/src/public/app/doc_notes/en/User Guide/User Guide/Note Types/Book/3_Calendar View_image.png new file mode 100644 index 000000000..052cddb18 Binary files /dev/null and b/src/public/app/doc_notes/en/User Guide/User Guide/Note Types/Book/3_Calendar View_image.png differ diff --git a/src/public/app/doc_notes/en/User Guide/User Guide/Note Types/Book/4_Calendar View_image.png b/src/public/app/doc_notes/en/User Guide/User Guide/Note Types/Book/4_Calendar View_image.png new file mode 100644 index 000000000..bec9d93e1 Binary files /dev/null and b/src/public/app/doc_notes/en/User Guide/User Guide/Note Types/Book/4_Calendar View_image.png differ diff --git a/src/public/app/doc_notes/en/User Guide/User Guide/Note Types/Book/5_Calendar View_image.png b/src/public/app/doc_notes/en/User Guide/User Guide/Note Types/Book/5_Calendar View_image.png new file mode 100644 index 000000000..acf382206 Binary files /dev/null and b/src/public/app/doc_notes/en/User Guide/User Guide/Note Types/Book/5_Calendar View_image.png differ diff --git a/src/public/app/doc_notes/en/User Guide/User Guide/Note Types/Book/6_Calendar View_image.png b/src/public/app/doc_notes/en/User Guide/User Guide/Note Types/Book/6_Calendar View_image.png new file mode 100644 index 000000000..57964953a Binary files /dev/null and b/src/public/app/doc_notes/en/User Guide/User Guide/Note Types/Book/6_Calendar View_image.png differ diff --git a/src/public/app/doc_notes/en/User Guide/User Guide/Note Types/Book/7_Calendar View_image.png b/src/public/app/doc_notes/en/User Guide/User Guide/Note Types/Book/7_Calendar View_image.png new file mode 100644 index 000000000..53ac5632f Binary files /dev/null and b/src/public/app/doc_notes/en/User Guide/User Guide/Note Types/Book/7_Calendar View_image.png differ diff --git a/src/public/app/doc_notes/en/User Guide/User Guide/Note Types/Book/8_Calendar View_image.png b/src/public/app/doc_notes/en/User Guide/User Guide/Note Types/Book/8_Calendar View_image.png new file mode 100644 index 000000000..aa9e445ad Binary files /dev/null and b/src/public/app/doc_notes/en/User Guide/User Guide/Note Types/Book/8_Calendar View_image.png differ diff --git a/src/public/app/doc_notes/en/User Guide/User Guide/Note Types/Book/9_Calendar View_image.png b/src/public/app/doc_notes/en/User Guide/User Guide/Note Types/Book/9_Calendar View_image.png new file mode 100644 index 000000000..e050184a7 Binary files /dev/null and b/src/public/app/doc_notes/en/User Guide/User Guide/Note Types/Book/9_Calendar View_image.png differ diff --git a/src/public/app/doc_notes/en/User Guide/User Guide/Note Types/Book/Calendar View.html b/src/public/app/doc_notes/en/User Guide/User Guide/Note Types/Book/Calendar View.html new file mode 100644 index 000000000..d5364a1e9 --- /dev/null +++ b/src/public/app/doc_notes/en/User Guide/User Guide/Note Types/Book/Calendar View.html @@ -0,0 +1,192 @@ + + + + + + + + Calendar View + + + +
+

Calendar View

+ +
+
+ +
+

The Calendar view of Book notes will display each child note in a calendar + that has a start date and optionally an end date, as an event.

+

Unlike other Book view types, the Calendar view also allows some kind + of interaction, such as moving events around as well as creating new ones.

+

Creating a calendar

+
+ + + + + + + + + + + + + + + + + + +
1 +
+ +
+

 

+
+

The Calendar View works only for Book note types. To create a new note, + right click on the note tree on the left and select Insert note after, + or Insert child note and then select Book.

+

 

+
2 +
+ +
+
Once created, the “View type” of the Book needs changed to “Calendar”, + by selecting the “Book Properties” tab in the ribbon.
+
+

Creating a new event/note

+ +

Interacting with events

+ +

Configuring the calendar

+ +

How the calendar works

+

+ The calendar displays all the child notes of the book that have a #startDate. + An #endDate can optionally be added.

+

If editing the start date and end date from the note itself is desirable, + the following attributes can be added to the book note:

#viewType=calendar #label:startDate(inheritable)="promoted,alias=Start Date,single,date" #label:endDate(inheritable)="promoted,alias=End Date,single,date" #hidePromotedAttributes 
+

This will result in:

+

+ +

+

Advanced use-cases

+

Using a different attribute as event title

+

By default, events are displayed on the calendar by their note title. + However, it is possible to configure a different attribute to be displayed + instead.

+

To do so, assign #calendar:title to the child note (not the + calendar/book note), with the value being #name where name can + be any label. The attribute can also come through inheritance such as a + template attribute. If the note does not have the requested label, the + title of the note will be used instead.

+
+ + + + + + + +
+
+ +
+
+
+ +
+
+
+

Using a relation attribute as event title

+

Similarly to using an attribute, use #calendar:title and set + it to ~name where name is the name of the relation + to use.

+

Moreover, if there are more relations of the same name, they will be displayed + as multiple events coming from the same note.

+
+ + + + + + + +
+
+ +
+
+
+ +
+
+
+

Note that it's even possible to have a #calendar:title on the + target note (e.g. “John Smith”) which will try to render an attribute of + it. Note that it's not possible to use a relation here as well for safety + reasons (an accidental recursion  of attributes could cause the application + to loop infinitely).

+
+ + + + + + + +
+
+ +
+
+
+ +
+
+
+
+
+ + + \ No newline at end of file diff --git a/src/public/app/doc_notes/en/User Guide/User Guide/Note Types/Book/Calendar View_image.png b/src/public/app/doc_notes/en/User Guide/User Guide/Note Types/Book/Calendar View_image.png new file mode 100644 index 000000000..d46f327a2 Binary files /dev/null and b/src/public/app/doc_notes/en/User Guide/User Guide/Note Types/Book/Calendar View_image.png differ diff --git a/src/public/app/doc_notes/en/User Guide/navigation.html b/src/public/app/doc_notes/en/User Guide/navigation.html index 77cf9ca53..85143e944 100644 --- a/src/public/app/doc_notes/en/User Guide/navigation.html +++ b/src/public/app/doc_notes/en/User Guide/navigation.html @@ -29,6 +29,12 @@
  • Geo map
  • +
  • Book + +
  • Shared notes diff --git a/src/public/app/layouts/mobile_layout.ts b/src/public/app/layouts/mobile_layout.ts index a98b3df29..51609ce32 100644 --- a/src/public/app/layouts/mobile_layout.ts +++ b/src/public/app/layouts/mobile_layout.ts @@ -31,6 +31,7 @@ import type AppContext from "../components/app_context.js"; import TabRowWidget from "../widgets/tab_row.js"; import JumpToNoteDialog from "../widgets/dialogs/jump_to_note.js"; import RecentChangesDialog from "../widgets/dialogs/recent_changes.js"; +import PromptDialog from "../widgets/dialogs/prompt.js"; const MOBILE_CSS = ` - -
    -
    - - - -
    -
    -`; - -class NoteListRenderer { - private $noteList: JQuery; - - private parentNote: FNote; - private noteIds: string[]; - private page?: number; - private pageSize?: number; - private viewType?: string | null; - private showNotePath?: boolean; - private highlightRegex?: RegExp | null; - - /* - * We're using noteIds so that it's not necessary to load all notes at once when paging - */ constructor($parent: JQuery, parentNote: FNote, noteIds: string[], showNotePath: boolean = false) { - this.$noteList = $(TPL); - - // note list must be added to the DOM immediately, otherwise some functionality scripting (canvas) won't work - $parent.empty(); - - this.parentNote = parentNote; - const includedNoteIds = this.getIncludedNoteIds(); - - this.noteIds = noteIds.filter((noteId) => !includedNoteIds.has(noteId) && noteId !== "_hidden"); - - if (this.noteIds.length === 0) { - return; + this.viewType = this.#getViewType(parentNote); + const args: ViewModeArgs = { + $parent, + parentNote, + noteIds, + showNotePath } - $parent.append(this.$noteList); - - this.page = 1; - this.pageSize = parseInt(parentNote.getLabelValue("pageSize") || ""); - - if (!this.pageSize || this.pageSize < 1) { - this.pageSize = 20; + if (this.viewType === "list" || this.viewType === "grid") { + this.viewMode = new ListOrGridView(this.viewType, args); + } else if (this.viewType === "calendar") { + this.viewMode = new CalendarView(args); + } else { + this.viewMode = null; } - - this.viewType = parentNote.getLabelValue("viewType"); - - if (!["list", "grid"].includes(this.viewType || "")) { - // when not explicitly set, decide based on the note type - this.viewType = parentNote.type === "search" ? "list" : "grid"; - } - - this.$noteList.addClass(`${this.viewType}-view`); - - this.showNotePath = showNotePath; } - /** @returns {Set} list of noteIds included (images, included notes) in the parent note and which - * don't have to be shown in the note list. */ - getIncludedNoteIds() { - const includedLinks = this.parentNote ? this.parentNote.getRelations().filter((rel) => rel.name === "imageLink" || rel.name === "includeNoteLink") : []; + #getViewType(parentNote: FNote): ViewTypeOptions { + const viewType = parentNote.getLabelValue("viewType"); - return new Set(includedLinks.map((rel) => rel.value)); + if (!["list", "grid", "calendar"].includes(viewType || "")) { + // when not explicitly set, decide based on the note type + return parentNote.type === "search" ? "list" : "grid"; + } else { + return viewType as ViewTypeOptions; + } } async renderList() { - if (this.noteIds.length === 0 || !this.page || !this.pageSize) { - this.$noteList.hide(); - return; + if (!this.viewMode) { + return null; } - const highlightedTokens = this.parentNote.highlightedTokens || []; - if (highlightedTokens.length > 0) { - await libraryLoader.requireLibrary(libraryLoader.MARKJS); - - const regex = highlightedTokens.map((token) => utils.escapeRegExp(token)).join("|"); - - this.highlightRegex = new RegExp(regex, "gi"); - } else { - this.highlightRegex = null; - } - - this.$noteList.show(); - - const $container = this.$noteList.find(".note-list-container").empty(); - - const startIdx = (this.page - 1) * this.pageSize; - const endIdx = startIdx + this.pageSize; - - const pageNoteIds = this.noteIds.slice(startIdx, Math.min(endIdx, this.noteIds.length)); - const pageNotes = await froca.getNotes(pageNoteIds); - - for (const note of pageNotes) { - const $card = await this.renderNote(note, this.parentNote.isLabelTruthy("expanded")); - - $container.append($card); - } - - this.renderPager(); - - return this.$noteList; + return await this.viewMode.renderList(); } - renderPager() { - const $pager = this.$noteList.find(".note-list-pager").empty(); - if (!this.page || !this.pageSize) { - return; - } - - const pageCount = Math.ceil(this.noteIds.length / this.pageSize); - - $pager.toggle(pageCount > 1); - - let lastPrinted; - - for (let i = 1; i <= pageCount; i++) { - if (pageCount < 20 || i <= 5 || pageCount - i <= 5 || Math.abs(this.page - i) <= 2) { - lastPrinted = true; - - const startIndex = (i - 1) * this.pageSize + 1; - const endIndex = Math.min(this.noteIds.length, i * this.pageSize); - - $pager.append( - i === this.page - ? $("").text(i).css("text-decoration", "underline").css("font-weight", "bold") - : $('') - .text(i) - .attr("title", `Page of ${startIndex} - ${endIndex}`) - .on("click", () => { - this.page = i; - this.renderList(); - }), - "   " - ); - } else if (lastPrinted) { - $pager.append("...   "); - - lastPrinted = false; - } - } - - // no need to distinguish "note" vs "notes" since in case of one result, there's no paging at all - $pager.append(`(${this.noteIds.length} notes)`); - } - - async renderNote(note: FNote, expand: boolean = false) { - const $expander = $(''); - - const { $renderedAttributes } = await attributeRenderer.renderNormalAttributes(note); - const notePath = - this.parentNote.type === "search" - ? note.noteId // for search note parent, we want to display a non-search path - : `${this.parentNote.noteId}/${note.noteId}`; - - const $card = $('
    ') - .attr("data-note-id", note.noteId) - .append( - $('
    ') - .append($expander) - .append($('').addClass(note.getIcon())) - .append( - this.viewType === "grid" - ? $('').text(await treeService.getNoteTitle(note.noteId, this.parentNote.noteId)) - : (await linkService.createLink(notePath, { showTooltip: false, showNotePath: this.showNotePath })).addClass("note-book-title") - ) - .append($renderedAttributes) - ); - - if (this.viewType === "grid") { - $card - .addClass("block-link") - .attr("data-href", `#${notePath}`) - .on("click", (e) => linkService.goToLink(e)); - } - - $expander.on("click", () => this.toggleContent($card, note, !$card.hasClass("expanded"))); - - if (this.highlightRegex) { - $card.find(".note-book-title").markRegExp(this.highlightRegex, { - element: "span", - className: "ck-find-result", - separateWordSearch: false, - caseSensitive: false - }); - } - - await this.toggleContent($card, note, expand); - - return $card; - } - - async toggleContent($card: JQuery, note: FNote, expand: boolean) { - if (this.viewType === "list" && ((expand && $card.hasClass("expanded")) || (!expand && !$card.hasClass("expanded")))) { - return; - } - - const $expander = $card.find("> .note-book-header .note-expander"); - - if (expand || this.viewType === "grid") { - $card.addClass("expanded"); - $expander.addClass("bx-chevron-down").removeClass("bx-chevron-right"); - } else { - $card.removeClass("expanded"); - $expander.addClass("bx-chevron-right").removeClass("bx-chevron-down"); - } - - if ((expand || this.viewType === "grid") && $card.find(".note-book-content").length === 0) { - $card.append(await this.renderNoteContent(note)); - } - } - - async renderNoteContent(note: FNote) { - const $content = $('
    '); - - try { - const { $renderedContent, type } = await contentRenderer.getRenderedContent(note, { - trim: this.viewType === "grid" // for grid only short content is needed - }); - - if (this.highlightRegex) { - $renderedContent.markRegExp(this.highlightRegex, { - element: "span", - className: "ck-find-result", - separateWordSearch: false, - caseSensitive: false - }); - } - - $content.append($renderedContent); - $content.addClass(`type-${type}`); - } catch (e) { - console.warn(`Caught error while rendering note '${note.noteId}' of type '${note.type}'`); - console.error(e); - - $content.append("rendering error"); - } - - if (this.viewType === "list") { - const imageLinks = note.getRelations("imageLink"); - - const childNotes = (await note.getChildNotes()).filter((childNote) => !imageLinks.find((rel) => rel.value === childNote.noteId)); - - for (const childNote of childNotes) { - $content.append(await this.renderNote(childNote)); - } - } - - return $content; - } } - -export default NoteListRenderer; diff --git a/src/public/app/widgets/buttons/global_menu.ts b/src/public/app/widgets/buttons/global_menu.ts index 9d04c8e56..67a235709 100644 --- a/src/public/app/widgets/buttons/global_menu.ts +++ b/src/public/app/widgets/buttons/global_menu.ts @@ -365,6 +365,7 @@ export default class GlobalMenuWidget extends BasicWidget { this.$zoomState = this.$widget.find(".zoom-state"); this.$toggleZenMode = this.$widget.find('[data-trigger-command="toggleZenMode"'); + this.$toggleZenMode.toggle(!utils.isMobile()); this.$widget.on("show.bs.dropdown", () => this.#onShown()); if (this.tooltip) { this.$widget.on("hide.bs.dropdown", () => this.tooltip.enable()); diff --git a/src/public/app/widgets/floating_buttons/help_button.ts b/src/public/app/widgets/floating_buttons/help_button.ts index 74bb976bd..c2147c75d 100644 --- a/src/public/app/widgets/floating_buttons/help_button.ts +++ b/src/public/app/widgets/floating_buttons/help_button.ts @@ -1,7 +1,8 @@ -import appContext from "../../components/app_context.js"; +import appContext, { type EventData } from "../../components/app_context.js"; import type { NoteType } from "../../entities/fnote.js"; import { t } from "../../services/i18n.js"; import type { ViewScope } from "../../services/link.js"; +import type { ViewTypeOptions } from "../../services/note_list_renderer.js"; import NoteContextAwareWidget from "../note_context_aware_widget.js"; const TPL = ` @@ -10,8 +11,7 @@ const TPL = ` `; -const byNoteType: Record = { - book: null, +const byNoteType: Record, string | null> = { canvas: null, code: null, contentWidget: null, @@ -30,6 +30,12 @@ const byNoteType: Record = { webView: null }; +const byBookType: Record = { + list: null, + grid: null, + calendar: "fDGg7QcJg3Xm" +}; + export default class ContextualHelpButton extends NoteContextAwareWidget { private helpNoteIdToOpen?: string | null; @@ -41,8 +47,10 @@ export default class ContextualHelpButton extends NoteContextAwareWidget { return false; } - if (this.note && byNoteType[this.note.type]) { + if (this.note && this.note.type !== "book" && byNoteType[this.note.type]) { this.helpNoteIdToOpen = byNoteType[this.note.type]; + } else if (this.note && this.note.type === "book") { + this.helpNoteIdToOpen = byBookType[this.note.getAttributeValue("label", "viewType") as ViewTypeOptions ?? ""] } return !!this.helpNoteIdToOpen; @@ -73,4 +81,10 @@ export default class ContextualHelpButton extends NoteContextAwareWidget { }); } + entitiesReloadedEvent({ loadResults }: EventData<"entitiesReloaded">) { + if (this.note?.type === "book" && loadResults.getAttributeRows().find((attr) => attr.noteId === this.noteId && attr.name === "viewType")) { + this.refresh(); + } + } + } diff --git a/src/public/app/widgets/note_list.ts b/src/public/app/widgets/note_list.ts index 97fd19bef..2775aea78 100644 --- a/src/public/app/widgets/note_list.ts +++ b/src/public/app/widgets/note_list.ts @@ -2,6 +2,7 @@ import NoteContextAwareWidget from "./note_context_aware_widget.js"; import NoteListRenderer from "../services/note_list_renderer.js"; import type FNote from "../entities/fnote.js"; import type { EventData } from "../components/app_context.js"; +import type ViewMode from "./view_widgets/view_mode.js"; const TPL = `
    @@ -26,6 +27,7 @@ export default class NoteListWidget extends NoteContextAwareWidget { private isIntersecting?: boolean; private noteIdRefreshed?: string; private shownNoteId?: string | null; + private viewMode?: ViewMode | null; isEnabled() { return super.isEnabled() && this.noteContext?.hasNoteList(); @@ -67,6 +69,7 @@ export default class NoteListWidget extends NoteContextAwareWidget { async renderNoteList(note: FNote) { const noteListRenderer = new NoteListRenderer(this.$content, note, note.getChildNoteIds()); await noteListRenderer.renderList(); + this.viewMode = noteListRenderer.viewMode; } async refresh() { @@ -102,11 +105,14 @@ export default class NoteListWidget extends NoteContextAwareWidget { } } - entitiesReloadedEvent({ loadResults }: EventData<"entitiesReloaded">) { - if (loadResults.getAttributeRows().find((attr) => attr.noteId === this.noteId && attr.name && ["viewType", "expanded", "pageSize"].includes(attr.name))) { - this.shownNoteId = null; // force render - + entitiesReloadedEvent(e: EventData<"entitiesReloaded">) { + if (e.loadResults.getAttributeRows().find((attr) => attr.noteId === this.noteId && attr.name && ["viewType", "expanded", "pageSize"].includes(attr.name))) { + this.refresh(); this.checkRenderStatus(); } + + if (this.viewMode) { + this.viewMode.entitiesReloadedEvents(e); + } } } diff --git a/src/public/app/widgets/ribbon_widgets/book_properties.ts b/src/public/app/widgets/ribbon_widgets/book_properties.ts index 6bb66dd1f..1a0671e3a 100644 --- a/src/public/app/widgets/ribbon_widgets/book_properties.ts +++ b/src/public/app/widgets/ribbon_widgets/book_properties.ts @@ -23,6 +23,7 @@ const TPL = `
    @@ -125,7 +126,7 @@ export default class BookPropertiesWidget extends NoteContextAwareWidget { return; } - if (type !== "list" && type !== "grid") { + if (![ "list", "grid", "calendar"].includes(type)) { throw new Error(t("book_properties.invalid_view_type", { type })); } diff --git a/src/public/app/widgets/type_widgets/book.ts b/src/public/app/widgets/type_widgets/book.ts index bb872d50e..38be2a56c 100644 --- a/src/public/app/widgets/type_widgets/book.ts +++ b/src/public/app/widgets/type_widgets/book.ts @@ -1,6 +1,7 @@ import TypeWidget from "./type_widget.js"; import { t } from "../../services/i18n.js"; import type FNote from "../../entities/fnote.js"; +import type { EventData } from "../../components/app_context.js"; const TPL = `
    @@ -35,6 +36,15 @@ export default class BookTypeWidget extends TypeWidget { } async doRefresh(note: FNote) { - this.$helpNoChildren.toggle(!this.note?.hasChildren()); + this.$helpNoChildren.toggle( + !this.note?.hasChildren() + && this.note?.getAttributeValue("label", "viewType") !== "calendar"); } + + entitiesReloadedEvent({ loadResults }: EventData<"entitiesReloaded">) { + if (loadResults.getAttributeRows().find((attr) => attr.noteId === this.noteId && attr.name === "viewType")) { + this.refresh(); + } + } + } diff --git a/src/public/app/widgets/view_widgets/calendar_view.ts b/src/public/app/widgets/view_widgets/calendar_view.ts new file mode 100644 index 000000000..70b68e98f --- /dev/null +++ b/src/public/app/widgets/view_widgets/calendar_view.ts @@ -0,0 +1,284 @@ +import type { Calendar, DateSelectArg, EventChangeArg, EventDropArg, EventSourceInput, PluginDef } from "@fullcalendar/core"; +import froca from "../../services/froca.js"; +import ViewMode, { type ViewModeArgs } from "./view_mode.js"; +import type FNote from "../../entities/fnote.js"; +import server from "../../services/server.js"; +import ws from "../../services/ws.js"; +import { t } from "../../services/i18n.js"; +import options from "../../services/options.js"; +import dialogService from "../../services/dialog.js"; +import attributes from "../../services/attributes.js"; +import type { EventData } from "../../components/app_context.js"; + +const TPL = ` +
    + + +
    +
    +
    +`; + +// TODO: Deduplicate +interface CreateChildResponse { + note: { + noteId: string; + } +} + +export default class CalendarView extends ViewMode { + + private $root: JQuery; + private $calendarContainer: JQuery; + private noteIds: string[]; + private parentNote: FNote; + private calendar?: Calendar; + + constructor(args: ViewModeArgs) { + super(args); + + this.$root = $(TPL); + this.$calendarContainer = this.$root.find(".calendar-container"); + this.noteIds = args.noteIds; + this.parentNote = args.parentNote; + console.log(args); + args.$parent.append(this.$root); + } + + async renderList(): Promise | undefined> { + const isEditable = true; + + const { Calendar } = await import("@fullcalendar/core"); + const plugins: PluginDef[] = []; + plugins.push((await import("@fullcalendar/daygrid")).default); + + if (isEditable) { + plugins.push((await import("@fullcalendar/interaction")).default); + } + + const calendar = new Calendar(this.$calendarContainer[0], { + plugins, + initialView: "dayGridMonth", + events: async () => await CalendarView.#buildEvents(this.noteIds), + editable: isEditable, + selectable: isEditable, + select: (e) => this.#onCalendarSelection(e), + eventChange: (e) => this.#onEventMoved(e), + firstDay: options.getInt("firstDayOfWeek") ?? 0, + locale: await CalendarView.#getLocale() + }); + calendar.render(); + this.calendar = calendar; + + return this.$root; + } + + static async #getLocale() { + const locale = options.get("locale"); + + // Here we hard-code the imports in order to ensure that they are embedded by webpack without having to load all the languages. + switch (locale) { + case "de": + return (await import("@fullcalendar/core/locales/de")).default; + case "es": + return (await import("@fullcalendar/core/locales/es")).default; + case "fr": + return (await import("@fullcalendar/core/locales/fr")).default; + case "cn": + return (await import("@fullcalendar/core/locales/zh-cn")).default; + case "tw": + return (await import("@fullcalendar/core/locales/zh-tw")).default; + case "ro": + return (await import("@fullcalendar/core/locales/ro")).default; + case "en": + default: + return undefined; + } + } + + async #onCalendarSelection(e: DateSelectArg) { + const startDate = CalendarView.#formatDateToLocalISO(e.start); + if (!startDate) { + return; + } + + const endDate = CalendarView.#formatDateToLocalISO(CalendarView.#offsetDate(e.end, -1)); + + const title = await dialogService.prompt({ message: t("relation_map.enter_title_of_new_note"), defaultValue: t("relation_map.default_new_note_title") }); + if (!title?.trim()) { + return; + } + + const { note } = await server.post(`notes/${this.parentNote.noteId}/children?target=into`, { + title, + content: "", + type: "text" + }); + attributes.setLabel(note.noteId, "startDate", startDate); + if (endDate) { + attributes.setLabel(note.noteId, "endDate", endDate); + } + } + + async #onEventMoved(e: EventChangeArg) { + const startDate = CalendarView.#formatDateToLocalISO(e.event.start); + // Fullcalendar end date is exclusive, not inclusive but we store it the other way around. + let endDate = CalendarView.#formatDateToLocalISO(CalendarView.#offsetDate(e.event.end, -1)); + const noteId = e.event.extendedProps.noteId; + + // Don't store the end date if it's empty. + if (endDate === startDate) { + endDate = undefined; + } + + // Update start date + const note = await froca.getNote(noteId); + if (!note) { + return; + } + + CalendarView.#setAttribute(note, "label", "startDate", startDate); + CalendarView.#setAttribute(note, "label", "endDate", endDate); + } + + entitiesReloadedEvents({ loadResults }: EventData<"entitiesReloaded">): void { + // Refresh note IDs if they got changed. + if (loadResults.getBranchRows().some((branch) => branch.parentNoteId == this.parentNote.noteId)) { + this.noteIds = this.parentNote.getChildNoteIds(); + } + + if (this.calendar && loadResults.getAttributeRows().some((a) => this.noteIds.includes(a.noteId ?? ""))) { + this.calendar.refetchEvents(); + } + } + + static async #buildEvents(noteIds: string[]) { + const notes = await froca.getNotes(noteIds); + const events: EventSourceInput = []; + + for (const note of notes) { + const startDate = note.getAttributeValue("label", "startDate"); + const customTitle = note.getAttributeValue("label", "calendar:title"); + + if (!startDate) { + continue; + } + + const titles = await CalendarView.#parseCustomTitle(customTitle, note); + for (const title of titles) { + const eventData: typeof events[0] = { + title: title, + start: startDate, + url: `#${note.noteId}`, + noteId: note.noteId + }; + + const endDate = CalendarView.#offsetDate(note.getAttributeValue("label", "endDate") ?? startDate, 1); + if (endDate) { + eventData.end = CalendarView.#formatDateToLocalISO(endDate); + } + + events.push(eventData); + } + } + + return events; + } + + static async #parseCustomTitle(customTitleValue: string | null, note: FNote, allowRelations = true): Promise { + if (customTitleValue) { + const attributeName = customTitleValue.substring(1); + if (customTitleValue.startsWith("#")) { + const labelValue = note.getAttributeValue("label", attributeName); + if (labelValue) { + return [ labelValue ]; + } + } else if (allowRelations && customTitleValue.startsWith("~")) { + const relations = note.getRelations(attributeName); + if (relations.length > 0) { + const noteIds = relations.map((r) => r.targetNoteId); + const notesFromRelation = await froca.getNotes(noteIds); + const titles = []; + + for (const targetNote of notesFromRelation) { + const targetCustomTitleValue = targetNote.getAttributeValue("label", "calendar:title"); + const targetTitles = await CalendarView.#parseCustomTitle(targetCustomTitleValue, targetNote, false); + titles.push(targetTitles.flat()); + } + + return titles.flat(); + } + } + } + + return [ note.title ]; + } + + static async #setAttribute(note: FNote, type: "label" | "relation", name: string, value: string | null | undefined) { + if (value) { + // Create or update the attribute. + await server.put(`notes/${note.noteId}/set-attribute`, { type, name, value }); + } else { + // Remove the attribute if it exists on the server but we don't define a value for it. + const attributeId = note.getAttribute(type, name)?.attributeId; + if (attributeId) { + await server.remove(`notes/${note.noteId}/attributes/${attributeId}`); + } + } + await ws.waitForMaxKnownEntityChangeId(); + } + + static #formatDateToLocalISO(date: Date | null | undefined) { + if (!date) { + return undefined; + } + + const offset = date.getTimezoneOffset(); + const localDate = new Date(date.getTime() - offset * 60 * 1000); + return localDate.toISOString().split('T')[0]; + } + + static #offsetDate(date: Date | string | null | undefined, offset: number) { + if (!date) { + return undefined; + } + + const newDate = new Date(date); + newDate.setDate(newDate.getDate() + offset); + return newDate; + } + +} diff --git a/src/public/app/widgets/view_widgets/list_or_grid_view.ts b/src/public/app/widgets/view_widgets/list_or_grid_view.ts new file mode 100644 index 000000000..56ee00b86 --- /dev/null +++ b/src/public/app/widgets/view_widgets/list_or_grid_view.ts @@ -0,0 +1,397 @@ +import linkService from "../../services/link.js"; +import contentRenderer from "../../services/content_renderer.js"; +import froca from "../../services/froca.js"; +import attributeRenderer from "../../services/attribute_renderer.js"; +import libraryLoader from "../../services/library_loader.js"; +import treeService from "../../services/tree.js"; +import utils from "../../services/utils.js"; +import type FNote from "../../entities/fnote.js"; +import ViewMode, { type ViewModeArgs } from "./view_mode.js"; + +const TPL = ` +
    + + +
    +
    + + + +
    +
    +
    `; + +class ListOrGridView extends ViewMode { + private $noteList: JQuery; + + private parentNote: FNote; + private noteIds: string[]; + private page?: number; + private pageSize?: number; + private viewType?: string | null; + private showNotePath?: boolean; + private highlightRegex?: RegExp | null; + + /* + * We're using noteIds so that it's not necessary to load all notes at once when paging + */ + constructor(viewType: string, args: ViewModeArgs) { + super(args); + this.$noteList = $(TPL); + this.viewType = viewType; + + this.parentNote = args.parentNote; + const includedNoteIds = this.getIncludedNoteIds(); + + this.noteIds = args.noteIds.filter((noteId) => !includedNoteIds.has(noteId) && noteId !== "_hidden"); + + if (this.noteIds.length === 0) { + return; + } + + args.$parent.append(this.$noteList); + + this.page = 1; + this.pageSize = parseInt(args.parentNote.getLabelValue("pageSize") || ""); + + if (!this.pageSize || this.pageSize < 1) { + this.pageSize = 20; + } + + this.$noteList.addClass(`${this.viewType}-view`); + + this.showNotePath = args.showNotePath; + } + + /** @returns {Set} list of noteIds included (images, included notes) in the parent note and which + * don't have to be shown in the note list. */ + getIncludedNoteIds() { + const includedLinks = this.parentNote ? this.parentNote.getRelations().filter((rel) => rel.name === "imageLink" || rel.name === "includeNoteLink") : []; + + return new Set(includedLinks.map((rel) => rel.value)); + } + + async renderList() { + if (this.noteIds.length === 0 || !this.page || !this.pageSize) { + this.$noteList.hide(); + return; + } + + const highlightedTokens = this.parentNote.highlightedTokens || []; + if (highlightedTokens.length > 0) { + await libraryLoader.requireLibrary(libraryLoader.MARKJS); + + const regex = highlightedTokens.map((token) => utils.escapeRegExp(token)).join("|"); + + this.highlightRegex = new RegExp(regex, "gi"); + } else { + this.highlightRegex = null; + } + + this.$noteList.show(); + + const $container = this.$noteList.find(".note-list-container").empty(); + + const startIdx = (this.page - 1) * this.pageSize; + const endIdx = startIdx + this.pageSize; + + const pageNoteIds = this.noteIds.slice(startIdx, Math.min(endIdx, this.noteIds.length)); + const pageNotes = await froca.getNotes(pageNoteIds); + + for (const note of pageNotes) { + const $card = await this.renderNote(note, this.parentNote.isLabelTruthy("expanded")); + + $container.append($card); + } + + this.renderPager(); + + return this.$noteList; + } + + renderPager() { + const $pager = this.$noteList.find(".note-list-pager").empty(); + if (!this.page || !this.pageSize) { + return; + } + + const pageCount = Math.ceil(this.noteIds.length / this.pageSize); + + $pager.toggle(pageCount > 1); + + let lastPrinted; + + for (let i = 1; i <= pageCount; i++) { + if (pageCount < 20 || i <= 5 || pageCount - i <= 5 || Math.abs(this.page - i) <= 2) { + lastPrinted = true; + + const startIndex = (i - 1) * this.pageSize + 1; + const endIndex = Math.min(this.noteIds.length, i * this.pageSize); + + $pager.append( + i === this.page + ? $("").text(i).css("text-decoration", "underline").css("font-weight", "bold") + : $('
    ') + .text(i) + .attr("title", `Page of ${startIndex} - ${endIndex}`) + .on("click", () => { + this.page = i; + this.renderList(); + }), + "   " + ); + } else if (lastPrinted) { + $pager.append("...   "); + + lastPrinted = false; + } + } + + // no need to distinguish "note" vs "notes" since in case of one result, there's no paging at all + $pager.append(`(${this.noteIds.length} notes)`); + } + + async renderNote(note: FNote, expand: boolean = false) { + const $expander = $(''); + + const { $renderedAttributes } = await attributeRenderer.renderNormalAttributes(note); + const notePath = + this.parentNote.type === "search" + ? note.noteId // for search note parent, we want to display a non-search path + : `${this.parentNote.noteId}/${note.noteId}`; + + const $card = $('
    ') + .attr("data-note-id", note.noteId) + .append( + $('
    ') + .append($expander) + .append($('').addClass(note.getIcon())) + .append( + this.viewType === "grid" + ? $('').text(await treeService.getNoteTitle(note.noteId, this.parentNote.noteId)) + : (await linkService.createLink(notePath, { showTooltip: false, showNotePath: this.showNotePath })).addClass("note-book-title") + ) + .append($renderedAttributes) + ); + + if (this.viewType === "grid") { + $card + .addClass("block-link") + .attr("data-href", `#${notePath}`) + .on("click", (e) => linkService.goToLink(e)); + } + + $expander.on("click", () => this.toggleContent($card, note, !$card.hasClass("expanded"))); + + if (this.highlightRegex) { + $card.find(".note-book-title").markRegExp(this.highlightRegex, { + element: "span", + className: "ck-find-result", + separateWordSearch: false, + caseSensitive: false + }); + } + + await this.toggleContent($card, note, expand); + + return $card; + } + + async toggleContent($card: JQuery, note: FNote, expand: boolean) { + if (this.viewType === "list" && ((expand && $card.hasClass("expanded")) || (!expand && !$card.hasClass("expanded")))) { + return; + } + + const $expander = $card.find("> .note-book-header .note-expander"); + + if (expand || this.viewType === "grid") { + $card.addClass("expanded"); + $expander.addClass("bx-chevron-down").removeClass("bx-chevron-right"); + } else { + $card.removeClass("expanded"); + $expander.addClass("bx-chevron-right").removeClass("bx-chevron-down"); + } + + if ((expand || this.viewType === "grid") && $card.find(".note-book-content").length === 0) { + $card.append(await this.renderNoteContent(note)); + } + } + + async renderNoteContent(note: FNote) { + const $content = $('
    '); + + try { + const { $renderedContent, type } = await contentRenderer.getRenderedContent(note, { + trim: this.viewType === "grid" // for grid only short content is needed + }); + + if (this.highlightRegex) { + $renderedContent.markRegExp(this.highlightRegex, { + element: "span", + className: "ck-find-result", + separateWordSearch: false, + caseSensitive: false + }); + } + + $content.append($renderedContent); + $content.addClass(`type-${type}`); + } catch (e) { + console.warn(`Caught error while rendering note '${note.noteId}' of type '${note.type}'`); + console.error(e); + + $content.append("rendering error"); + } + + if (this.viewType === "list") { + const imageLinks = note.getRelations("imageLink"); + + const childNotes = (await note.getChildNotes()).filter((childNote) => !imageLinks.find((rel) => rel.value === childNote.noteId)); + + for (const childNote of childNotes) { + $content.append(await this.renderNote(childNote)); + } + } + + return $content; + } +} + +export default ListOrGridView; diff --git a/src/public/app/widgets/view_widgets/view_mode.ts b/src/public/app/widgets/view_widgets/view_mode.ts new file mode 100644 index 000000000..e17d3cebf --- /dev/null +++ b/src/public/app/widgets/view_widgets/view_mode.ts @@ -0,0 +1,24 @@ +import type { EventData } from "../../components/app_context.js"; +import type FNote from "../../entities/fnote.js"; + +export interface ViewModeArgs { + $parent: JQuery; + parentNote: FNote; + noteIds: string[]; + showNotePath?: boolean; +} + +export default abstract class ViewMode { + + constructor(args: ViewModeArgs) { + // note list must be added to the DOM immediately, otherwise some functionality scripting (canvas) won't work + args.$parent.empty(); + } + + abstract renderList(): Promise | undefined>; + + entitiesReloadedEvents(e: EventData<"entitiesReloaded">) { + // Do nothing by default. + } + +} diff --git a/src/public/translations/en/translation.json b/src/public/translations/en/translation.json index 392ea89ae..f8fdbc51a 100644 --- a/src/public/translations/en/translation.json +++ b/src/public/translations/en/translation.json @@ -752,7 +752,8 @@ "collapse": "Collapse", "expand": "Expand", "book_properties": "Book Properties", - "invalid_view_type": "Invalid view type '{{type}}'" + "invalid_view_type": "Invalid view type '{{type}}'", + "calendar": "Calendar" }, "edited_notes": { "no_edited_notes_found": "No edited notes on this day yet...", diff --git a/src/public/translations/ro/translation.json b/src/public/translations/ro/translation.json index f751350a1..1f768eea6 100644 --- a/src/public/translations/ro/translation.json +++ b/src/public/translations/ro/translation.json @@ -281,7 +281,8 @@ "grid": "Grilă", "invalid_view_type": "Mod de afișare incorect „{{type}}”", "list": "Listă", - "view_type": "Mod de afișare" + "view_type": "Mod de afișare", + "calendar": "Calendar" }, "bookmark_switch": { "bookmark": "Semn de carte",