mirror of
https://github.com/zadam/trilium.git
synced 2025-11-12 00:05:50 +01:00
chore(monorepo): relocate client files
This commit is contained in:
129
apps/client/src/widgets/ribbon_widgets/basic_properties.ts
Normal file
129
apps/client/src/widgets/ribbon_widgets/basic_properties.ts
Normal file
@@ -0,0 +1,129 @@
|
||||
import NoteContextAwareWidget from "../note_context_aware_widget.js";
|
||||
import NoteTypeWidget from "../note_type.js";
|
||||
import ProtectedNoteSwitchWidget from "../protected_note_switch.js";
|
||||
import EditabilitySelectWidget from "../editability_select.js";
|
||||
import BookmarkSwitchWidget from "../bookmark_switch.js";
|
||||
import SharedSwitchWidget from "../shared_switch.js";
|
||||
import { t } from "../../services/i18n.js";
|
||||
import TemplateSwitchWidget from "../template_switch.js";
|
||||
import type FNote from "../../entities/fnote.js";
|
||||
import NoteLanguageWidget from "../note_language.js";
|
||||
|
||||
const TPL = /*html*/`
|
||||
<div class="basic-properties-widget">
|
||||
<style>
|
||||
.basic-properties-widget {
|
||||
padding: 0px 12px 6px 12px;
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.basic-properties-widget > * {
|
||||
margin-top: 9px;
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
|
||||
.basic-properties-widget > * > :last-child {
|
||||
margin-right: 30px;
|
||||
}
|
||||
|
||||
.note-type-container,
|
||||
.editability-select-container,
|
||||
.note-language-container {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
</style>
|
||||
|
||||
<div class="note-type-container">
|
||||
<span>${t("basic_properties.note_type")}:</span>
|
||||
</div>
|
||||
|
||||
<div class="protected-note-switch-container"></div>
|
||||
|
||||
<div class="editability-select-container">
|
||||
<span>${t("basic_properties.editable")}:</span>
|
||||
</div>
|
||||
|
||||
<div class="bookmark-switch-container"></div>
|
||||
|
||||
<div class="shared-switch-container"></div>
|
||||
|
||||
<div class="template-switch-container"></div>
|
||||
|
||||
<div class="note-language-container">
|
||||
<span>${t("basic_properties.language")}:</span>
|
||||
</div>
|
||||
</div>`;
|
||||
|
||||
export default class BasicPropertiesWidget extends NoteContextAwareWidget {
|
||||
|
||||
private noteTypeWidget: NoteTypeWidget;
|
||||
private protectedNoteSwitchWidget: ProtectedNoteSwitchWidget;
|
||||
private editabilitySelectWidget: EditabilitySelectWidget;
|
||||
private bookmarkSwitchWidget: BookmarkSwitchWidget;
|
||||
private sharedSwitchWidget: SharedSwitchWidget;
|
||||
private templateSwitchWidget: TemplateSwitchWidget;
|
||||
private noteLanguageWidget: NoteLanguageWidget;
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
|
||||
this.noteTypeWidget = new NoteTypeWidget().contentSized();
|
||||
this.protectedNoteSwitchWidget = new ProtectedNoteSwitchWidget().contentSized();
|
||||
this.editabilitySelectWidget = new EditabilitySelectWidget().contentSized();
|
||||
this.bookmarkSwitchWidget = new BookmarkSwitchWidget().contentSized();
|
||||
this.sharedSwitchWidget = new SharedSwitchWidget().contentSized();
|
||||
this.templateSwitchWidget = new TemplateSwitchWidget().contentSized();
|
||||
this.noteLanguageWidget = new NoteLanguageWidget().contentSized();
|
||||
|
||||
this.child(
|
||||
this.noteTypeWidget,
|
||||
this.protectedNoteSwitchWidget,
|
||||
this.editabilitySelectWidget,
|
||||
this.bookmarkSwitchWidget,
|
||||
this.sharedSwitchWidget,
|
||||
this.templateSwitchWidget,
|
||||
this.noteLanguageWidget);
|
||||
}
|
||||
|
||||
get name() {
|
||||
return "basicProperties";
|
||||
}
|
||||
|
||||
get toggleCommand() {
|
||||
return "toggleRibbonBasicProperties";
|
||||
}
|
||||
|
||||
getTitle() {
|
||||
return {
|
||||
show: !this.note?.isLaunchBarConfig(),
|
||||
title: t("basic_properties.basic_properties"),
|
||||
icon: "bx bx-slider"
|
||||
};
|
||||
}
|
||||
|
||||
doRender() {
|
||||
this.$widget = $(TPL);
|
||||
this.contentSized();
|
||||
|
||||
this.$widget.find(".note-type-container").append(this.noteTypeWidget.render());
|
||||
this.$widget.find(".protected-note-switch-container").append(this.protectedNoteSwitchWidget.render());
|
||||
this.$widget.find(".editability-select-container").append(this.editabilitySelectWidget.render());
|
||||
this.$widget.find(".bookmark-switch-container").append(this.bookmarkSwitchWidget.render());
|
||||
this.$widget.find(".shared-switch-container").append(this.sharedSwitchWidget.render());
|
||||
this.$widget.find(".template-switch-container").append(this.templateSwitchWidget.render());
|
||||
this.$widget.find(".note-language-container").append(this.noteLanguageWidget.render());
|
||||
}
|
||||
|
||||
async refreshWithNote(note: FNote) {
|
||||
await super.refreshWithNote(note);
|
||||
if (!this.note) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.$widget.find(".editability-select-container").toggle(this.note && ["text", "code", "mermaid"].includes(this.note.type));
|
||||
this.$widget.find(".note-language-container").toggle(this.note && ["text"].includes(this.note.type));
|
||||
}
|
||||
}
|
||||
141
apps/client/src/widgets/ribbon_widgets/book_properties.ts
Normal file
141
apps/client/src/widgets/ribbon_widgets/book_properties.ts
Normal file
@@ -0,0 +1,141 @@
|
||||
import NoteContextAwareWidget from "../note_context_aware_widget.js";
|
||||
import attributeService from "../../services/attributes.js";
|
||||
import { t } from "../../services/i18n.js";
|
||||
import type FNote from "../../entities/fnote.js";
|
||||
import type { EventData } from "../../components/app_context.js";
|
||||
|
||||
const TPL = /*html*/`
|
||||
<div class="book-properties-widget">
|
||||
<style>
|
||||
.book-properties-widget {
|
||||
padding: 12px 12px 6px 12px;
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.book-properties-widget > * {
|
||||
margin-right: 15px;
|
||||
}
|
||||
</style>
|
||||
|
||||
<div style="display: flex; align-items: baseline">
|
||||
<span style="white-space: nowrap">${t("book_properties.view_type")}: </span>
|
||||
|
||||
<select class="view-type-select form-select form-select-sm">
|
||||
<option value="grid">${t("book_properties.grid")}</option>
|
||||
<option value="list">${t("book_properties.list")}</option>
|
||||
<option value="calendar">${t("book_properties.calendar")}</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<button type="button"
|
||||
class="collapse-all-button btn btn-sm"
|
||||
title="${t("book_properties.collapse_all_notes")}">
|
||||
|
||||
<span class="bx bx-layer-minus"></span>
|
||||
|
||||
${t("book_properties.collapse")}
|
||||
</button>
|
||||
|
||||
<button type="button"
|
||||
class="expand-children-button btn btn-sm"
|
||||
title="${t("book_properties.expand_all_children")}">
|
||||
<span class="bx bx-move-vertical"></span>
|
||||
|
||||
${t("book_properties.expand")}
|
||||
</button>
|
||||
</div>
|
||||
`;
|
||||
|
||||
export default class BookPropertiesWidget extends NoteContextAwareWidget {
|
||||
|
||||
private $viewTypeSelect!: JQuery<HTMLElement>;
|
||||
private $expandChildrenButton!: JQuery<HTMLElement>;
|
||||
private $collapseAllButton!: JQuery<HTMLElement>;
|
||||
|
||||
get name() {
|
||||
return "bookProperties";
|
||||
}
|
||||
|
||||
get toggleCommand() {
|
||||
return "toggleRibbonTabBookProperties";
|
||||
}
|
||||
|
||||
isEnabled() {
|
||||
return this.note && this.note.type === "book";
|
||||
}
|
||||
|
||||
getTitle() {
|
||||
return {
|
||||
show: this.isEnabled(),
|
||||
activate: true,
|
||||
title: t("book_properties.book_properties"),
|
||||
icon: "bx bx-book"
|
||||
};
|
||||
}
|
||||
|
||||
doRender() {
|
||||
this.$widget = $(TPL);
|
||||
this.contentSized();
|
||||
|
||||
this.$viewTypeSelect = this.$widget.find(".view-type-select");
|
||||
this.$viewTypeSelect.on("change", () => this.toggleViewType(String(this.$viewTypeSelect.val())));
|
||||
|
||||
this.$expandChildrenButton = this.$widget.find(".expand-children-button");
|
||||
this.$expandChildrenButton.on("click", async () => {
|
||||
if (!this.noteId || !this.note) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!this.note?.isLabelTruthy("expanded")) {
|
||||
await attributeService.addLabel(this.noteId, "expanded");
|
||||
}
|
||||
|
||||
this.triggerCommand("refreshNoteList", { noteId: this.noteId });
|
||||
});
|
||||
|
||||
this.$collapseAllButton = this.$widget.find(".collapse-all-button");
|
||||
this.$collapseAllButton.on("click", async () => {
|
||||
if (!this.noteId || !this.note) {
|
||||
return;
|
||||
}
|
||||
|
||||
// owned is important - we shouldn't remove inherited expanded labels
|
||||
for (const expandedAttr of this.note.getOwnedLabels("expanded")) {
|
||||
await attributeService.removeAttributeById(this.noteId, expandedAttr.attributeId);
|
||||
}
|
||||
|
||||
this.triggerCommand("refreshNoteList", { noteId: this.noteId });
|
||||
});
|
||||
}
|
||||
|
||||
async refreshWithNote(note: FNote) {
|
||||
if (!this.note) {
|
||||
return;
|
||||
}
|
||||
|
||||
const viewType = this.note.getLabelValue("viewType") || "grid";
|
||||
|
||||
this.$viewTypeSelect.val(viewType);
|
||||
|
||||
this.$expandChildrenButton.toggle(viewType === "list");
|
||||
this.$collapseAllButton.toggle(viewType === "list");
|
||||
}
|
||||
|
||||
async toggleViewType(type: string) {
|
||||
if (!this.noteId) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!["list", "grid", "calendar"].includes(type)) {
|
||||
throw new Error(t("book_properties.invalid_view_type", { type }));
|
||||
}
|
||||
|
||||
await attributeService.setLabel(this.noteId, "viewType", type);
|
||||
}
|
||||
|
||||
entitiesReloadedEvent({ loadResults }: EventData<"entitiesReloaded">) {
|
||||
if (loadResults.getAttributeRows().find((attr) => attr.noteId === this.noteId && attr.name === "viewType")) {
|
||||
this.refresh();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
import { t } from "../../services/i18n.js";
|
||||
import options from "../../services/options.js";
|
||||
import utils from "../../services/utils.js";
|
||||
import NoteContextAwareWidget from "../note_context_aware_widget.js";
|
||||
|
||||
const TPL = /*html*/`\
|
||||
<div class="classic-toolbar-widget"></div>
|
||||
|
||||
<style>
|
||||
.classic-toolbar-widget {
|
||||
--ck-color-toolbar-background: transparent;
|
||||
--ck-color-button-default-background: transparent;
|
||||
--ck-color-button-default-disabled-background: transparent;
|
||||
min-height: 39px;
|
||||
}
|
||||
|
||||
.classic-toolbar-widget .ck.ck-toolbar {
|
||||
border: none;
|
||||
}
|
||||
|
||||
.classic-toolbar-widget .ck.ck-button.ck-disabled {
|
||||
opacity: 0.3;
|
||||
}
|
||||
</style>
|
||||
`;
|
||||
|
||||
/**
|
||||
* Handles the editing toolbar when the CKEditor is in decoupled mode.
|
||||
*
|
||||
* <p>
|
||||
* This toolbar is only enabled if the user has selected the classic CKEditor.
|
||||
*
|
||||
* <p>
|
||||
* The ribbon item is active by default for text notes, as long as they are not in read-only mode.
|
||||
*/
|
||||
export default class ClassicEditorToolbar extends NoteContextAwareWidget {
|
||||
|
||||
get name() {
|
||||
return "classicEditor";
|
||||
}
|
||||
|
||||
get toggleCommand() {
|
||||
return "toggleRibbonTabClassicEditor";
|
||||
}
|
||||
|
||||
doRender() {
|
||||
this.$widget = $(TPL);
|
||||
this.contentSized();
|
||||
}
|
||||
|
||||
async getTitle() {
|
||||
return {
|
||||
show: await this.#shouldDisplay(),
|
||||
activate: true,
|
||||
title: t("classic_editor_toolbar.title"),
|
||||
icon: "bx bx-text"
|
||||
};
|
||||
}
|
||||
|
||||
async #shouldDisplay() {
|
||||
if (options.get("textNoteEditorType") !== "ckeditor-classic") {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!this.note || this.note.type !== "text") {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (await this.noteContext?.isReadOnly()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
}
|
||||
99
apps/client/src/widgets/ribbon_widgets/edited_notes.ts
Normal file
99
apps/client/src/widgets/ribbon_widgets/edited_notes.ts
Normal file
@@ -0,0 +1,99 @@
|
||||
import linkService from "../../services/link.js";
|
||||
import server from "../../services/server.js";
|
||||
import froca from "../../services/froca.js";
|
||||
import NoteContextAwareWidget from "../note_context_aware_widget.js";
|
||||
import options from "../../services/options.js";
|
||||
import { t } from "../../services/i18n.js";
|
||||
import type FNote from "../../entities/fnote.js";
|
||||
|
||||
const TPL = /*html*/`
|
||||
<div class="edited-notes-widget">
|
||||
<style>
|
||||
.edited-notes-widget {
|
||||
padding: 12px;
|
||||
max-height: 200px;
|
||||
width: 100%;
|
||||
overflow: auto;
|
||||
}
|
||||
</style>
|
||||
|
||||
<div class="no-edited-notes-found">${t("edited_notes.no_edited_notes_found")}</div>
|
||||
|
||||
<div class="edited-notes-list"></div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
// TODO: Deduplicate with server.
|
||||
interface EditedNotesResponse {
|
||||
noteId: string;
|
||||
isDeleted: boolean;
|
||||
title: string;
|
||||
notePath: string[];
|
||||
}
|
||||
|
||||
export default class EditedNotesWidget extends NoteContextAwareWidget {
|
||||
|
||||
private $list!: JQuery<HTMLElement>;
|
||||
private $noneFound!: JQuery<HTMLElement>;
|
||||
|
||||
get name() {
|
||||
return "editedNotes";
|
||||
}
|
||||
|
||||
isEnabled() {
|
||||
return super.isEnabled() && this.note?.hasOwnedLabel("dateNote");
|
||||
}
|
||||
|
||||
getTitle() {
|
||||
return {
|
||||
show: this.isEnabled(),
|
||||
// promoted attributes have priority over edited notes
|
||||
activate: (this.note?.getPromotedDefinitionAttributes().length === 0 || !options.is("promotedAttributesOpenInRibbon")) && options.is("editedNotesOpenInRibbon"),
|
||||
title: t("edited_notes.title"),
|
||||
icon: "bx bx-calendar-edit"
|
||||
};
|
||||
}
|
||||
|
||||
async doRender() {
|
||||
this.$widget = $(TPL);
|
||||
this.contentSized();
|
||||
this.$list = this.$widget.find(".edited-notes-list");
|
||||
this.$noneFound = this.$widget.find(".no-edited-notes-found");
|
||||
}
|
||||
|
||||
async refreshWithNote(note: FNote) {
|
||||
let editedNotes = await server.get<EditedNotesResponse[]>(`edited-notes/${note.getLabelValue("dateNote")}`);
|
||||
|
||||
editedNotes = editedNotes.filter((n) => n.noteId !== note.noteId);
|
||||
|
||||
this.$list.empty();
|
||||
this.$noneFound.hide();
|
||||
|
||||
if (editedNotes.length === 0) {
|
||||
this.$noneFound.show();
|
||||
return;
|
||||
}
|
||||
|
||||
const noteIds = editedNotes.flatMap((n) => n.noteId);
|
||||
|
||||
await froca.getNotes(noteIds, true); // preload all at once
|
||||
|
||||
for (let i = 0; i < editedNotes.length; i++) {
|
||||
const editedNote = editedNotes[i];
|
||||
const $item = $('<span class="edited-note-line">');
|
||||
|
||||
if (editedNote.isDeleted) {
|
||||
const title = `${editedNote.title} ${t("edited_notes.deleted")}`;
|
||||
$item.append($("<i>").text(title).attr("title", title));
|
||||
} else {
|
||||
$item.append(editedNote.notePath ? await linkService.createLink(editedNote.notePath.join("/"), { showNotePath: true }) : $("<span>").text(editedNote.title));
|
||||
}
|
||||
|
||||
if (i < editedNotes.length - 1) {
|
||||
$item.append(", ");
|
||||
}
|
||||
|
||||
this.$list.append($item);
|
||||
}
|
||||
}
|
||||
}
|
||||
156
apps/client/src/widgets/ribbon_widgets/file_properties.ts
Normal file
156
apps/client/src/widgets/ribbon_widgets/file_properties.ts
Normal file
@@ -0,0 +1,156 @@
|
||||
import server from "../../services/server.js";
|
||||
import NoteContextAwareWidget from "../note_context_aware_widget.js";
|
||||
import toastService from "../../services/toast.js";
|
||||
import openService from "../../services/open.js";
|
||||
import utils from "../../services/utils.js";
|
||||
import protectedSessionHolder from "../../services/protected_session_holder.js";
|
||||
import { t } from "../../services/i18n.js";
|
||||
import type FNote from "../../entities/fnote.js";
|
||||
|
||||
const TPL = /*html*/`
|
||||
<div class="file-properties-widget">
|
||||
<style>
|
||||
.file-table {
|
||||
width: 100%;
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.file-table th, .file-table td {
|
||||
padding: 5px;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.file-buttons {
|
||||
padding: 10px;
|
||||
display: flex;
|
||||
justify-content: space-evenly;
|
||||
}
|
||||
</style>
|
||||
|
||||
<table class="file-table">
|
||||
<tr>
|
||||
<th class="text-nowrap">${t("file_properties.note_id")}:</th>
|
||||
<td class="file-note-id"></td>
|
||||
<th class="text-nowrap">${t("file_properties.original_file_name")}:</th>
|
||||
<td class="file-filename"></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th class="text-nowrap">${t("file_properties.file_type")}:</th>
|
||||
<td class="file-filetype"></td>
|
||||
<th class="text-nowrap">${t("file_properties.file_size")}:</th>
|
||||
<td class="file-filesize"></td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td colspan="4">
|
||||
<div class="file-buttons">
|
||||
<button class="file-download btn btn-sm btn-primary" type="button">
|
||||
<span class="bx bx-download"></span>
|
||||
${t("file_properties.download")}
|
||||
</button>
|
||||
|
||||
<button class="file-open btn btn-sm btn-primary" type="button">
|
||||
<span class="bx bx-link-external"></span>
|
||||
${t("file_properties.open")}
|
||||
</button>
|
||||
|
||||
<button class="file-upload-new-revision btn btn-sm btn-primary">
|
||||
<span class="bx bx-folder-open"></span>
|
||||
${t("file_properties.upload_new_revision")}
|
||||
</button>
|
||||
|
||||
<input type="file" class="file-upload-new-revision-input" style="display: none">
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>`;
|
||||
|
||||
export default class FilePropertiesWidget extends NoteContextAwareWidget {
|
||||
|
||||
private $fileNoteId!: JQuery<HTMLElement>;
|
||||
private $fileName!: JQuery<HTMLElement>;
|
||||
private $fileType!: JQuery<HTMLElement>;
|
||||
private $fileSize!: JQuery<HTMLElement>;
|
||||
private $downloadButton!: JQuery<HTMLElement>;
|
||||
private $openButton!: JQuery<HTMLElement>;
|
||||
private $uploadNewRevisionButton!: JQuery<HTMLElement>;
|
||||
private $uploadNewRevisionInput!: JQuery<HTMLFormElement>;
|
||||
|
||||
get name() {
|
||||
return "fileProperties";
|
||||
}
|
||||
|
||||
get toggleCommand() {
|
||||
return "toggleRibbonTabFileProperties";
|
||||
}
|
||||
|
||||
isEnabled() {
|
||||
return this.note && this.note.type === "file";
|
||||
}
|
||||
|
||||
getTitle() {
|
||||
return {
|
||||
show: this.isEnabled(),
|
||||
activate: true,
|
||||
title: t("file_properties.title"),
|
||||
icon: "bx bx-file"
|
||||
};
|
||||
}
|
||||
|
||||
doRender() {
|
||||
this.$widget = $(TPL);
|
||||
this.contentSized();
|
||||
this.$fileNoteId = this.$widget.find(".file-note-id");
|
||||
this.$fileName = this.$widget.find(".file-filename");
|
||||
this.$fileType = this.$widget.find(".file-filetype");
|
||||
this.$fileSize = this.$widget.find(".file-filesize");
|
||||
this.$downloadButton = this.$widget.find(".file-download");
|
||||
this.$openButton = this.$widget.find(".file-open");
|
||||
this.$uploadNewRevisionButton = this.$widget.find(".file-upload-new-revision");
|
||||
this.$uploadNewRevisionInput = this.$widget.find(".file-upload-new-revision-input");
|
||||
|
||||
this.$downloadButton.on("click", () => this.noteId && openService.downloadFileNote(this.noteId));
|
||||
this.$openButton.on("click", () => this.noteId && this.note && openService.openNoteExternally(this.noteId, this.note.mime));
|
||||
|
||||
this.$uploadNewRevisionButton.on("click", () => {
|
||||
this.$uploadNewRevisionInput.trigger("click");
|
||||
});
|
||||
|
||||
this.$uploadNewRevisionInput.on("change", async () => {
|
||||
const fileToUpload = this.$uploadNewRevisionInput[0].files[0]; // copy to allow reset below
|
||||
this.$uploadNewRevisionInput.val("");
|
||||
|
||||
const result = await server.upload(`notes/${this.noteId}/file`, fileToUpload);
|
||||
|
||||
if (result.uploaded) {
|
||||
toastService.showMessage(t("file_properties.upload_success"));
|
||||
|
||||
this.refresh();
|
||||
} else {
|
||||
toastService.showError(t("file_properties.upload_failed"));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async refreshWithNote(note: FNote) {
|
||||
this.$widget.show();
|
||||
|
||||
if (!this.note) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.$fileNoteId.text(note.noteId);
|
||||
this.$fileName.text(note.getLabelValue("originalFileName") || "?");
|
||||
this.$fileType.text(note.mime);
|
||||
|
||||
const blob = await this.note.getBlob();
|
||||
|
||||
this.$fileSize.text(utils.formatSize(blob?.contentLength ?? 0));
|
||||
|
||||
// open doesn't work for protected notes since it works through a browser which isn't in protected session
|
||||
this.$openButton.toggle(!note.isProtected);
|
||||
this.$downloadButton.toggle(!note.isProtected || protectedSessionHolder.isProtectedSessionAvailable());
|
||||
this.$uploadNewRevisionButton.toggle(!note.isProtected || protectedSessionHolder.isProtectedSessionAvailable());
|
||||
}
|
||||
}
|
||||
136
apps/client/src/widgets/ribbon_widgets/image_properties.ts
Normal file
136
apps/client/src/widgets/ribbon_widgets/image_properties.ts
Normal file
@@ -0,0 +1,136 @@
|
||||
import server from "../../services/server.js";
|
||||
import NoteContextAwareWidget from "../note_context_aware_widget.js";
|
||||
import toastService from "../../services/toast.js";
|
||||
import openService from "../../services/open.js";
|
||||
import utils from "../../services/utils.js";
|
||||
import { t } from "../../services/i18n.js";
|
||||
import type FNote from "../../entities/fnote.js";
|
||||
|
||||
const TPL = /*html*/`
|
||||
<div class="image-properties">
|
||||
<div style="display: flex; justify-content: space-evenly; margin: 10px;">
|
||||
<span>
|
||||
<strong>${t("image_properties.original_file_name")}:</strong>
|
||||
<span class="image-filename"></span>
|
||||
</span>
|
||||
|
||||
<span>
|
||||
<strong>${t("image_properties.file_type")}:</strong>
|
||||
<span class="image-filetype"></span>
|
||||
</span>
|
||||
|
||||
<span>
|
||||
<strong>${t("image_properties.file_size")}:</strong>
|
||||
<span class="image-filesize"></span>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="no-print" style="display: flex; justify-content: space-evenly; margin: 10px;">
|
||||
<button class="image-download btn btn-sm btn-primary" type="button">
|
||||
<span class="bx bx-download"></span>
|
||||
${t("image_properties.download")}
|
||||
</button>
|
||||
|
||||
<button class="image-open btn btn-sm btn-primary" type="button">
|
||||
<span class="bx bx-link-external"></span>
|
||||
${t("image_properties.open")}
|
||||
</button>
|
||||
|
||||
<button class="image-copy-reference-to-clipboard btn btn-sm btn-primary" type="button">
|
||||
<span class="bx bx-copy"></span>
|
||||
${t("image_properties.copy_reference_to_clipboard")}
|
||||
</button>
|
||||
|
||||
<button class="image-upload-new-revision btn btn-sm btn-primary" type="button">
|
||||
<span class="bx bx-folder-open"></span>
|
||||
${t("image_properties.upload_new_revision")}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<input type="file" class="image-upload-new-revision-input" style="display: none">
|
||||
</div>`;
|
||||
|
||||
export default class ImagePropertiesWidget extends NoteContextAwareWidget {
|
||||
|
||||
private $copyReferenceToClipboardButton!: JQuery<HTMLElement>;
|
||||
private $uploadNewRevisionButton!: JQuery<HTMLElement>;
|
||||
private $uploadNewRevisionInput!: JQuery<HTMLFormElement>;
|
||||
private $fileName!: JQuery<HTMLElement>;
|
||||
private $fileType!: JQuery<HTMLElement>;
|
||||
private $fileSize!: JQuery<HTMLElement>;
|
||||
private $openButton!: JQuery<HTMLElement>;
|
||||
private $imageDownloadButton!: JQuery<HTMLElement>;
|
||||
|
||||
get name() {
|
||||
return "imageProperties";
|
||||
}
|
||||
|
||||
get toggleCommand() {
|
||||
return "toggleRibbonTabImageProperties";
|
||||
}
|
||||
|
||||
isEnabled() {
|
||||
return this.note && this.note.type === "image";
|
||||
}
|
||||
|
||||
getTitle() {
|
||||
return {
|
||||
show: this.isEnabled(),
|
||||
activate: true,
|
||||
title: t("image_properties.title"),
|
||||
icon: "bx bx-image"
|
||||
};
|
||||
}
|
||||
|
||||
doRender() {
|
||||
this.$widget = $(TPL);
|
||||
this.contentSized();
|
||||
|
||||
this.$copyReferenceToClipboardButton = this.$widget.find(".image-copy-reference-to-clipboard");
|
||||
this.$copyReferenceToClipboardButton.on("click", () => this.triggerEvent(`copyImageReferenceToClipboard`, { ntxId: this.noteContext?.ntxId }));
|
||||
|
||||
this.$uploadNewRevisionButton = this.$widget.find(".image-upload-new-revision");
|
||||
this.$uploadNewRevisionInput = this.$widget.find(".image-upload-new-revision-input");
|
||||
|
||||
this.$fileName = this.$widget.find(".image-filename");
|
||||
this.$fileType = this.$widget.find(".image-filetype");
|
||||
this.$fileSize = this.$widget.find(".image-filesize");
|
||||
|
||||
this.$openButton = this.$widget.find(".image-open");
|
||||
this.$openButton.on("click", () => this.noteId && this.note && openService.openNoteExternally(this.noteId, this.note.mime));
|
||||
|
||||
this.$imageDownloadButton = this.$widget.find(".image-download");
|
||||
this.$imageDownloadButton.on("click", () => this.noteId && openService.downloadFileNote(this.noteId));
|
||||
|
||||
this.$uploadNewRevisionButton.on("click", () => {
|
||||
this.$uploadNewRevisionInput.trigger("click");
|
||||
});
|
||||
|
||||
this.$uploadNewRevisionInput.on("change", async () => {
|
||||
const fileToUpload = this.$uploadNewRevisionInput[0].files[0]; // copy to allow reset below
|
||||
this.$uploadNewRevisionInput.val("");
|
||||
|
||||
const result = await server.upload(`images/${this.noteId}`, fileToUpload);
|
||||
|
||||
if (result.uploaded) {
|
||||
toastService.showMessage(t("image_properties.upload_success"));
|
||||
|
||||
await utils.clearBrowserCache();
|
||||
|
||||
this.refresh();
|
||||
} else {
|
||||
toastService.showError(t("image_properties.upload_failed", { message: result.message }));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async refreshWithNote(note: FNote) {
|
||||
this.$widget.show();
|
||||
|
||||
const blob = await this.note?.getBlob();
|
||||
|
||||
this.$fileName.text(note.getLabelValue("originalFileName") || "?");
|
||||
this.$fileSize.text(utils.formatSize(blob?.contentLength ?? 0));
|
||||
this.$fileType.text(note.mime);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
import NoteContextAwareWidget from "../note_context_aware_widget.js";
|
||||
import AttributeDetailWidget from "../attribute_widgets/attribute_detail.js";
|
||||
import attributeRenderer from "../../services/attribute_renderer.js";
|
||||
import attributeService from "../../services/attributes.js";
|
||||
import { t } from "../../services/i18n.js";
|
||||
import type FNote from "../../entities/fnote.js";
|
||||
import type { EventData } from "../../components/app_context.js";
|
||||
|
||||
const TPL = /*html*/`
|
||||
<div class="inherited-attributes-widget">
|
||||
<style>
|
||||
.inherited-attributes-widget {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.inherited-attributes-container {
|
||||
color: var(--muted-text-color);
|
||||
max-height: 200px;
|
||||
overflow: auto;
|
||||
padding: 14px 12px 13px 12px;
|
||||
}
|
||||
</style>
|
||||
|
||||
<div class="inherited-attributes-container"></div>
|
||||
</div>`;
|
||||
|
||||
export default class InheritedAttributesWidget extends NoteContextAwareWidget {
|
||||
|
||||
private attributeDetailWidget: AttributeDetailWidget;
|
||||
|
||||
private $container!: JQuery<HTMLElement>;
|
||||
|
||||
get name() {
|
||||
return "inheritedAttributes";
|
||||
}
|
||||
|
||||
get toggleCommand() {
|
||||
return "toggleRibbonTabInheritedAttributes";
|
||||
}
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
|
||||
this.attributeDetailWidget = new AttributeDetailWidget().contentSized().setParent(this);
|
||||
|
||||
this.child(this.attributeDetailWidget);
|
||||
}
|
||||
|
||||
getTitle() {
|
||||
return {
|
||||
show: !this.note?.isLaunchBarConfig(),
|
||||
title: t("inherited_attribute_list.title"),
|
||||
icon: "bx bx-list-plus"
|
||||
};
|
||||
}
|
||||
|
||||
doRender() {
|
||||
this.$widget = $(TPL);
|
||||
this.contentSized();
|
||||
|
||||
this.$container = this.$widget.find(".inherited-attributes-container");
|
||||
this.$widget.append(this.attributeDetailWidget.render());
|
||||
}
|
||||
|
||||
async refreshWithNote(note: FNote) {
|
||||
this.$container.empty();
|
||||
|
||||
const inheritedAttributes = this.getInheritedAttributes(note);
|
||||
|
||||
if (inheritedAttributes.length === 0) {
|
||||
this.$container.append(t("inherited_attribute_list.no_inherited_attributes"));
|
||||
return;
|
||||
}
|
||||
|
||||
for (const attribute of inheritedAttributes) {
|
||||
const $attr = (await attributeRenderer.renderAttribute(attribute, false)).on("click", (e) => {
|
||||
setTimeout(
|
||||
() =>
|
||||
this.attributeDetailWidget.showAttributeDetail({
|
||||
attribute: {
|
||||
noteId: attribute.noteId,
|
||||
type: attribute.type,
|
||||
name: attribute.name,
|
||||
value: attribute.value,
|
||||
isInheritable: attribute.isInheritable
|
||||
},
|
||||
isOwned: false,
|
||||
x: e.pageX,
|
||||
y: e.pageY
|
||||
}),
|
||||
100
|
||||
);
|
||||
});
|
||||
|
||||
this.$container.append($attr).append(" ");
|
||||
}
|
||||
}
|
||||
|
||||
getInheritedAttributes(note: FNote) {
|
||||
const attrs = note.getAttributes().filter((attr) => attr.noteId !== this.noteId);
|
||||
|
||||
attrs.sort((a, b) => {
|
||||
if (a.noteId === b.noteId) {
|
||||
return a.position - b.position;
|
||||
} else {
|
||||
// inherited attributes should stay grouped: https://github.com/zadam/trilium/issues/3761
|
||||
return a.noteId < b.noteId ? -1 : 1;
|
||||
}
|
||||
});
|
||||
|
||||
return attrs;
|
||||
}
|
||||
|
||||
entitiesReloadedEvent({ loadResults }: EventData<"entitiesReloaded">) {
|
||||
if (loadResults.getAttributeRows(this.componentId).find((attr) => attributeService.isAffecting(attr, this.note))) {
|
||||
this.refresh();
|
||||
}
|
||||
}
|
||||
}
|
||||
130
apps/client/src/widgets/ribbon_widgets/mobile_editor_toolbar.ts
Normal file
130
apps/client/src/widgets/ribbon_widgets/mobile_editor_toolbar.ts
Normal file
@@ -0,0 +1,130 @@
|
||||
import { isIOS } from "../../services/utils.js";
|
||||
import NoteContextAwareWidget from "../note_context_aware_widget.js";
|
||||
|
||||
const TPL = /*html*/`\
|
||||
<div class="classic-toolbar-outer-container">
|
||||
<div class="classic-toolbar-widget"></div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.classic-toolbar-outer-container.visible {
|
||||
height: 38px;
|
||||
background-color: var(--main-background-color);
|
||||
position: relative;
|
||||
overflow: visible;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
#root-widget.virtual-keyboard-opened .classic-toolbar-outer-container.ios {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
}
|
||||
|
||||
.classic-toolbar-widget {
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 38px;
|
||||
overflow: scroll;
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.classic-toolbar-widget::-webkit-scrollbar {
|
||||
height: 0 !important;
|
||||
width: 0 !important;
|
||||
}
|
||||
|
||||
.classic-toolbar-widget.dropdown-active {
|
||||
height: 50vh;
|
||||
}
|
||||
|
||||
.classic-toolbar-widget .ck.ck-toolbar {
|
||||
--ck-color-toolbar-background: transparent;
|
||||
--ck-color-button-default-background: transparent;
|
||||
--ck-color-button-default-disabled-background: transparent;
|
||||
position: absolute;
|
||||
background-color: transparent;
|
||||
border: none;
|
||||
}
|
||||
|
||||
.classic-toolbar-widget .ck.ck-button.ck-disabled {
|
||||
opacity: 0.3;
|
||||
}
|
||||
</style>
|
||||
`;
|
||||
|
||||
/**
|
||||
* Handles the editing toolbar for CKEditor in mobile mode. The toolbar acts as a floating bar, with two different mechanism:
|
||||
*
|
||||
* - On iOS, because it does not respect the viewport meta value `interactive-widget=resizes-content`, we need to listen to window resizes and scroll and reposition the toolbar using absolute positioning.
|
||||
* - On Android, the viewport change makes the keyboard resize the content area, all we have to do is to hide the tab bar and global menu (handled in the global style).
|
||||
*/
|
||||
export default class MobileEditorToolbar extends NoteContextAwareWidget {
|
||||
|
||||
private observer: MutationObserver;
|
||||
private $innerWrapper!: JQuery<HTMLElement>;
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
this.observer = new MutationObserver((e) => this.#onDropdownStateChanged(e));
|
||||
}
|
||||
|
||||
get name() {
|
||||
return "classicEditor";
|
||||
}
|
||||
|
||||
doRender() {
|
||||
this.$widget = $(TPL);
|
||||
this.$innerWrapper = this.$widget.find(".classic-toolbar-widget");
|
||||
this.contentSized();
|
||||
|
||||
// Observe when a dropdown is expanded to apply a style that allows the dropdown to be visible, since we can't have the element both visible and the toolbar scrollable.
|
||||
this.observer.disconnect();
|
||||
this.observer.observe(this.$widget[0], {
|
||||
attributeFilter: ["aria-expanded"],
|
||||
subtree: true
|
||||
});
|
||||
|
||||
if (isIOS()) {
|
||||
this.#handlePositioningOniOS();
|
||||
}
|
||||
}
|
||||
|
||||
#handlePositioningOniOS() {
|
||||
const adjustPosition = () => {
|
||||
let bottom = window.innerHeight - (window.visualViewport?.height || 0);
|
||||
this.$widget.css("bottom", `${bottom}px`);
|
||||
}
|
||||
|
||||
this.$widget.addClass("ios");
|
||||
window.visualViewport?.addEventListener("resize", adjustPosition);
|
||||
window.addEventListener("scroll", adjustPosition);
|
||||
}
|
||||
|
||||
#onDropdownStateChanged(e: MutationRecord[]) {
|
||||
const dropdownActive = e.map((e) => (e.target as any).ariaExpanded === "true").reduce((acc, e) => acc && e);
|
||||
this.$innerWrapper.toggleClass("dropdown-active", dropdownActive);
|
||||
}
|
||||
|
||||
async #shouldDisplay() {
|
||||
if (!this.note || this.note.type !== "text") {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (await this.noteContext?.isReadOnly()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
async refreshWithNote() {
|
||||
this.toggleExt(await this.#shouldDisplay());
|
||||
}
|
||||
|
||||
}
|
||||
173
apps/client/src/widgets/ribbon_widgets/note_info_widget.ts
Normal file
173
apps/client/src/widgets/ribbon_widgets/note_info_widget.ts
Normal file
@@ -0,0 +1,173 @@
|
||||
import { formatDateTime } from "../../utils/formatters.js";
|
||||
import { t } from "../../services/i18n.js";
|
||||
import NoteContextAwareWidget from "../note_context_aware_widget.js";
|
||||
import server from "../../services/server.js";
|
||||
import utils from "../../services/utils.js";
|
||||
import type { EventData } from "../../components/app_context.js";
|
||||
import type FNote from "../../entities/fnote.js";
|
||||
|
||||
const TPL = /*html*/`
|
||||
<div class="note-info-widget">
|
||||
<style>
|
||||
.note-info-widget {
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.note-info-widget-table {
|
||||
max-width: 100%;
|
||||
display: block;
|
||||
overflow-x: auto;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.note-info-widget-table td, .note-info-widget-table th {
|
||||
padding: 5px;
|
||||
}
|
||||
|
||||
.note-info-mime {
|
||||
max-width: 13em;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
</style>
|
||||
|
||||
<table class="note-info-widget-table">
|
||||
<tr>
|
||||
<th>${t("note_info_widget.note_id")}:</th>
|
||||
<td class="note-info-note-id"></td>
|
||||
<th>${t("note_info_widget.created")}:</th>
|
||||
<td class="note-info-date-created"></td>
|
||||
<th>${t("note_info_widget.modified")}:</th>
|
||||
<td class="note-info-date-modified"></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>${t("note_info_widget.type")}:</th>
|
||||
<td>
|
||||
<span class="note-info-type"></span>
|
||||
<span class="note-info-mime"></span>
|
||||
</td>
|
||||
|
||||
<th title="${t("note_info_widget.note_size_info")}">${t("note_info_widget.note_size")}:</th>
|
||||
<td colspan="3">
|
||||
<button class="btn btn-sm calculate-button" style="padding: 0px 10px 0px 10px;">
|
||||
<span class="bx bx-calculator"></span> ${t("note_info_widget.calculate")}
|
||||
</button>
|
||||
<span class="note-sizes-wrapper">
|
||||
<span class="note-size"></span>
|
||||
<span class="subtree-size"></span>
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
`;
|
||||
|
||||
// TODO: Deduplicate with server
|
||||
interface NoteSizeResponse {
|
||||
noteSize: number;
|
||||
}
|
||||
|
||||
interface SubtreeSizeResponse {
|
||||
subTreeNoteCount: number;
|
||||
subTreeSize: number;
|
||||
}
|
||||
|
||||
interface MetadataResponse {
|
||||
dateCreated: number;
|
||||
dateModified: number;
|
||||
}
|
||||
|
||||
export default class NoteInfoWidget extends NoteContextAwareWidget {
|
||||
|
||||
private $noteId!: JQuery<HTMLElement>;
|
||||
private $dateCreated!: JQuery<HTMLElement>;
|
||||
private $dateModified!: JQuery<HTMLElement>;
|
||||
private $type!: JQuery<HTMLElement>;
|
||||
private $mime!: JQuery<HTMLElement>;
|
||||
private $noteSizesWrapper!: JQuery<HTMLElement>;
|
||||
private $noteSize!: JQuery<HTMLElement>;
|
||||
private $subTreeSize!: JQuery<HTMLElement>;
|
||||
private $calculateButton!: JQuery<HTMLElement>;
|
||||
|
||||
get name() {
|
||||
return "noteInfo";
|
||||
}
|
||||
|
||||
get toggleCommand() {
|
||||
return "toggleRibbonTabNoteInfo";
|
||||
}
|
||||
|
||||
isEnabled() {
|
||||
return !!this.note;
|
||||
}
|
||||
|
||||
getTitle() {
|
||||
return {
|
||||
show: this.isEnabled(),
|
||||
title: t("note_info_widget.title"),
|
||||
icon: "bx bx-info-circle"
|
||||
};
|
||||
}
|
||||
|
||||
doRender() {
|
||||
this.$widget = $(TPL);
|
||||
this.contentSized();
|
||||
|
||||
this.$noteId = this.$widget.find(".note-info-note-id");
|
||||
this.$dateCreated = this.$widget.find(".note-info-date-created");
|
||||
this.$dateModified = this.$widget.find(".note-info-date-modified");
|
||||
this.$type = this.$widget.find(".note-info-type");
|
||||
this.$mime = this.$widget.find(".note-info-mime");
|
||||
|
||||
this.$noteSizesWrapper = this.$widget.find(".note-sizes-wrapper");
|
||||
this.$noteSize = this.$widget.find(".note-size");
|
||||
this.$subTreeSize = this.$widget.find(".subtree-size");
|
||||
|
||||
this.$calculateButton = this.$widget.find(".calculate-button");
|
||||
this.$calculateButton.on("click", async () => {
|
||||
this.$noteSizesWrapper.show();
|
||||
this.$calculateButton.hide();
|
||||
|
||||
this.$noteSize.empty().append($('<span class="bx bx-loader bx-spin"></span>'));
|
||||
this.$subTreeSize.empty().append($('<span class="bx bx-loader bx-spin"></span>'));
|
||||
|
||||
const noteSizeResp = await server.get<NoteSizeResponse>(`stats/note-size/${this.noteId}`);
|
||||
this.$noteSize.text(utils.formatSize(noteSizeResp.noteSize));
|
||||
|
||||
const subTreeResp = await server.get<SubtreeSizeResponse>(`stats/subtree-size/${this.noteId}`);
|
||||
|
||||
if (subTreeResp.subTreeNoteCount > 1) {
|
||||
this.$subTreeSize.text(t("note_info_widget.subtree_size", { size: utils.formatSize(subTreeResp.subTreeSize), count: subTreeResp.subTreeNoteCount }));
|
||||
} else {
|
||||
this.$subTreeSize.text("");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async refreshWithNote(note: FNote) {
|
||||
const metadata = await server.get<MetadataResponse>(`notes/${this.noteId}/metadata`);
|
||||
|
||||
this.$noteId.text(note.noteId);
|
||||
this.$dateCreated.text(formatDateTime(metadata.dateCreated)).attr("title", metadata.dateCreated);
|
||||
|
||||
this.$dateModified.text(formatDateTime(metadata.dateModified)).attr("title", metadata.dateModified);
|
||||
|
||||
this.$type.text(note.type);
|
||||
|
||||
if (note.mime) {
|
||||
this.$mime.text(`(${note.mime})`);
|
||||
} else {
|
||||
this.$mime.empty();
|
||||
}
|
||||
|
||||
this.$calculateButton.show();
|
||||
this.$noteSizesWrapper.hide();
|
||||
}
|
||||
|
||||
entitiesReloadedEvent({ loadResults }: EventData<"entitiesReloaded">) {
|
||||
if (this.noteId && (loadResults.isNoteReloaded(this.noteId) || loadResults.isNoteContentReloaded(this.noteId))) {
|
||||
this.refresh();
|
||||
}
|
||||
}
|
||||
}
|
||||
131
apps/client/src/widgets/ribbon_widgets/note_map.ts
Normal file
131
apps/client/src/widgets/ribbon_widgets/note_map.ts
Normal file
@@ -0,0 +1,131 @@
|
||||
import NoteContextAwareWidget from "../note_context_aware_widget.js";
|
||||
import NoteMapWidget from "../note_map.js";
|
||||
import { t } from "../../services/i18n.js";
|
||||
|
||||
const TPL = /*html*/`
|
||||
<div class="note-map-ribbon-widget">
|
||||
<style>
|
||||
.note-map-ribbon-widget {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.note-map-ribbon-widget .note-map-container {
|
||||
height: 300px;
|
||||
}
|
||||
|
||||
.open-full-button, .collapse-button {
|
||||
position: absolute;
|
||||
right: 5px;
|
||||
bottom: 5px;
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
.style-resolver {
|
||||
color: var(--muted-text-color);
|
||||
display: none;
|
||||
}
|
||||
</style>
|
||||
|
||||
<button class="bx bx-arrow-to-bottom icon-action open-full-button" title="${t("note_map.open_full")}"></button>
|
||||
<button class="bx bx-arrow-to-top icon-action collapse-button" style="display: none;" title="${t("note_map.collapse")}"></button>
|
||||
|
||||
<div class="note-map-container"></div>
|
||||
</div>`;
|
||||
|
||||
export default class NoteMapRibbonWidget extends NoteContextAwareWidget {
|
||||
|
||||
private openState!: "small" | "full";
|
||||
private noteMapWidget: NoteMapWidget;
|
||||
private $container!: JQuery<HTMLElement>;
|
||||
private $openFullButton!: JQuery<HTMLElement>;
|
||||
private $collapseButton!: JQuery<HTMLElement>;
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
|
||||
this.noteMapWidget = new NoteMapWidget("ribbon");
|
||||
this.child(this.noteMapWidget);
|
||||
}
|
||||
|
||||
get name() {
|
||||
return "noteMap";
|
||||
}
|
||||
|
||||
get toggleCommand() {
|
||||
return "toggleRibbonTabNoteMap";
|
||||
}
|
||||
|
||||
getTitle() {
|
||||
return {
|
||||
show: this.isEnabled(),
|
||||
title: t("note_map.title"),
|
||||
icon: "bx bxs-network-chart"
|
||||
};
|
||||
}
|
||||
|
||||
doRender() {
|
||||
this.$widget = $(TPL);
|
||||
this.contentSized();
|
||||
this.$container = this.$widget.find(".note-map-container");
|
||||
this.$container.append(this.noteMapWidget.render());
|
||||
|
||||
this.openState = "small";
|
||||
|
||||
this.$openFullButton = this.$widget.find(".open-full-button");
|
||||
this.$openFullButton.on("click", () => {
|
||||
this.setFullHeight();
|
||||
|
||||
this.$openFullButton.hide();
|
||||
this.$collapseButton.show();
|
||||
|
||||
this.openState = "full";
|
||||
|
||||
this.noteMapWidget.setDimensions();
|
||||
});
|
||||
|
||||
this.$collapseButton = this.$widget.find(".collapse-button");
|
||||
this.$collapseButton.on("click", () => {
|
||||
this.setSmallSize();
|
||||
|
||||
this.$openFullButton.show();
|
||||
this.$collapseButton.hide();
|
||||
|
||||
this.openState = "small";
|
||||
|
||||
this.noteMapWidget.setDimensions();
|
||||
});
|
||||
|
||||
const handleResize = () => {
|
||||
if (!this.noteMapWidget.graph) {
|
||||
// no graph has been even rendered
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.openState === "full") {
|
||||
this.setFullHeight();
|
||||
} else if (this.openState === "small") {
|
||||
this.setSmallSize();
|
||||
}
|
||||
};
|
||||
|
||||
new ResizeObserver(handleResize).observe(this.$widget[0]);
|
||||
}
|
||||
|
||||
setSmallSize() {
|
||||
const SMALL_SIZE_HEIGHT = 300;
|
||||
const width = this.$widget.width() ?? 0;
|
||||
|
||||
this.$widget.find(".note-map-container").height(SMALL_SIZE_HEIGHT).width(width);
|
||||
}
|
||||
|
||||
setFullHeight() {
|
||||
const { top } = this.$widget[0].getBoundingClientRect();
|
||||
|
||||
const height = ($(window).height() ?? 0) - top;
|
||||
const width = this.$widget.width() ?? 0;
|
||||
|
||||
this.$widget.find(".note-map-container")
|
||||
.height(height)
|
||||
.width(width);
|
||||
}
|
||||
}
|
||||
154
apps/client/src/widgets/ribbon_widgets/note_paths.ts
Normal file
154
apps/client/src/widgets/ribbon_widgets/note_paths.ts
Normal file
@@ -0,0 +1,154 @@
|
||||
import NoteContextAwareWidget from "../note_context_aware_widget.js";
|
||||
import treeService from "../../services/tree.js";
|
||||
import linkService from "../../services/link.js";
|
||||
import { t } from "../../services/i18n.js";
|
||||
import type FNote from "../../entities/fnote.js";
|
||||
import type { NotePathRecord } from "../../entities/fnote.js";
|
||||
import type { EventData } from "../../components/app_context.js";
|
||||
|
||||
const TPL = /*html*/`
|
||||
<div class="note-paths-widget">
|
||||
<style>
|
||||
.note-paths-widget {
|
||||
padding: 12px;
|
||||
max-height: 300px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.note-path-list {
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.note-path-list .path-current a {
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.note-path-list .path-archived a {
|
||||
color: var(--muted-text-color) !important;
|
||||
}
|
||||
|
||||
.note-path-list .path-search a {
|
||||
font-style: italic;
|
||||
}
|
||||
</style>
|
||||
|
||||
<div class="note-path-intro"></div>
|
||||
|
||||
<ul class="note-path-list"></ul>
|
||||
|
||||
<button class="btn btn-sm" data-trigger-command="cloneNoteIdsTo">${t("note_paths.clone_button")}</button>
|
||||
</div>`;
|
||||
|
||||
export default class NotePathsWidget extends NoteContextAwareWidget {
|
||||
|
||||
private $notePathIntro!: JQuery<HTMLElement>;
|
||||
private $notePathList!: JQuery<HTMLElement>;
|
||||
|
||||
get name() {
|
||||
return "notePaths";
|
||||
}
|
||||
|
||||
get toggleCommand() {
|
||||
return "toggleRibbonTabNotePaths";
|
||||
}
|
||||
|
||||
getTitle() {
|
||||
return {
|
||||
show: true,
|
||||
title: t("note_paths.title"),
|
||||
icon: "bx bx-collection"
|
||||
};
|
||||
}
|
||||
|
||||
doRender() {
|
||||
this.$widget = $(TPL);
|
||||
this.contentSized();
|
||||
|
||||
this.$notePathIntro = this.$widget.find(".note-path-intro");
|
||||
this.$notePathList = this.$widget.find(".note-path-list");
|
||||
}
|
||||
|
||||
async refreshWithNote(note: FNote) {
|
||||
this.$notePathList.empty();
|
||||
|
||||
if (!this.note || this.noteId === "root") {
|
||||
this.$notePathList.empty().append(await this.getRenderedPath(["root"]));
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
const sortedNotePaths = this.note.getSortedNotePathRecords(this.hoistedNoteId).filter((notePath) => !notePath.isHidden);
|
||||
|
||||
if (sortedNotePaths.length > 0) {
|
||||
this.$notePathIntro.text(t("note_paths.intro_placed"));
|
||||
} else {
|
||||
this.$notePathIntro.text(t("note_paths.intro_not_placed"));
|
||||
}
|
||||
|
||||
const renderedPaths = [];
|
||||
|
||||
for (const notePathRecord of sortedNotePaths) {
|
||||
const notePath = notePathRecord.notePath;
|
||||
|
||||
renderedPaths.push(await this.getRenderedPath(notePath, notePathRecord));
|
||||
}
|
||||
|
||||
this.$notePathList.empty().append(...renderedPaths);
|
||||
}
|
||||
|
||||
async getRenderedPath(notePath: string[], notePathRecord: NotePathRecord | null = null) {
|
||||
const $pathItem = $("<li>");
|
||||
const pathSegments: string[] = [];
|
||||
const lastIndex = notePath.length - 1;
|
||||
|
||||
for (let i = 0; i < notePath.length; i++) {
|
||||
const noteId = notePath[i];
|
||||
pathSegments.push(noteId);
|
||||
const title = await treeService.getNoteTitle(noteId);
|
||||
const $noteLink = await linkService.createLink(pathSegments.join("/"), { title });
|
||||
|
||||
$noteLink.find("a").addClass("no-tooltip-preview tn-link");
|
||||
$pathItem.append($noteLink);
|
||||
|
||||
if (i != lastIndex) {
|
||||
$pathItem.append(" / ");
|
||||
}
|
||||
}
|
||||
|
||||
const icons = [];
|
||||
|
||||
if (this.notePath === notePath.join("/")) {
|
||||
$pathItem.addClass("path-current");
|
||||
}
|
||||
|
||||
if (!notePathRecord || notePathRecord.isInHoistedSubTree) {
|
||||
$pathItem.addClass("path-in-hoisted-subtree");
|
||||
} else {
|
||||
icons.push(`<span class="bx bx-trending-up" title="${t("note_paths.outside_hoisted")}"></span>`);
|
||||
}
|
||||
|
||||
if (notePathRecord?.isArchived) {
|
||||
$pathItem.addClass("path-archived");
|
||||
|
||||
icons.push(`<span class="bx bx-archive" title="${t("note_paths.archived")}"></span>`);
|
||||
}
|
||||
|
||||
if (notePathRecord?.isSearch) {
|
||||
$pathItem.addClass("path-search");
|
||||
|
||||
icons.push(`<span class="bx bx-search" title="${t("note_paths.search")}"></span>`);
|
||||
}
|
||||
|
||||
if (icons.length > 0) {
|
||||
$pathItem.append(` ${icons.join(" ")}`);
|
||||
}
|
||||
|
||||
return $pathItem;
|
||||
}
|
||||
|
||||
entitiesReloadedEvent({ loadResults }: EventData<"entitiesReloaded">) {
|
||||
if (loadResults.getBranchRows().find((branch) => branch.noteId === this.noteId) || (this.noteId != null && loadResults.isNoteReloaded(this.noteId))) {
|
||||
this.refresh();
|
||||
}
|
||||
}
|
||||
}
|
||||
54
apps/client/src/widgets/ribbon_widgets/note_properties.ts
Normal file
54
apps/client/src/widgets/ribbon_widgets/note_properties.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
import type FNote from "../../entities/fnote.js";
|
||||
import { t } from "../../services/i18n.js";
|
||||
import NoteContextAwareWidget from "../note_context_aware_widget.js";
|
||||
|
||||
const TPL = /*html*/`
|
||||
<div class="note-properties-widget">
|
||||
<style>
|
||||
.note-properties-widget {
|
||||
padding: 12px;
|
||||
color: var(--muted-text-color);
|
||||
}
|
||||
</style>
|
||||
|
||||
<div style="overflow: hidden; text-overflow: ellipsis; white-space: nowrap">
|
||||
${t("note_properties.this_note_was_originally_taken_from")} <a class="page-url external"></a>
|
||||
</div>
|
||||
</div>`;
|
||||
|
||||
/**
|
||||
* TODO: figure out better name or conceptualize better.
|
||||
*/
|
||||
export default class NotePropertiesWidget extends NoteContextAwareWidget {
|
||||
|
||||
private $pageUrl!: JQuery<HTMLElement>;
|
||||
|
||||
isEnabled() {
|
||||
return this.note && !!this.note.getLabelValue("pageUrl");
|
||||
}
|
||||
|
||||
getTitle() {
|
||||
return {
|
||||
show: this.isEnabled(),
|
||||
activate: true,
|
||||
title: t("note_properties.info"),
|
||||
icon: "bx bx-info-square"
|
||||
};
|
||||
}
|
||||
|
||||
doRender() {
|
||||
this.$widget = $(TPL);
|
||||
this.contentSized();
|
||||
|
||||
this.$pageUrl = this.$widget.find(".page-url");
|
||||
}
|
||||
|
||||
async refreshWithNote(note: FNote) {
|
||||
const pageUrl = note.getLabelValue("pageUrl");
|
||||
|
||||
this.$pageUrl
|
||||
.attr("href", pageUrl)
|
||||
.attr("title", pageUrl)
|
||||
.text(pageUrl ?? "");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
import { t } from "../../services/i18n.js";
|
||||
import NoteContextAwareWidget from "../note_context_aware_widget.js";
|
||||
import AttributeDetailWidget from "../attribute_widgets/attribute_detail.js";
|
||||
import AttributeEditorWidget from "../attribute_widgets/attribute_editor.js";
|
||||
import type { CommandListenerData } from "../../components/app_context.js";
|
||||
import type FAttribute from "../../entities/fattribute.js";
|
||||
|
||||
const TPL = /*html*/`
|
||||
<div class="attribute-list">
|
||||
<style>
|
||||
.attribute-list {
|
||||
margin-left: 7px;
|
||||
margin-right: 7px;
|
||||
margin-top: 5px;
|
||||
margin-bottom: 2px;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.attribute-list-editor p {
|
||||
margin: 0 !important;
|
||||
}
|
||||
</style>
|
||||
|
||||
<div class="attr-editor-placeholder"></div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
export default class OwnedAttributeListWidget extends NoteContextAwareWidget {
|
||||
|
||||
private attributeDetailWidget: AttributeDetailWidget;
|
||||
private attributeEditorWidget: AttributeEditorWidget;
|
||||
private $title!: JQuery<HTMLElement>;
|
||||
|
||||
get name() {
|
||||
return "ownedAttributes";
|
||||
}
|
||||
|
||||
get toggleCommand() {
|
||||
return "toggleRibbonTabOwnedAttributes";
|
||||
}
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
|
||||
this.attributeDetailWidget = new AttributeDetailWidget().contentSized().setParent(this);
|
||||
|
||||
this.attributeEditorWidget = new AttributeEditorWidget(this.attributeDetailWidget).contentSized().setParent(this);
|
||||
|
||||
this.child(this.attributeEditorWidget, this.attributeDetailWidget);
|
||||
}
|
||||
|
||||
getTitle() {
|
||||
return {
|
||||
show: !this.note?.isLaunchBarConfig(),
|
||||
title: t("owned_attribute_list.owned_attributes"),
|
||||
icon: "bx bx-list-check"
|
||||
};
|
||||
}
|
||||
|
||||
doRender() {
|
||||
this.$widget = $(TPL);
|
||||
this.contentSized();
|
||||
|
||||
this.$widget.find(".attr-editor-placeholder").replaceWith(this.attributeEditorWidget.render());
|
||||
this.$widget.append(this.attributeDetailWidget.render());
|
||||
|
||||
this.$title = $("<div>");
|
||||
}
|
||||
|
||||
async saveAttributesCommand() {
|
||||
await this.attributeEditorWidget.save();
|
||||
}
|
||||
|
||||
async reloadAttributesCommand() {
|
||||
await this.attributeEditorWidget.refresh();
|
||||
}
|
||||
|
||||
async updateAttributeListCommand({ attributes }: CommandListenerData<"updateAttributeList">) {
|
||||
// TODO: See why we need FAttribute[] and Attribute[]
|
||||
await this.attributeEditorWidget.updateAttributeList(attributes as FAttribute[]);
|
||||
}
|
||||
|
||||
focus() {
|
||||
this.attributeEditorWidget.focus();
|
||||
}
|
||||
}
|
||||
391
apps/client/src/widgets/ribbon_widgets/promoted_attributes.ts
Normal file
391
apps/client/src/widgets/ribbon_widgets/promoted_attributes.ts
Normal file
@@ -0,0 +1,391 @@
|
||||
import { t } from "../../services/i18n.js";
|
||||
import server from "../../services/server.js";
|
||||
import ws from "../../services/ws.js";
|
||||
import treeService from "../../services/tree.js";
|
||||
import noteAutocompleteService from "../../services/note_autocomplete.js";
|
||||
import NoteContextAwareWidget from "../note_context_aware_widget.js";
|
||||
import attributeService from "../../services/attributes.js";
|
||||
import options from "../../services/options.js";
|
||||
import utils from "../../services/utils.js";
|
||||
import type FNote from "../../entities/fnote.js";
|
||||
import type { Attribute } from "../../services/attribute_parser.js";
|
||||
import type FAttribute from "../../entities/fattribute.js";
|
||||
import type { EventData } from "../../components/app_context.js";
|
||||
|
||||
const TPL = /*html*/`
|
||||
<div class="promoted-attributes-widget">
|
||||
<style>
|
||||
body.mobile .promoted-attributes-widget {
|
||||
/* https://github.com/zadam/trilium/issues/4468 */
|
||||
flex-shrink: 0.4;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.promoted-attributes-container {
|
||||
margin: 0 1.5em;
|
||||
overflow: auto;
|
||||
max-height: 400px;
|
||||
flex-wrap: wrap;
|
||||
display: table;
|
||||
}
|
||||
.promoted-attribute-cell {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin: 10px;
|
||||
display: table-row;
|
||||
}
|
||||
.promoted-attribute-cell > label {
|
||||
user-select: none;
|
||||
font-weight: bold;
|
||||
vertical-align: middle;
|
||||
}
|
||||
.promoted-attribute-cell > * {
|
||||
display: table-cell;
|
||||
padding: 1px 0;
|
||||
}
|
||||
|
||||
.promoted-attribute-cell div.input-group {
|
||||
margin-left: 10px;
|
||||
display: flex;
|
||||
min-height: 40px;
|
||||
}
|
||||
.promoted-attribute-cell strong {
|
||||
word-break:keep-all;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.promoted-attribute-cell input[type="checkbox"] {
|
||||
width: 22px !important;
|
||||
flex-grow: 0;
|
||||
width: unset;
|
||||
}
|
||||
|
||||
</style>
|
||||
|
||||
<div class="promoted-attributes-container"></div>
|
||||
</div>`;
|
||||
|
||||
// TODO: Deduplicate
|
||||
interface AttributeResult {
|
||||
attributeId: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* This widget is quite special because it's used in the desktop ribbon, but in mobile outside of ribbon.
|
||||
* This works without many issues (apart from autocomplete), but it should be kept in mind when changing things
|
||||
* and testing.
|
||||
*/
|
||||
export default class PromotedAttributesWidget extends NoteContextAwareWidget {
|
||||
|
||||
private $container!: JQuery<HTMLElement>;
|
||||
|
||||
get name() {
|
||||
return "promotedAttributes";
|
||||
}
|
||||
|
||||
get toggleCommand() {
|
||||
return "toggleRibbonTabPromotedAttributes";
|
||||
}
|
||||
|
||||
doRender() {
|
||||
this.$widget = $(TPL);
|
||||
this.contentSized();
|
||||
this.$container = this.$widget.find(".promoted-attributes-container");
|
||||
}
|
||||
|
||||
getTitle(note: FNote) {
|
||||
const promotedDefAttrs = note.getPromotedDefinitionAttributes();
|
||||
|
||||
if (promotedDefAttrs.length === 0) {
|
||||
return { show: false };
|
||||
}
|
||||
|
||||
return {
|
||||
show: true,
|
||||
activate: options.is("promotedAttributesOpenInRibbon"),
|
||||
title: t("promoted_attributes.promoted_attributes"),
|
||||
icon: "bx bx-table"
|
||||
};
|
||||
}
|
||||
|
||||
async refreshWithNote(note: FNote) {
|
||||
this.$container.empty();
|
||||
|
||||
const promotedDefAttrs = note.getPromotedDefinitionAttributes();
|
||||
const ownedAttributes = note.getOwnedAttributes();
|
||||
// attrs are not resorted if position changes after the initial load
|
||||
// promoted attrs are sorted primarily by order of definitions, but with multi-valued promoted attrs
|
||||
// the order of attributes is important as well
|
||||
ownedAttributes.sort((a, b) => a.position - b.position);
|
||||
|
||||
if (promotedDefAttrs.length === 0) {
|
||||
this.toggleInt(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const $cells = [];
|
||||
|
||||
for (const definitionAttr of promotedDefAttrs) {
|
||||
const valueType = definitionAttr.name.startsWith("label:") ? "label" : "relation";
|
||||
const valueName = definitionAttr.name.substr(valueType.length + 1);
|
||||
|
||||
let valueAttrs = ownedAttributes.filter((el) => el.name === valueName && el.type === valueType) as Attribute[];
|
||||
|
||||
if (valueAttrs.length === 0) {
|
||||
valueAttrs.push({
|
||||
attributeId: "",
|
||||
type: valueType,
|
||||
name: valueName,
|
||||
value: ""
|
||||
});
|
||||
}
|
||||
|
||||
if (definitionAttr.getDefinition().multiplicity === "single") {
|
||||
valueAttrs = valueAttrs.slice(0, 1);
|
||||
}
|
||||
|
||||
for (const valueAttr of valueAttrs) {
|
||||
const $cell = await this.createPromotedAttributeCell(definitionAttr, valueAttr, valueName);
|
||||
|
||||
if ($cell) {
|
||||
$cells.push($cell);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// we replace the whole content in one step, so there can't be any race conditions
|
||||
// (previously we saw promoted attributes doubling)
|
||||
this.$container.empty().append(...$cells);
|
||||
this.toggleInt(true);
|
||||
}
|
||||
|
||||
async createPromotedAttributeCell(definitionAttr: FAttribute, valueAttr: Attribute, valueName: string) {
|
||||
const definition = definitionAttr.getDefinition();
|
||||
const id = `value-${valueAttr.attributeId}`;
|
||||
|
||||
const $input = $("<input>")
|
||||
.prop("tabindex", 200 + definitionAttr.position)
|
||||
.prop("id", id)
|
||||
.attr("data-attribute-id", valueAttr.noteId === this.noteId ? valueAttr.attributeId ?? "" : "") // if not owned, we'll force creation of a new attribute instead of updating the inherited one
|
||||
.attr("data-attribute-type", valueAttr.type)
|
||||
.attr("data-attribute-name", valueAttr.name)
|
||||
.prop("value", valueAttr.value)
|
||||
.prop("placeholder", t("promoted_attributes.unset-field-placeholder"))
|
||||
.addClass("form-control")
|
||||
.addClass("promoted-attribute-input")
|
||||
.on("change", (event) => this.promotedAttributeChanged(event));
|
||||
|
||||
const $actionCell = $("<div>");
|
||||
const $multiplicityCell = $("<td>").addClass("multiplicity").attr("nowrap", "true");
|
||||
|
||||
const $wrapper = $('<div class="promoted-attribute-cell">')
|
||||
.append(
|
||||
$("<label>")
|
||||
.prop("for", id)
|
||||
.text(definition.promotedAlias ?? valueName)
|
||||
)
|
||||
.append($("<div>").addClass("input-group").append($input))
|
||||
.append($actionCell)
|
||||
.append($multiplicityCell);
|
||||
|
||||
if (valueAttr.type === "label") {
|
||||
if (definition.labelType === "text") {
|
||||
$input.prop("type", "text");
|
||||
|
||||
// autocomplete for label values is just nice to have, mobile can keep labels editable without autocomplete
|
||||
if (utils.isDesktop()) {
|
||||
// no need to await for this, can be done asynchronously
|
||||
server.get<string[]>(`attribute-values/${encodeURIComponent(valueAttr.name)}`).then((_attributeValues) => {
|
||||
if (_attributeValues.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const attributeValues = _attributeValues.map((attribute) => ({ value: attribute }));
|
||||
|
||||
$input.autocomplete(
|
||||
{
|
||||
appendTo: document.querySelector("body"),
|
||||
hint: false,
|
||||
autoselect: false,
|
||||
openOnFocus: true,
|
||||
minLength: 0,
|
||||
tabAutocomplete: false
|
||||
},
|
||||
[
|
||||
{
|
||||
displayKey: "value",
|
||||
source: function (term, cb) {
|
||||
term = term.toLowerCase();
|
||||
|
||||
const filtered = attributeValues.filter((attr) => attr.value.toLowerCase().includes(term));
|
||||
|
||||
cb(filtered);
|
||||
}
|
||||
}
|
||||
]
|
||||
);
|
||||
|
||||
$input.on("autocomplete:selected", (e) => this.promotedAttributeChanged(e));
|
||||
});
|
||||
}
|
||||
} else if (definition.labelType === "number") {
|
||||
$input.prop("type", "number");
|
||||
|
||||
let step = 1;
|
||||
|
||||
for (let i = 0; i < (definition.numberPrecision || 0) && i < 10; i++) {
|
||||
step /= 10;
|
||||
}
|
||||
|
||||
$input.prop("step", step);
|
||||
$input.css("text-align", "right").css("width", "120");
|
||||
} else if (definition.labelType === "boolean") {
|
||||
$input.prop("type", "checkbox");
|
||||
|
||||
$input.wrap($(`<label class="tn-checkbox"></label>`));
|
||||
$wrapper.find(".input-group").removeClass("input-group");
|
||||
|
||||
if (valueAttr.value === "true") {
|
||||
$input.prop("checked", "checked");
|
||||
}
|
||||
} else if (definition.labelType === "date") {
|
||||
$input.prop("type", "date");
|
||||
} else if (definition.labelType === "datetime") {
|
||||
$input.prop("type", "datetime-local");
|
||||
} else if (definition.labelType === "time") {
|
||||
$input.prop("type", "time");
|
||||
} else if (definition.labelType === "url") {
|
||||
$input.prop("placeholder", t("promoted_attributes.url_placeholder"));
|
||||
|
||||
const $openButton = $("<span>")
|
||||
.addClass("input-group-text open-external-link-button bx bx-window-open")
|
||||
.prop("title", t("promoted_attributes.open_external_link"))
|
||||
.on("click", () => window.open($input.val() as string, "_blank"));
|
||||
|
||||
$input.after($openButton);
|
||||
} else {
|
||||
ws.logError(t("promoted_attributes.unknown_label_type", { type: definition.labelType }));
|
||||
}
|
||||
} else if (valueAttr.type === "relation") {
|
||||
if (valueAttr.value) {
|
||||
$input.val(await treeService.getNoteTitle(valueAttr.value));
|
||||
}
|
||||
|
||||
if (utils.isDesktop()) {
|
||||
// no need to wait for this
|
||||
noteAutocompleteService.initNoteAutocomplete($input, { allowCreatingNotes: true });
|
||||
|
||||
$input.on("autocomplete:noteselected", (event, suggestion, dataset) => {
|
||||
this.promotedAttributeChanged(event);
|
||||
});
|
||||
|
||||
$input.setSelectedNotePath(valueAttr.value);
|
||||
} else {
|
||||
// we can't provide user a way to edit the relation so make it read only
|
||||
$input.attr("readonly", "readonly");
|
||||
}
|
||||
} else {
|
||||
ws.logError(t(`promoted_attributes.unknown_attribute_type`, { type: valueAttr.type }));
|
||||
return;
|
||||
}
|
||||
|
||||
if (definition.multiplicity === "multi") {
|
||||
const $addButton = $("<span>")
|
||||
.addClass("bx bx-plus pointer tn-tool-button")
|
||||
.prop("title", t("promoted_attributes.add_new_attribute"))
|
||||
.on("click", async () => {
|
||||
const $new = await this.createPromotedAttributeCell(
|
||||
definitionAttr,
|
||||
{
|
||||
attributeId: "",
|
||||
type: valueAttr.type,
|
||||
name: valueName,
|
||||
value: ""
|
||||
},
|
||||
valueName
|
||||
);
|
||||
|
||||
if ($new) {
|
||||
$wrapper.after($new);
|
||||
|
||||
$new.find("input").trigger("focus");
|
||||
}
|
||||
});
|
||||
|
||||
const $removeButton = $("<span>")
|
||||
.addClass("bx bx-trash pointer tn-tool-button")
|
||||
.prop("title", t("promoted_attributes.remove_this_attribute"))
|
||||
.on("click", async () => {
|
||||
const attributeId = $input.attr("data-attribute-id");
|
||||
|
||||
if (attributeId) {
|
||||
await server.remove(`notes/${this.noteId}/attributes/${attributeId}`, this.componentId);
|
||||
}
|
||||
|
||||
// if it's the last one the create new empty form immediately
|
||||
const sameAttrSelector = `input[data-attribute-type='${valueAttr.type}'][data-attribute-name='${valueName}']`;
|
||||
|
||||
if (this.$widget.find(sameAttrSelector).length <= 1) {
|
||||
const $new = await this.createPromotedAttributeCell(
|
||||
definitionAttr,
|
||||
{
|
||||
attributeId: "",
|
||||
type: valueAttr.type,
|
||||
name: valueName,
|
||||
value: ""
|
||||
},
|
||||
valueName
|
||||
);
|
||||
|
||||
if ($new) {
|
||||
$wrapper.after($new);
|
||||
}
|
||||
}
|
||||
|
||||
$wrapper.remove();
|
||||
});
|
||||
|
||||
$multiplicityCell.append(" ").append($addButton).append(" ").append($removeButton);
|
||||
}
|
||||
|
||||
return $wrapper;
|
||||
}
|
||||
|
||||
async promotedAttributeChanged(event: JQuery.TriggeredEvent<HTMLElement, undefined, HTMLElement, HTMLElement>) {
|
||||
const $attr = $(event.target);
|
||||
|
||||
let value;
|
||||
|
||||
if ($attr.prop("type") === "checkbox") {
|
||||
value = $attr.is(":checked") ? "true" : "false";
|
||||
} else if ($attr.attr("data-attribute-type") === "relation") {
|
||||
const selectedPath = $attr.getSelectedNotePath();
|
||||
|
||||
value = selectedPath ? treeService.getNoteIdFromUrl(selectedPath) : "";
|
||||
} else {
|
||||
value = $attr.val();
|
||||
}
|
||||
|
||||
const result = await server.put<AttributeResult>(
|
||||
`notes/${this.noteId}/attribute`,
|
||||
{
|
||||
attributeId: $attr.attr("data-attribute-id"),
|
||||
type: $attr.attr("data-attribute-type"),
|
||||
name: $attr.attr("data-attribute-name"),
|
||||
value: value
|
||||
},
|
||||
this.componentId
|
||||
);
|
||||
|
||||
$attr.attr("data-attribute-id", result.attributeId);
|
||||
}
|
||||
|
||||
focus() {
|
||||
this.$widget.find(".promoted-attribute-input:first").focus();
|
||||
}
|
||||
|
||||
entitiesReloadedEvent({ loadResults }: EventData<"entitiesReloaded">) {
|
||||
if (loadResults.getAttributeRows(this.componentId).find((attr) => attributeService.isAffecting(attr, this.note))) {
|
||||
this.refresh();
|
||||
}
|
||||
}
|
||||
}
|
||||
76
apps/client/src/widgets/ribbon_widgets/script_executor.ts
Normal file
76
apps/client/src/widgets/ribbon_widgets/script_executor.ts
Normal file
@@ -0,0 +1,76 @@
|
||||
import NoteContextAwareWidget from "../note_context_aware_widget.js";
|
||||
import keyboardActionService from "../../services/keyboard_actions.js";
|
||||
import { t } from "../../services/i18n.js";
|
||||
import type FNote from "../../entities/fnote.js";
|
||||
|
||||
const TPL = /*html*/`
|
||||
<div class="script-runner-widget">
|
||||
<style>
|
||||
.script-runner-widget {
|
||||
padding: 12px;
|
||||
color: var(--muted-text-color);
|
||||
}
|
||||
|
||||
.execute-description {
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
</style>
|
||||
|
||||
<div class="execute-description"></div>
|
||||
|
||||
<div style="display: flex; justify-content: space-around">
|
||||
<button data-trigger-command="runActiveNote" class="execute-button btn btn-sm"></button>
|
||||
</div>
|
||||
</div>`;
|
||||
|
||||
export default class ScriptExecutorWidget extends NoteContextAwareWidget {
|
||||
|
||||
private $executeButton!: JQuery<HTMLElement>;
|
||||
private $executeDescription!: JQuery<HTMLElement>;
|
||||
|
||||
isEnabled() {
|
||||
return (
|
||||
super.isEnabled() &&
|
||||
this.note &&
|
||||
(this.note.mime.startsWith("application/javascript") || this.isTriliumSqlite()) &&
|
||||
(this.note.hasLabel("executeDescription") || this.note.hasLabel("executeButton"))
|
||||
);
|
||||
}
|
||||
|
||||
isTriliumSqlite() {
|
||||
return this.note?.mime === "text/x-sqlite;schema=trilium";
|
||||
}
|
||||
|
||||
getTitle() {
|
||||
return {
|
||||
show: this.isEnabled(),
|
||||
activate: true,
|
||||
title: this.isTriliumSqlite() ? t("script_executor.query") : t("script_executor.script"),
|
||||
icon: "bx bx-play"
|
||||
};
|
||||
}
|
||||
|
||||
doRender() {
|
||||
this.$widget = $(TPL);
|
||||
this.contentSized();
|
||||
|
||||
this.$executeButton = this.$widget.find(".execute-button");
|
||||
this.$executeDescription = this.$widget.find(".execute-description");
|
||||
}
|
||||
|
||||
async refreshWithNote(note: FNote) {
|
||||
const executeTitle = note.getLabelValue("executeButton") || (this.isTriliumSqlite() ? t("script_executor.execute_query") : t("script_executor.execute_script"));
|
||||
|
||||
this.$executeButton.text(executeTitle);
|
||||
this.$executeButton.attr("title", executeTitle);
|
||||
keyboardActionService.updateDisplayedShortcuts(this.$widget);
|
||||
|
||||
const executeDescription = note.getLabelValue("executeDescription");
|
||||
|
||||
if (executeDescription) {
|
||||
this.$executeDescription.show().html(executeDescription);
|
||||
} else {
|
||||
this.$executeDescription.empty().hide();
|
||||
}
|
||||
}
|
||||
}
|
||||
334
apps/client/src/widgets/ribbon_widgets/search_definition.ts
Normal file
334
apps/client/src/widgets/ribbon_widgets/search_definition.ts
Normal file
@@ -0,0 +1,334 @@
|
||||
import { t } from "../../services/i18n.js";
|
||||
import server from "../../services/server.js";
|
||||
import NoteContextAwareWidget from "../note_context_aware_widget.js";
|
||||
import froca from "../../services/froca.js";
|
||||
import ws from "../../services/ws.js";
|
||||
import toastService from "../../services/toast.js";
|
||||
import treeService from "../../services/tree.js";
|
||||
|
||||
import SearchString from "../search_options/search_string.js";
|
||||
import FastSearch from "../search_options/fast_search.js";
|
||||
import Ancestor from "../search_options/ancestor.js";
|
||||
import IncludeArchivedNotes from "../search_options/include_archived_notes.js";
|
||||
import OrderBy from "../search_options/order_by.js";
|
||||
import SearchScript from "../search_options/search_script.js";
|
||||
import Limit from "../search_options/limit.js";
|
||||
import Debug from "../search_options/debug.js";
|
||||
import appContext, { type EventData } from "../../components/app_context.js";
|
||||
import bulkActionService from "../../services/bulk_action.js";
|
||||
import { Dropdown } from "bootstrap";
|
||||
import type FNote from "../../entities/fnote.js";
|
||||
import type { AttributeType } from "../../entities/fattribute.js";
|
||||
|
||||
const TPL = /*html*/`
|
||||
<div class="search-definition-widget">
|
||||
<style>
|
||||
.search-setting-table {
|
||||
margin-top: 0;
|
||||
margin-bottom: 7px;
|
||||
width: 100%;
|
||||
border-collapse: separate;
|
||||
border-spacing: 10px;
|
||||
}
|
||||
|
||||
.search-setting-table div {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.search-setting-table .button-column {
|
||||
/* minimal width so that table remains static sized and most space remains for middle column with settings */
|
||||
width: 50px;
|
||||
white-space: nowrap;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.search-setting-table .title-column {
|
||||
/* minimal width so that table remains static sized and most space remains for middle column with settings */
|
||||
width: 50px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.search-setting-table .button-column .dropdown-menu {
|
||||
white-space: normal;
|
||||
}
|
||||
|
||||
.attribute-list hr {
|
||||
height: 1px;
|
||||
border-color: var(--main-border-color);
|
||||
position: relative;
|
||||
top: 4px;
|
||||
margin-top: 5px;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.search-definition-widget input:invalid {
|
||||
border: 3px solid red;
|
||||
}
|
||||
|
||||
.add-search-option button {
|
||||
margin-top: 5px; /* to give some spacing when buttons overflow on the next line */
|
||||
}
|
||||
|
||||
.dropdown-header {
|
||||
background-color: var(--accented-background-color);
|
||||
}
|
||||
</style>
|
||||
|
||||
<div class="search-settings">
|
||||
<table class="search-setting-table">
|
||||
<tr>
|
||||
<td class="title-column">${t("search_definition.add_search_option")}</td>
|
||||
<td colspan="2" class="add-search-option">
|
||||
<button type="button" class="btn btn-sm" data-search-option-add="searchString">
|
||||
<span class="bx bx-text"></span>
|
||||
${t("search_definition.search_string")}
|
||||
</button>
|
||||
|
||||
<button type="button" class="btn btn-sm" data-search-option-add="searchScript">
|
||||
<span class="bx bx-code"></span>
|
||||
${t("search_definition.search_script")}
|
||||
</button>
|
||||
|
||||
<button type="button" class="btn btn-sm" data-search-option-add="ancestor">
|
||||
<span class="bx bx-filter-alt"></span>
|
||||
${t("search_definition.ancestor")}
|
||||
</button>
|
||||
|
||||
<button type="button" class="btn btn-sm" data-search-option-add="fastSearch"
|
||||
title="${t("search_definition.fast_search_description")}">
|
||||
<span class="bx bx-run"></span>
|
||||
${t("search_definition.fast_search")}
|
||||
</button>
|
||||
|
||||
<button type="button" class="btn btn-sm" data-search-option-add="includeArchivedNotes"
|
||||
title="${t("search_definition.include_archived_notes_description")}">
|
||||
<span class="bx bx-archive"></span>
|
||||
${t("search_definition.include_archived")}
|
||||
</button>
|
||||
|
||||
<button type="button" class="btn btn-sm" data-search-option-add="orderBy">
|
||||
<span class="bx bx-arrow-from-top"></span>
|
||||
${t("search_definition.order_by")}
|
||||
</button>
|
||||
|
||||
<button type="button" class="btn btn-sm" data-search-option-add="limit" title="${t("search_definition.limit_description")}">
|
||||
<span class="bx bx-stop"></span>
|
||||
${t("search_definition.limit")}
|
||||
</button>
|
||||
|
||||
<button type="button" class="btn btn-sm" data-search-option-add="debug" title="${t("search_definition.debug_description")}">
|
||||
<span class="bx bx-bug"></span>
|
||||
${t("search_definition.debug")}
|
||||
</button>
|
||||
|
||||
<div class="dropdown" style="display: inline-block;">
|
||||
<button class="btn btn-sm dropdown-toggle action-add-toggle" type="button" id="dropdownMenuButton" data-bs-toggle="dropdown" aria-haspopup="true" aria-expanded="false">
|
||||
<span class="bx bxs-zap"></span>
|
||||
${t("search_definition.action")}
|
||||
</button>
|
||||
<div class="dropdown-menu action-list"></div>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
<tbody class="search-options"></tbody>
|
||||
<tbody class="action-options"></tbody>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td colspan="3">
|
||||
<div style="display: flex; justify-content: space-evenly">
|
||||
<button type="button" class="btn btn-sm search-button">
|
||||
<span class="bx bx-search"></span>
|
||||
${t("search_definition.search_button")}
|
||||
</button>
|
||||
|
||||
<button type="button" class="btn btn-sm search-and-execute-button">
|
||||
<span class="bx bxs-zap"></span>
|
||||
${t("search_definition.search_execute")}
|
||||
</button>
|
||||
|
||||
<button type="button" class="btn btn-sm save-to-note-button">
|
||||
<span class="bx bx-save"></span>
|
||||
${t("search_definition.save_to_note")}
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>`;
|
||||
|
||||
const OPTION_CLASSES = [SearchString, SearchScript, Ancestor, FastSearch, IncludeArchivedNotes, OrderBy, Limit, Debug];
|
||||
|
||||
// TODO: Deduplicate with server
|
||||
interface SaveSearchNoteResponse {
|
||||
notePath: string;
|
||||
}
|
||||
|
||||
export default class SearchDefinitionWidget extends NoteContextAwareWidget {
|
||||
|
||||
private $component!: JQuery<HTMLElement>;
|
||||
private $actionList!: JQuery<HTMLElement>;
|
||||
private $searchOptions!: JQuery<HTMLElement>;
|
||||
private $searchButton!: JQuery<HTMLElement>;
|
||||
private $searchAndExecuteButton!: JQuery<HTMLElement>;
|
||||
private $saveToNoteButton!: JQuery<HTMLElement>;
|
||||
private $actionOptions!: JQuery<HTMLElement>;
|
||||
|
||||
get name() {
|
||||
return "searchDefinition";
|
||||
}
|
||||
|
||||
isEnabled() {
|
||||
return this.note && this.note.type === "search";
|
||||
}
|
||||
|
||||
getTitle() {
|
||||
return {
|
||||
show: this.isEnabled(),
|
||||
activate: true,
|
||||
title: t("search_definition.search_parameters"),
|
||||
icon: "bx bx-search"
|
||||
};
|
||||
}
|
||||
|
||||
doRender() {
|
||||
this.$widget = $(TPL);
|
||||
this.contentSized();
|
||||
this.$component = this.$widget.find(".search-definition-widget");
|
||||
this.$actionList = this.$widget.find(".action-list");
|
||||
|
||||
for (const actionGroup of bulkActionService.ACTION_GROUPS) {
|
||||
this.$actionList.append($('<h6 class="dropdown-header">').append(actionGroup.title));
|
||||
|
||||
for (const action of actionGroup.actions) {
|
||||
this.$actionList.append($('<a class="dropdown-item" href="#">').attr("data-action-add", action.actionName).text(action.actionTitle));
|
||||
}
|
||||
}
|
||||
|
||||
this.$widget.on("click", "[data-search-option-add]", async (event) => {
|
||||
const searchOptionName = $(event.target).attr("data-search-option-add");
|
||||
const clazz = OPTION_CLASSES.find((SearchOptionClass) => SearchOptionClass.optionName === searchOptionName);
|
||||
|
||||
if (clazz && this.noteId) {
|
||||
await clazz.create(this.noteId);
|
||||
} else {
|
||||
logError(t("search_definition.unknown_search_option", { searchOptionName }));
|
||||
}
|
||||
|
||||
this.refresh();
|
||||
});
|
||||
|
||||
this.$widget.on("click", "[data-action-add]", async (event) => {
|
||||
Dropdown.getOrCreateInstance(this.$widget.find(".action-add-toggle")[0]);
|
||||
|
||||
const actionName = $(event.target).attr("data-action-add");
|
||||
|
||||
if (this.noteId && actionName) {
|
||||
await bulkActionService.addAction(this.noteId, actionName);
|
||||
}
|
||||
|
||||
this.refresh();
|
||||
});
|
||||
|
||||
this.$searchOptions = this.$widget.find(".search-options");
|
||||
this.$actionOptions = this.$widget.find(".action-options");
|
||||
|
||||
this.$searchButton = this.$widget.find(".search-button");
|
||||
this.$searchButton.on("click", () => this.triggerCommand("refreshResults"));
|
||||
|
||||
this.$searchAndExecuteButton = this.$widget.find(".search-and-execute-button");
|
||||
this.$searchAndExecuteButton.on("click", () => this.searchAndExecute());
|
||||
|
||||
this.$saveToNoteButton = this.$widget.find(".save-to-note-button");
|
||||
this.$saveToNoteButton.on("click", async () => {
|
||||
const { notePath } = await server.post<SaveSearchNoteResponse>("special-notes/save-search-note", { searchNoteId: this.noteId });
|
||||
|
||||
await ws.waitForMaxKnownEntityChangeId();
|
||||
|
||||
await appContext.tabManager.getActiveContext()?.setNote(notePath);
|
||||
// Note the {{- notePathTitle}} in json file is not typo, it's unescaping
|
||||
// See https://www.i18next.com/translation-function/interpolation#unescape
|
||||
toastService.showMessage(t("search_definition.search_note_saved", { notePathTitle: await treeService.getNotePathTitle(notePath) }));
|
||||
});
|
||||
}
|
||||
|
||||
async refreshResultsCommand() {
|
||||
if (!this.noteId) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await froca.loadSearchNote(this.noteId);
|
||||
|
||||
if (result && result.error) {
|
||||
this.handleEvent("showSearchError", { error: result.error });
|
||||
}
|
||||
} catch (e: any) {
|
||||
toastService.showError(e.message);
|
||||
}
|
||||
|
||||
this.triggerEvent("searchRefreshed", { ntxId: this.noteContext?.ntxId });
|
||||
}
|
||||
|
||||
async refreshSearchDefinitionCommand() {
|
||||
await this.refresh();
|
||||
}
|
||||
|
||||
async refreshWithNote(note: FNote) {
|
||||
if (!this.note) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.$component.show();
|
||||
|
||||
this.$saveToNoteButton.toggle(note.isHiddenCompletely());
|
||||
|
||||
this.$searchOptions.empty();
|
||||
|
||||
for (const OptionClass of OPTION_CLASSES) {
|
||||
const { attributeType, optionName } = OptionClass;
|
||||
|
||||
const attr = this.note.getAttribute(attributeType as AttributeType, optionName);
|
||||
|
||||
this.$widget.find(`[data-search-option-add='${optionName}'`).toggle(!attr);
|
||||
|
||||
if (attr) {
|
||||
const searchOption = new OptionClass(attr, this.note).setParent(this);
|
||||
this.child(searchOption);
|
||||
|
||||
const renderedEl = searchOption.render();
|
||||
if (renderedEl) {
|
||||
this.$searchOptions.append(renderedEl);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const actions = bulkActionService.parseActions(this.note);
|
||||
const renderedEls = actions
|
||||
.map((action) => action.render())
|
||||
.filter((e) => e) as JQuery<HTMLElement>[];
|
||||
|
||||
this.$actionOptions.empty().append(...renderedEls);
|
||||
this.$searchAndExecuteButton.css("visibility", actions.length > 0 ? "visible" : "_hidden");
|
||||
}
|
||||
|
||||
getContent() {
|
||||
return "";
|
||||
}
|
||||
|
||||
async searchAndExecute() {
|
||||
await server.post(`search-and-execute-note/${this.noteId}`);
|
||||
|
||||
this.triggerCommand("refreshResults");
|
||||
|
||||
toastService.showMessage(t("search_definition.actions_executed"), 3000);
|
||||
}
|
||||
|
||||
entitiesReloadedEvent({ loadResults }: EventData<"entitiesReloaded">) {
|
||||
// only refreshing deleted attrs, otherwise components update themselves
|
||||
if (loadResults.getAttributeRows().find((attrRow) => attrRow.type === "label" && attrRow.name === "action" && attrRow.isDeleted)) {
|
||||
this.refresh();
|
||||
}
|
||||
}
|
||||
}
|
||||
120
apps/client/src/widgets/ribbon_widgets/similar_notes.ts
Normal file
120
apps/client/src/widgets/ribbon_widgets/similar_notes.ts
Normal file
@@ -0,0 +1,120 @@
|
||||
import { t } from "../../services/i18n.js";
|
||||
import linkService from "../../services/link.js";
|
||||
import server from "../../services/server.js";
|
||||
import froca from "../../services/froca.js";
|
||||
import NoteContextAwareWidget from "../note_context_aware_widget.js";
|
||||
import type FNote from "../../entities/fnote.js";
|
||||
import type { EventData } from "../../components/app_context.js";
|
||||
|
||||
const TPL = /*html*/`
|
||||
<div class="similar-notes-widget">
|
||||
<style>
|
||||
.similar-notes-wrapper {
|
||||
max-height: 200px;
|
||||
overflow: auto;
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.similar-notes-wrapper a {
|
||||
display: inline-block;
|
||||
border: 1px dotted var(--main-border-color);
|
||||
border-radius: 20px;
|
||||
background-color: var(--accented-background-color);
|
||||
padding: 0 10px 0 10px;
|
||||
margin: 0 3px 0 3px;
|
||||
max-width: 10em;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
}
|
||||
</style>
|
||||
|
||||
<div class="similar-notes-wrapper"></div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
// TODO: Deduplicate with server
|
||||
interface SimilarNote {
|
||||
score: number;
|
||||
notePath: string[];
|
||||
noteId: string;
|
||||
}
|
||||
|
||||
export default class SimilarNotesWidget extends NoteContextAwareWidget {
|
||||
|
||||
private $similarNotesWrapper!: JQuery<HTMLElement>;
|
||||
private title?: string;
|
||||
private rendered?: boolean;
|
||||
|
||||
get name() {
|
||||
return "similarNotes";
|
||||
}
|
||||
|
||||
get toggleCommand() {
|
||||
return "toggleRibbonTabSimilarNotes";
|
||||
}
|
||||
|
||||
isEnabled() {
|
||||
return super.isEnabled() && this.note?.type !== "search" && !this.note?.isLabelTruthy("similarNotesWidgetDisabled");
|
||||
}
|
||||
|
||||
getTitle() {
|
||||
return {
|
||||
show: this.isEnabled(),
|
||||
title: t("similar_notes.title"),
|
||||
icon: "bx bx-bar-chart"
|
||||
};
|
||||
}
|
||||
|
||||
doRender() {
|
||||
this.$widget = $(TPL);
|
||||
this.contentSized();
|
||||
|
||||
this.$similarNotesWrapper = this.$widget.find(".similar-notes-wrapper");
|
||||
}
|
||||
|
||||
async refreshWithNote(note: FNote) {
|
||||
if (!this.note) {
|
||||
return;
|
||||
}
|
||||
|
||||
// remember which title was when we found the similar notes
|
||||
this.title = this.note.title;
|
||||
|
||||
const similarNotes = await server.get<SimilarNote[]>(`similar-notes/${this.noteId}`);
|
||||
|
||||
if (similarNotes.length === 0) {
|
||||
this.$similarNotesWrapper.empty().append(t("similar_notes.no_similar_notes_found"));
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
const noteIds = similarNotes.flatMap((note) => note.notePath);
|
||||
|
||||
await froca.getNotes(noteIds, true); // preload all at once
|
||||
|
||||
const $list = $("<div>");
|
||||
|
||||
for (const similarNote of similarNotes) {
|
||||
const note = await froca.getNote(similarNote.noteId, true);
|
||||
|
||||
if (!note) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const $item = (await linkService.createLink(similarNote.notePath.join("/"))).css("font-size", 24 * (1 - 1 / (1 + similarNote.score)));
|
||||
|
||||
$list.append($item);
|
||||
}
|
||||
|
||||
this.$similarNotesWrapper.empty().append($list);
|
||||
}
|
||||
|
||||
entitiesReloadedEvent({ loadResults }: EventData<"entitiesReloaded">) {
|
||||
if (this.note && this.title !== this.note.title) {
|
||||
this.rendered = false;
|
||||
|
||||
this.refresh();
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user