chore(monorepo): relocate client files

This commit is contained in:
Elian Doran
2025-04-18 01:37:55 +03:00
parent 4aad0552b3
commit de2cdd5e78
364 changed files with 0 additions and 0 deletions

View File

@@ -0,0 +1,80 @@
import type { EventData } from "../components/app_context.js";
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="api-log-widget">
<style>
.api-log-widget {
padding: 15px;
flex-grow: 1;
max-height: 40%;
position: relative;
}
.hidden-api-log {
display: none;
}
.api-log-container {
overflow: auto;
height: 100%;
}
.close-api-log-button {
padding: 5px;
border: 1px solid var(--button-border-color);
background-color: var(--button-background-color);
border-radius: var(--button-border-radius);
color: var(--button-text-color);
position: absolute;
top: 10px;
right: 40px;
cursor: pointer;
}
</style>
<div class="bx bx-x close-api-log-button" title="${t("api_log.close")}"></div>
<div class="api-log-container"></div>
</div>`;
export default class ApiLogWidget extends NoteContextAwareWidget {
private $logContainer!: JQuery<HTMLElement>;
private $closeButton!: JQuery<HTMLElement>;
isEnabled() {
return !!this.note && this.note.mime.startsWith("application/javascript;env=") && super.isEnabled();
}
doRender() {
this.$widget = $(TPL);
this.toggle(false);
this.$logContainer = this.$widget.find(".api-log-container");
this.$closeButton = this.$widget.find(".close-api-log-button");
this.$closeButton.on("click", () => this.toggle(false));
}
async refreshWithNote(note: FNote) {
this.$logContainer.empty();
}
apiLogMessagesEvent({ messages, noteId }: EventData<"apiLogMessages">) {
if (!this.isNote(noteId)) {
return;
}
this.toggle(true);
for (const message of messages) {
this.$logContainer.append(message).append($("<br>"));
}
}
toggle(show: boolean) {
this.$widget.toggleClass("hidden-api-log", !show);
}
}

View File

@@ -0,0 +1,207 @@
import { t } from "../services/i18n.js";
import utils from "../services/utils.js";
import AttachmentActionsWidget from "./buttons/attachments_actions.js";
import BasicWidget from "./basic_widget.js";
import options from "../services/options.js";
import imageService from "../services/image.js";
import linkService from "../services/link.js";
import contentRenderer from "../services/content_renderer.js";
import toastService from "../services/toast.js";
import type FAttachment from "../entities/fattachment.js";
import type { EventData } from "../components/app_context.js";
const TPL = /*html*/`
<div class="attachment-detail-widget">
<style>
.attachment-detail-widget {
height: 100%;
}
.attachment-detail-wrapper {
margin-bottom: 20px;
display: flex;
flex-direction: column;
}
.attachment-title-line {
display: flex;
align-items: baseline;
gap: 1em;
}
.attachment-details {
margin-left: 10px;
}
.attachment-content-wrapper {
flex-grow: 1;
}
.attachment-content-wrapper .rendered-content {
height: 100%;
}
.attachment-content-wrapper pre {
padding: 10px;
margin-top: 10px;
margin-bottom: 10px;
}
.attachment-detail-wrapper.list-view .attachment-content-wrapper {
max-height: 300px;
}
.attachment-detail-wrapper.full-detail {
height: 100%;
}
.attachment-detail-wrapper.full-detail .attachment-content-wrapper {
height: 100%;
}
.attachment-detail-wrapper.list-view .attachment-content-wrapper pre {
max-height: 400px;
}
.attachment-content-wrapper img {
margin: 10px;
}
.attachment-detail-wrapper.list-view .attachment-content-wrapper img, .attachment-detail-wrapper.list-view .attachment-content-wrapper video {
max-height: 300px;
max-width: 90%;
object-fit: contain;
}
.attachment-detail-wrapper.full-detail .attachment-content-wrapper img {
max-width: 90%;
object-fit: contain;
}
.attachment-detail-wrapper.scheduled-for-deletion .attachment-content-wrapper img {
filter: contrast(10%);
}
</style>
<div class="attachment-detail-wrapper">
<div class="attachment-title-line">
<div class="attachment-actions-container"></div>
<h4 class="attachment-title"></h4>
<div class="attachment-details"></div>
<div style="flex: 1 1;"></div>
</div>
<div class="attachment-deletion-warning alert alert-info" style="margin-top: 15px;"></div>
<div class="attachment-content-wrapper"></div>
</div>
</div>`;
export default class AttachmentDetailWidget extends BasicWidget {
attachment: FAttachment;
attachmentActionsWidget: AttachmentActionsWidget;
isFullDetail: boolean;
$wrapper!: JQuery<HTMLElement>;
constructor(attachment: FAttachment, isFullDetail: boolean) {
super();
this.contentSized();
this.attachment = attachment;
this.attachmentActionsWidget = new AttachmentActionsWidget(attachment, isFullDetail);
this.isFullDetail = isFullDetail;
this.child(this.attachmentActionsWidget);
}
doRender() {
this.$widget = $(TPL);
this.refresh();
super.doRender();
}
async refresh() {
this.$widget.find(".attachment-detail-wrapper").empty().append($(TPL).find(".attachment-detail-wrapper").html());
this.$wrapper = this.$widget.find(".attachment-detail-wrapper");
this.$wrapper.addClass(this.isFullDetail ? "full-detail" : "list-view");
if (!this.isFullDetail) {
const $link = await linkService.createLink(this.attachment.ownerId, {
title: this.attachment.title,
viewScope: {
viewMode: "attachments",
attachmentId: this.attachment.attachmentId
}
});
$link.addClass("use-tn-links");
this.$wrapper.find(".attachment-title").append($link);
} else {
this.$wrapper.find(".attachment-title").text(this.attachment.title);
}
const $deletionWarning = this.$wrapper.find(".attachment-deletion-warning");
const { utcDateScheduledForErasureSince } = this.attachment;
if (utcDateScheduledForErasureSince) {
this.$wrapper.addClass("scheduled-for-deletion");
const scheduledSinceTimestamp = utils.parseDate(utcDateScheduledForErasureSince)?.getTime();
// use default value (30 days in seconds) from options_init as fallback, in case getInt returns null
const intervalMs = options.getInt("eraseUnusedAttachmentsAfterSeconds") || 2592000 * 1000;
const deletionTimestamp = scheduledSinceTimestamp + intervalMs;
const willBeDeletedInMs = deletionTimestamp - Date.now();
$deletionWarning.show();
if (willBeDeletedInMs >= 60000) {
$deletionWarning.text(t("attachment_detail_2.will_be_deleted_in", { time: utils.formatTimeInterval(willBeDeletedInMs) }));
} else {
$deletionWarning.text(t("attachment_detail_2.will_be_deleted_soon"));
}
$deletionWarning.append(t("attachment_detail_2.deletion_reason"));
} else {
this.$wrapper.removeClass("scheduled-for-deletion");
$deletionWarning.hide();
}
this.$wrapper.find(".attachment-details").text(t("attachment_detail_2.role_and_size", { role: this.attachment.role, size: utils.formatSize(this.attachment.contentLength) }));
this.$wrapper.find(".attachment-actions-container").append(this.attachmentActionsWidget.render());
const { $renderedContent } = await contentRenderer.getRenderedContent(this.attachment, { imageHasZoom: this.isFullDetail });
this.$wrapper.find(".attachment-content-wrapper").append($renderedContent);
}
async copyAttachmentLinkToClipboard() {
if (this.attachment.role === "image") {
imageService.copyImageReferenceToClipboard(this.$wrapper.find(".attachment-content-wrapper"));
} else if (this.attachment.role === "file") {
const $link = await linkService.createLink(this.attachment.ownerId, {
referenceLink: true,
viewScope: {
viewMode: "attachments",
attachmentId: this.attachment.attachmentId
}
});
utils.copyHtmlToClipboard($link[0].outerHTML);
toastService.showMessage(t("attachment_detail_2.link_copied"));
} else {
throw new Error(t("attachment_detail_2.unrecognized_role", { role: this.attachment.role }));
}
}
async entitiesReloadedEvent({ loadResults }: EventData<"entitiesReloaded">) {
const attachmentRow = loadResults.getAttachmentRows().find((att) => att.attachmentId === this.attachment.attachmentId);
if (attachmentRow) {
if (attachmentRow.isDeleted) {
this.toggleInt(false);
} else {
this.refresh();
}
}
}
}

View File

@@ -0,0 +1,766 @@
import { t } from "../../services/i18n.js";
import server from "../../services/server.js";
import froca from "../../services/froca.js";
import linkService from "../../services/link.js";
import attributeAutocompleteService from "../../services/attribute_autocomplete.js";
import noteAutocompleteService from "../../services/note_autocomplete.js";
import promotedAttributeDefinitionParser from "../../services/promoted_attribute_definition_parser.js";
import NoteContextAwareWidget from "../note_context_aware_widget.js";
import SpacedUpdate from "../../services/spaced_update.js";
import utils from "../../services/utils.js";
import shortcutService from "../../services/shortcuts.js";
import appContext from "../../components/app_context.js";
import type { Attribute } from "../../services/attribute_parser.js";
const TPL = /*html*/`
<div class="attr-detail tn-tool-dialog">
<style>
.attr-detail {
display: block;
background-color: var(--accented-background-color);
border: 1px solid var(--main-border-color);
border-radius: 4px;
z-index: 1000;
padding: 15px;
position: absolute;
width: 500px;
max-height: 600px;
overflow: auto;
box-shadow: 10px 10px 93px -25px black;
}
.attr-help td {
color: var(--muted-text-color);
padding: 5px;
}
.related-notes-list {
padding-left: 20px;
margin-top: 10px;
margin-bottom: 10px;
}
.attr-edit-table {
width: 100%;
}
.attr-edit-table th {
text-align: left;
}
.attr-edit-table td input[not(type="checkbox")] {
width: 100%;
}
.attr-edit-table td input[type="checkbox"] {
display: inline-block;
}
.close-attr-detail-button {
font-size: x-large;
cursor: pointer;
position: relative;
top: -2px;
}
.attr-save-delete-button-container {
display: flex;
margin-top: 15px;
}
.attr-detail input[readonly] {
background-color: var(--accented-background-color) !important;
}
.attr-edit-table td {
padding: 4px 0;
}
</style>
<div style="display: flex; justify-content: space-between; margin-bottom: 8px;">
<h5 class="attr-detail-title">${t("attribute_detail.attr_detail_title")}</h5>
<span class="bx bx-x close-attr-detail-button tn-tool-button" title="${t("attribute_detail.close_button_title")}"></span>
</div>
<div class="attr-is-owned-by">${t("attribute_detail.attr_is_owned_by")}</div>
<table class="attr-edit-table">
<tr title="${t("attribute_detail.attr_name_title")}">
<th>${t("attribute_detail.name")}</th>
<td><input type="text" class="attr-input-name form-control" /></td>
</tr>
<tr class="attr-help"></tr>
<tr class="attr-row-value">
<th>${t("attribute_detail.value")}</th>
<td><input type="text" class="attr-input-value form-control" /></td>
</tr>
<tr class="attr-row-target-note">
<th title="${t("attribute_detail.target_note_title")}">${t("attribute_detail.target_note")}</th>
<td>
<div class="input-group">
<input type="text" class="attr-input-target-note form-control" />
</div>
</td>
</tr>
<tr class="attr-row-promoted"
title="${t("attribute_detail.promoted_title")}">
<th></th>
<td>
<label class="tn-checkbox">
<input type="checkbox" class="attr-input-promoted" />
${t("attribute_detail.promoted")}
</label>
</td>
</tr>
<tr class="attr-row-promoted-alias">
<th title="${t("attribute_detail.promoted_alias_title")}">${t("attribute_detail.promoted_alias")}</th>
<td>
<div class="input-group">
<input type="text" class="attr-input-promoted-alias form-control" />
</div>
</td>
</tr>
<tr class="attr-row-multiplicity">
<th title="${t("attribute_detail.multiplicity_title")}">${t("attribute_detail.multiplicity")}</th>
<td>
<select class="attr-input-multiplicity form-control">
<option value="single">${t("attribute_detail.single_value")}</option>
<option value="multi">${t("attribute_detail.multi_value")}</option>
</select>
</td>
</tr>
<tr class="attr-row-label-type">
<th title="${t("attribute_detail.label_type_title")}">${t("attribute_detail.label_type")}</th>
<td>
<select class="attr-input-label-type form-control">
<option value="text">${t("attribute_detail.text")}</option>
<option value="number">${t("attribute_detail.number")}</option>
<option value="boolean">${t("attribute_detail.boolean")}</option>
<option value="date">${t("attribute_detail.date")}</option>
<option value="datetime">${t("attribute_detail.date_time")}</option>
<option value="time">${t("attribute_detail.time")}</option>
<option value="url">${t("attribute_detail.url")}</option>
</select>
</td>
</tr>
<tr class="attr-row-number-precision">
<th title="${t("attribute_detail.precision_title")}">${t("attribute_detail.precision")}</th>
<td>
<div class="input-group">
<input type="number" class="form-control attr-input-number-precision" style="text-align: right">
<span class="input-group-text">${t("attribute_detail.digits")}</span>
</div>
</td>
</tr>
<tr class="attr-row-inverse-relation">
<th title="${t("attribute_detail.inverse_relation_title")}">${t("attribute_detail.inverse_relation")}</th>
<td>
<div class="input-group">
<input type="text" class="attr-input-inverse-relation form-control" />
</div>
</td>
</tr>
<tr title="${t("attribute_detail.inheritable_title")}">
<th></th>
<td>
<label class="tn-checkbox">
<input type="checkbox" class="attr-input-inheritable" />
${t("attribute_detail.inheritable")}
</label>
</td>
</tr>
</table>
<div class="attr-save-delete-button-container">
<button class="btn btn-primary btn-sm attr-save-changes-and-close-button"
style="flex-grow: 1; margin-right: 20px">
${t("attribute_detail.save_and_close")}</button>
<button class="btn btn-secondary btn-sm attr-delete-button">
${t("attribute_detail.delete")}</button>
</div>
<div class="related-notes-container">
<br/>
<h5 class="related-notes-tile">${t("attribute_detail.related_notes_title")}</h5>
<ul class="related-notes-list"></ul>
<div class="related-notes-more-notes">${t("attribute_detail.more_notes")}</div>
</div>
</div>`;
const DISPLAYED_NOTES = 10;
const ATTR_TITLES: Record<string, string> = {
label: t("attribute_detail.label"),
"label-definition": t("attribute_detail.label_definition"),
relation: t("attribute_detail.relation"),
"relation-definition": t("attribute_detail.relation_definition")
};
const ATTR_HELP: Record<string, Record<string, string>> = {
label: {
disableVersioning: t("attribute_detail.disable_versioning"),
calendarRoot: t("attribute_detail.calendar_root"),
archived: t("attribute_detail.archived"),
excludeFromExport: t("attribute_detail.exclude_from_export"),
run: t("attribute_detail.run"),
runOnInstance: t("attribute_detail.run_on_instance"),
runAtHour: t("attribute_detail.run_at_hour"),
disableInclusion: t("attribute_detail.disable_inclusion"),
sorted: t("attribute_detail.sorted"),
sortDirection: t("attribute_detail.sort_direction"),
sortFoldersFirst: t("attribute_detail.sort_folders_first"),
top: t("attribute_detail.top"),
hidePromotedAttributes: t("attribute_detail.hide_promoted_attributes"),
readOnly: t("attribute_detail.read_only"),
autoReadOnlyDisabled: t("attribute_detail.auto_read_only_disabled"),
appCss: t("attribute_detail.app_css"),
appTheme: t("attribute_detail.app_theme"),
appThemeBase: t("attribute_detail.app_theme_base"),
cssClass: t("attribute_detail.css_class"),
iconClass: t("attribute_detail.icon_class"),
pageSize: t("attribute_detail.page_size"),
customRequestHandler: t("attribute_detail.custom_request_handler"),
customResourceProvider: t("attribute_detail.custom_resource_provider"),
widget: t("attribute_detail.widget"),
workspace: t("attribute_detail.workspace"),
workspaceIconClass: t("attribute_detail.workspace_icon_class"),
workspaceTabBackgroundColor: t("attribute_detail.workspace_tab_background_color"),
workspaceCalendarRoot: t("attribute_detail.workspace_calendar_root"),
workspaceTemplate: t("attribute_detail.workspace_template"),
searchHome: t("attribute_detail.search_home"),
workspaceSearchHome: t("attribute_detail.workspace_search_home"),
inbox: t("attribute_detail.inbox"),
workspaceInbox: t("attribute_detail.workspace_inbox"),
sqlConsoleHome: t("attribute_detail.sql_console_home"),
bookmarkFolder: t("attribute_detail.bookmark_folder"),
shareHiddenFromTree: t("attribute_detail.share_hidden_from_tree"),
shareExternalLink: t("attribute_detail.share_external_link"),
shareAlias: t("attribute_detail.share_alias"),
shareOmitDefaultCss: t("attribute_detail.share_omit_default_css"),
shareRoot: t("attribute_detail.share_root"),
shareDescription: t("attribute_detail.share_description"),
shareRaw: t("attribute_detail.share_raw"),
shareDisallowRobotIndexing: t("attribute_detail.share_disallow_robot_indexing"),
shareCredentials: t("attribute_detail.share_credentials"),
shareIndex: t("attribute_detail.share_index"),
displayRelations: t("attribute_detail.display_relations"),
hideRelations: t("attribute_detail.hide_relations"),
titleTemplate: t("attribute_detail.title_template"),
template: t("attribute_detail.template"),
toc: t("attribute_detail.toc"),
color: t("attribute_detail.color"),
keyboardShortcut: t("attribute_detail.keyboard_shortcut"),
keepCurrentHoisting: t("attribute_detail.keep_current_hoisting"),
executeButton: t("attribute_detail.execute_button"),
executeDescription: t("attribute_detail.execute_description"),
excludeFromNoteMap: t("attribute_detail.exclude_from_note_map"),
newNotesOnTop: t("attribute_detail.new_notes_on_top"),
hideHighlightWidget: t("attribute_detail.hide_highlight_widget"),
printLandscape: t("attribute_detail.print_landscape"),
printPageSize: t("attribute_detail.print_page_size")
},
relation: {
runOnNoteCreation: t("attribute_detail.run_on_note_creation"),
runOnChildNoteCreation: t("attribute_detail.run_on_child_note_creation"),
runOnNoteTitleChange: t("attribute_detail.run_on_note_title_change"),
runOnNoteContentChange: t("attribute_detail.run_on_note_content_change"),
runOnNoteChange: t("attribute_detail.run_on_note_change"),
runOnNoteDeletion: t("attribute_detail.run_on_note_deletion"),
runOnBranchCreation: t("attribute_detail.run_on_branch_creation"),
runOnBranchChange: t("attribute_detail.run_on_branch_change"),
runOnBranchDeletion: t("attribute_detail.run_on_branch_deletion"),
runOnAttributeCreation: t("attribute_detail.run_on_attribute_creation"),
runOnAttributeChange: t("attribute_detail.run_on_attribute_change"),
template: t("attribute_detail.relation_template"),
inherit: t("attribute_detail.inherit"),
renderNote: t("attribute_detail.render_note"),
widget: t("attribute_detail.widget_relation"),
shareCss: t("attribute_detail.share_css"),
shareJs: t("attribute_detail.share_js"),
shareTemplate: t("attribute_detail.share_template"),
shareFavicon: t("attribute_detail.share_favicon")
}
};
interface AttributeDetailOpts {
allAttributes?: Attribute[];
attribute: Attribute;
isOwned: boolean;
x: number;
y: number;
focus?: "name";
}
interface SearchRelatedResponse {
// TODO: Deduplicate once we split client from server.
results: {
noteId: string;
notePathArray: string[];
}[];
count: number;
}
export default class AttributeDetailWidget extends NoteContextAwareWidget {
private $title!: JQuery<HTMLElement>;
private $inputName!: JQuery<HTMLElement>;
private $inputValue!: JQuery<HTMLElement>;
private $rowPromoted!: JQuery<HTMLElement>;
private $inputPromoted!: JQuery<HTMLElement>;
private $inputPromotedAlias!: JQuery<HTMLElement>;
private $inputMultiplicity!: JQuery<HTMLElement>;
private $inputInverseRelation!: JQuery<HTMLElement>;
private $inputLabelType!: JQuery<HTMLElement>;
private $inputTargetNote!: JQuery<HTMLElement>;
private $inputNumberPrecision!: JQuery<HTMLElement>;
private $inputInheritable!: JQuery<HTMLElement>;
private $rowValue!: JQuery<HTMLElement>;
private $rowMultiplicity!: JQuery<HTMLElement>;
private $rowLabelType!: JQuery<HTMLElement>;
private $rowNumberPrecision!: JQuery<HTMLElement>;
private $rowInverseRelation!: JQuery<HTMLElement>;
private $rowTargetNote!: JQuery<HTMLElement>;
private $rowPromotedAlias!: JQuery<HTMLElement>;
private $attrIsOwnedBy!: JQuery<HTMLElement>;
private $attrSaveDeleteButtonContainer!: JQuery<HTMLElement>;
private $closeAttrDetailButton!: JQuery<HTMLElement>;
private $saveAndCloseButton!: JQuery<HTMLElement>;
private $deleteButton!: JQuery<HTMLElement>;
private $relatedNotesContainer!: JQuery<HTMLElement>;
private $relatedNotesTitle!: JQuery<HTMLElement>;
private $relatedNotesList!: JQuery<HTMLElement>;
private $relatedNotesMoreNotes!: JQuery<HTMLElement>;
private $attrHelp!: JQuery<HTMLElement>;
private relatedNotesSpacedUpdate!: SpacedUpdate;
private attribute!: Attribute;
private allAttributes?: Attribute[];
private attrType!: ReturnType<AttributeDetailWidget["getAttrType"]>;
async refresh() {
// switching note/tab should close the widget
this.hide();
}
doRender() {
this.relatedNotesSpacedUpdate = new SpacedUpdate(async () => this.updateRelatedNotes(), 1000);
this.$widget = $(TPL);
shortcutService.bindElShortcut(this.$widget, "ctrl+return", () => this.saveAndClose());
shortcutService.bindElShortcut(this.$widget, "esc", () => this.cancelAndClose());
this.$title = this.$widget.find(".attr-detail-title");
this.$inputName = this.$widget.find(".attr-input-name");
this.$inputName.on("input", (ev) => {
if (!(ev.originalEvent as KeyboardEvent)?.isComposing) {
// https://github.com/zadam/trilium/pull/3812
this.userEditedAttribute();
}
});
this.$inputName.on("change", () => this.userEditedAttribute());
this.$inputName.on("autocomplete:closed", () => this.userEditedAttribute());
this.$inputName.on("focus", () => {
attributeAutocompleteService.initAttributeNameAutocomplete({
$el: this.$inputName,
attributeType: () => (["relation", "relation-definition"].includes(this.attrType || "") ? "relation" : "label"),
open: true
});
});
this.$rowValue = this.$widget.find(".attr-row-value");
this.$inputValue = this.$widget.find(".attr-input-value");
this.$inputValue.on("input", (ev) => {
if (!(ev.originalEvent as KeyboardEvent)?.isComposing) {
// https://github.com/zadam/trilium/pull/3812
this.userEditedAttribute();
}
});
this.$inputValue.on("change", () => this.userEditedAttribute());
this.$inputValue.on("autocomplete:closed", () => this.userEditedAttribute());
this.$inputValue.on("focus", () => {
attributeAutocompleteService.initLabelValueAutocomplete({
$el: this.$inputValue,
open: true,
nameCallback: () => String(this.$inputName.val())
});
});
this.$rowPromoted = this.$widget.find(".attr-row-promoted");
this.$inputPromoted = this.$widget.find(".attr-input-promoted");
this.$inputPromoted.on("change", () => this.userEditedAttribute());
this.$rowPromotedAlias = this.$widget.find(".attr-row-promoted-alias");
this.$inputPromotedAlias = this.$widget.find(".attr-input-promoted-alias");
this.$inputPromotedAlias.on("change", () => this.userEditedAttribute());
this.$rowMultiplicity = this.$widget.find(".attr-row-multiplicity");
this.$inputMultiplicity = this.$widget.find(".attr-input-multiplicity");
this.$inputMultiplicity.on("change", () => this.userEditedAttribute());
this.$rowLabelType = this.$widget.find(".attr-row-label-type");
this.$inputLabelType = this.$widget.find(".attr-input-label-type");
this.$inputLabelType.on("change", () => this.userEditedAttribute());
this.$rowNumberPrecision = this.$widget.find(".attr-row-number-precision");
this.$inputNumberPrecision = this.$widget.find(".attr-input-number-precision");
this.$inputNumberPrecision.on("change", () => this.userEditedAttribute());
this.$rowInverseRelation = this.$widget.find(".attr-row-inverse-relation");
this.$inputInverseRelation = this.$widget.find(".attr-input-inverse-relation");
this.$inputInverseRelation.on("input", (ev) => {
if (!(ev.originalEvent as KeyboardEvent)?.isComposing) {
// https://github.com/zadam/trilium/pull/3812
this.userEditedAttribute();
}
});
this.$rowTargetNote = this.$widget.find(".attr-row-target-note");
this.$inputTargetNote = this.$widget.find(".attr-input-target-note");
noteAutocompleteService.initNoteAutocomplete(this.$inputTargetNote, { allowCreatingNotes: true }).on("autocomplete:noteselected", (event, suggestion, dataset) => {
if (!suggestion.notePath) {
return false;
}
const pathChunks = suggestion.notePath.split("/");
this.attribute.value = pathChunks[pathChunks.length - 1]; // noteId
this.triggerCommand("updateAttributeList", { attributes: this.allAttributes ?? [] });
this.updateRelatedNotes();
});
this.$inputInheritable = this.$widget.find(".attr-input-inheritable");
this.$inputInheritable.on("change", () => this.userEditedAttribute());
this.$closeAttrDetailButton = this.$widget.find(".close-attr-detail-button");
this.$closeAttrDetailButton.on("click", () => this.cancelAndClose());
this.$attrIsOwnedBy = this.$widget.find(".attr-is-owned-by");
this.$attrSaveDeleteButtonContainer = this.$widget.find(".attr-save-delete-button-container");
this.$saveAndCloseButton = this.$widget.find(".attr-save-changes-and-close-button");
this.$saveAndCloseButton.on("click", () => this.saveAndClose());
this.$deleteButton = this.$widget.find(".attr-delete-button");
this.$deleteButton.on("click", async () => {
await this.triggerCommand("updateAttributeList", {
attributes: (this.allAttributes || []).filter((attr) => attr !== this.attribute)
});
await this.triggerCommand("saveAttributes");
this.hide();
});
this.$attrHelp = this.$widget.find(".attr-help");
this.$relatedNotesContainer = this.$widget.find(".related-notes-container");
this.$relatedNotesTitle = this.$relatedNotesContainer.find(".related-notes-tile");
this.$relatedNotesList = this.$relatedNotesContainer.find(".related-notes-list");
this.$relatedNotesMoreNotes = this.$relatedNotesContainer.find(".related-notes-more-notes");
$(window).on("mousedown", (e) => {
if (!$(e.target).closest(this.$widget[0]).length && !$(e.target).closest(".algolia-autocomplete").length && !$(e.target).closest("#context-menu-container").length) {
this.hide();
}
});
}
async showAttributeDetail({ allAttributes, attribute, isOwned, x, y, focus }: AttributeDetailOpts) {
if (!attribute) {
this.hide();
return;
}
utils.saveFocusedElement();
this.attrType = this.getAttrType(attribute);
const attrName = this.attrType === "label-definition" ? attribute.name.substr(6) : this.attrType === "relation-definition" ? attribute.name.substr(9) : attribute.name;
const definition = this.attrType?.endsWith("-definition") ? promotedAttributeDefinitionParser.parse(attribute.value || "") : {};
if (this.attrType) {
this.$title.text(ATTR_TITLES[this.attrType]);
}
this.allAttributes = allAttributes;
this.attribute = attribute;
// can be slightly slower so just make it async
this.updateRelatedNotes();
this.$attrSaveDeleteButtonContainer.toggle(!!isOwned);
if (isOwned) {
this.$attrIsOwnedBy.hide();
} else if (attribute.noteId) {
this.$attrIsOwnedBy
.show()
.empty()
.append(attribute.type === "label" ? "Label" : "Relation")
.append(` ${t("attribute_detail.is_owned_by_note")} `)
.append(await linkService.createLink(attribute.noteId));
}
const disabledFn = () => (!isOwned ? "true" : undefined);
this.$inputName.val(attrName).attr("readonly", disabledFn);
this.$rowValue.toggle(this.attrType === "label");
this.$rowTargetNote.toggle(this.attrType === "relation");
this.$rowPromoted.toggle(["label-definition", "relation-definition"].includes(this.attrType || ""));
this.$inputPromoted.prop("checked", !!definition.isPromoted).attr("disabled", disabledFn);
this.$rowPromotedAlias.toggle(!!definition.isPromoted);
this.$inputPromotedAlias.val(definition.promotedAlias || "").attr("disabled", disabledFn);
this.$rowMultiplicity.toggle(["label-definition", "relation-definition"].includes(this.attrType || ""));
this.$inputMultiplicity.val(definition.multiplicity || "").attr("disabled", disabledFn);
this.$rowLabelType.toggle(this.attrType === "label-definition");
this.$inputLabelType.val(definition.labelType || "").attr("disabled", disabledFn);
this.$rowNumberPrecision.toggle(this.attrType === "label-definition" && definition.labelType === "number");
this.$inputNumberPrecision.val(definition.numberPrecision || "").attr("disabled", disabledFn);
this.$rowInverseRelation.toggle(this.attrType === "relation-definition");
this.$inputInverseRelation.val(definition.inverseRelation || "").attr("disabled", disabledFn);
if (attribute.type === "label") {
this.$inputValue.val(attribute.value || "").attr("readonly", disabledFn);
} else if (attribute.type === "relation") {
this.$inputTargetNote.attr("readonly", disabledFn).val("").setSelectedNotePath("");
if (attribute.value) {
const targetNote = await froca.getNote(attribute.value);
if (targetNote) {
this.$inputTargetNote.val(targetNote ? targetNote.title : "").setSelectedNotePath(attribute.value);
}
}
}
this.$inputInheritable.prop("checked", !!attribute.isInheritable).attr("disabled", disabledFn);
this.updateHelp();
this.toggleInt(true);
const offset = this.parent?.$widget.offset() || { top: 0, left: 0 };
const detPosition = this.getDetailPosition(x, offset);
const outerHeight = this.$widget.outerHeight();
const height = $(window).height();
if (detPosition && outerHeight && height) {
this.$widget
.css("left", detPosition.left)
.css("right", detPosition.right)
.css("top", y - offset.top + 70)
.css("max-height", outerHeight + y > height - 50 ? height - y - 50 : 10000);
}
if (focus === "name") {
this.$inputName.trigger("focus").trigger("select");
}
}
getDetailPosition(x: number, offset: { left: number }) {
const outerWidth = this.$widget.outerWidth();
if (!outerWidth) {
return null;
}
let left: number | string = x - offset.left - outerWidth / 2;
let right: number | string = "";
if (left < 0) {
left = 10;
} else {
const rightEdge = left + outerWidth;
if (rightEdge > outerWidth - 10) {
left = "";
right = 10;
}
}
return { left, right };
}
async saveAndClose() {
await this.triggerCommand("saveAttributes");
this.hide();
utils.focusSavedElement();
}
async cancelAndClose() {
await this.triggerCommand("reloadAttributes");
this.hide();
utils.focusSavedElement();
}
userEditedAttribute() {
this.updateAttributeInEditor();
this.updateHelp();
this.relatedNotesSpacedUpdate.scheduleUpdate();
}
updateHelp() {
const attrName = String(this.$inputName.val());
if (this.attrType && this.attrType in ATTR_HELP && attrName && attrName in ATTR_HELP[this.attrType]) {
this.$attrHelp
.empty()
.append($("<td colspan=2>").append($("<strong>").text(attrName)).append(" - ").append(ATTR_HELP[this.attrType][attrName]))
.show();
} else {
this.$attrHelp.empty().hide();
}
}
async updateRelatedNotes() {
let { results, count } = await server.post<SearchRelatedResponse>("search-related", this.attribute);
for (const res of results) {
res.noteId = res.notePathArray[res.notePathArray.length - 1];
}
results = results.filter(({ noteId }) => noteId !== this.noteId);
if (results.length === 0) {
this.$relatedNotesContainer.hide();
} else {
this.$relatedNotesContainer.show();
this.$relatedNotesTitle.text(t("attribute_detail.other_notes_with_name", { attributeType: this.attribute.type, attributeName: this.attribute.name }));
this.$relatedNotesList.empty();
const displayedResults = results.length <= DISPLAYED_NOTES ? results : results.slice(0, DISPLAYED_NOTES);
const displayedNotes = await froca.getNotes(displayedResults.map((res) => res.noteId));
const hoistedNoteId = appContext.tabManager.getActiveContext()?.hoistedNoteId;
for (const note of displayedNotes) {
const notePath = note.getBestNotePathString(hoistedNoteId);
const $noteLink = await linkService.createLink(notePath, { showNotePath: true });
this.$relatedNotesList.append($("<li>").append($noteLink));
}
if (results.length > DISPLAYED_NOTES) {
this.$relatedNotesMoreNotes.show().text(t("attribute_detail.and_more", { count: count - DISPLAYED_NOTES }));
} else {
this.$relatedNotesMoreNotes.hide();
}
}
}
getAttrType(attribute: Attribute) {
if (attribute.type === "label") {
if (attribute.name.startsWith("label:")) {
return "label-definition";
} else if (attribute.name.startsWith("relation:")) {
return "relation-definition";
} else {
return "label";
}
} else if (attribute.type === "relation") {
return "relation";
} else {
this.$title.text("");
}
}
updateAttributeInEditor() {
let attrName = String(this.$inputName.val());
if (!utils.isValidAttributeName(attrName)) {
// invalid characters are simply ignored (from user perspective they are not even entered)
attrName = utils.filterAttributeName(attrName);
this.$inputName.val(attrName);
}
if (this.attrType === "label-definition") {
attrName = `label:${attrName}`;
} else if (this.attrType === "relation-definition") {
attrName = `relation:${attrName}`;
}
this.attribute.name = attrName;
this.attribute.isInheritable = this.$inputInheritable.is(":checked");
if (this.attrType?.endsWith("-definition")) {
this.attribute.value = this.buildDefinitionValue();
} else if (this.attrType === "relation") {
this.attribute.value = this.$inputTargetNote.getSelectedNoteId() || "";
} else {
this.attribute.value = String(this.$inputValue.val());
}
this.triggerCommand("updateAttributeList", { attributes: this.allAttributes ?? [] });
}
buildDefinitionValue() {
const props = [];
if (this.$inputPromoted.is(":checked")) {
props.push("promoted");
if (this.$inputPromotedAlias.val() !== "") {
props.push(`alias=${this.$inputPromotedAlias.val()}`);
}
}
props.push(this.$inputMultiplicity.val());
if (this.attrType === "label-definition") {
props.push(this.$inputLabelType.val());
if (this.$inputLabelType.val() === "number" && this.$inputNumberPrecision.val() !== "") {
props.push(`precision=${this.$inputNumberPrecision.val()}`);
}
} else if (this.attrType === "relation-definition" && String(this.$inputInverseRelation.val())?.trim().length > 0) {
const inverseRelationName = this.$inputInverseRelation.val();
props.push(`inverse=${utils.filterAttributeName(String(inverseRelationName))}`);
}
this.$rowNumberPrecision.toggle(this.attrType === "label-definition" && this.$inputLabelType.val() === "number");
this.$rowPromotedAlias.toggle(this.$inputPromoted.is(":checked"));
return props.join(",");
}
hide() {
this.toggleInt(false);
}
createLink(noteId: string) {
return $("<a>", {
href: `#root/${noteId}`,
class: "reference-link"
});
}
async noteSwitched() {
this.hide();
}
}

View File

@@ -0,0 +1,548 @@
import { t } from "../../services/i18n.js";
import NoteContextAwareWidget from "../note_context_aware_widget.js";
import noteAutocompleteService from "../../services/note_autocomplete.js";
import server from "../../services/server.js";
import contextMenuService from "../../menus/context_menu.js";
import attributeParser, { type Attribute } from "../../services/attribute_parser.js";
import libraryLoader from "../../services/library_loader.js";
import froca from "../../services/froca.js";
import attributeRenderer from "../../services/attribute_renderer.js";
import noteCreateService from "../../services/note_create.js";
import attributeService from "../../services/attributes.js";
import linkService from "../../services/link.js";
import type AttributeDetailWidget from "./attribute_detail.js";
import type { CommandData, EventData, EventListener, FilteredCommandNames } from "../../components/app_context.js";
import type { default as FAttribute, AttributeType } from "../../entities/fattribute.js";
import type FNote from "../../entities/fnote.js";
import { escapeQuotes } from "../../services/utils.js";
import { buildConfig } from "../type_widgets/ckeditor/config.js";
const HELP_TEXT = `
<p>${t("attribute_editor.help_text_body1")}</p>
<p>${t("attribute_editor.help_text_body2")}</p>
<p>${t("attribute_editor.help_text_body3")}</p>`;
const TPL = /*html*/`
<div style="position: relative; padding-top: 10px; padding-bottom: 10px">
<style>
.attribute-list-editor {
border: 0 !important;
outline: 0 !important;
box-shadow: none !important;
padding: 0 0 0 5px !important;
margin: 0 !important;
max-height: 100px;
overflow: auto;
transition: opacity .1s linear;
}
.attribute-list-editor.ck-content .mention {
color: var(--muted-text-color) !important;
background: transparent !important;
}
.save-attributes-button {
color: var(--muted-text-color);
position: absolute;
bottom: 14px;
right: 25px;
cursor: pointer;
border: 1px solid transparent;
font-size: 130%;
}
.add-new-attribute-button {
color: var(--muted-text-color);
position: absolute;
bottom: 13px;
right: 0;
cursor: pointer;
border: 1px solid transparent;
font-size: 130%;
}
.add-new-attribute-button:hover, .save-attributes-button:hover {
border: 1px solid var(--button-border-color);
border-radius: var(--button-border-radius);
background: var(--button-background-color);
color: var(--button-text-color);
}
.attribute-errors {
color: red;
padding: 5px 50px 0px 5px; /* large right padding to avoid buttons */
}
</style>
<div class="attribute-list-editor" tabindex="200"></div>
<div class="bx bx-save save-attributes-button tn-tool-button" title="${escapeQuotes(t("attribute_editor.save_attributes"))}"></div>
<div class="bx bx-plus add-new-attribute-button tn-tool-button" title="${escapeQuotes(t("attribute_editor.add_a_new_attribute"))}"></div>
<div class="attribute-errors" style="display: none;"></div>
</div>
`;
const mentionSetup: MentionConfig = {
feeds: [
{
marker: "@",
feed: (queryText) => noteAutocompleteService.autocompleteSourceForCKEditor(queryText),
itemRenderer: (item) => {
const itemElement = document.createElement("button");
itemElement.innerHTML = `${item.highlightedNotePathTitle} `;
return itemElement;
},
minimumCharacters: 0
},
{
marker: "#",
feed: async (queryText) => {
const names = await server.get<string[]>(`attribute-names/?type=label&query=${encodeURIComponent(queryText)}`);
return names.map((name) => {
return {
id: `#${name}`,
name: name
};
});
},
minimumCharacters: 0
},
{
marker: "~",
feed: async (queryText) => {
const names = await server.get<string[]>(`attribute-names/?type=relation&query=${encodeURIComponent(queryText)}`);
return names.map((name) => {
return {
id: `~${name}`,
name: name
};
});
},
minimumCharacters: 0
}
]
};
const editorConfig = {
...buildConfig(),
removePlugins: [
"Heading",
"Link",
"Autoformat",
"Bold",
"Italic",
"Underline",
"Strikethrough",
"Code",
"Superscript",
"Subscript",
"BlockQuote",
"Image",
"ImageCaption",
"ImageStyle",
"ImageToolbar",
"ImageUpload",
"ImageResize",
"List",
"TodoList",
"PasteFromOffice",
"Table",
"TableToolbar",
"TableProperties",
"TableCellProperties",
"Indent",
"IndentBlock",
"BlockToolbar",
"ParagraphButtonUI",
"HeadingButtonsUI",
"UploadimagePlugin",
"InternalLinkPlugin",
"MarkdownImportPlugin",
"CuttonotePlugin",
"TextTransformation",
"Font",
"FontColor",
"FontBackgroundColor",
"CodeBlock",
"SelectAll",
"IncludeNote",
"CutToNote",
"Math",
"AutoformatMath",
"indentBlockShortcutPlugin",
"removeFormatLinksPlugin",
"Footnotes",
"Mermaid",
"Kbd",
"Admonition"
],
toolbar: {
items: []
},
placeholder: t("attribute_editor.placeholder"),
mention: mentionSetup
};
type AttributeCommandNames = FilteredCommandNames<CommandData>;
export default class AttributeEditorWidget extends NoteContextAwareWidget implements EventListener<"entitiesReloaded">, EventListener<"addNewLabel">, EventListener<"addNewRelation"> {
private attributeDetailWidget: AttributeDetailWidget;
private $editor!: JQuery<HTMLElement>;
private $addNewAttributeButton!: JQuery<HTMLElement>;
private $saveAttributesButton!: JQuery<HTMLElement>;
private $errors!: JQuery<HTMLElement>;
private textEditor!: TextEditor;
private lastUpdatedNoteId!: string | undefined;
private lastSavedContent!: string;
constructor(attributeDetailWidget: AttributeDetailWidget) {
super();
this.attributeDetailWidget = attributeDetailWidget;
}
doRender() {
this.$widget = $(TPL);
this.$editor = this.$widget.find(".attribute-list-editor");
this.initialized = this.initEditor();
this.$editor.on("keydown", async (e) => {
if (e.which === 13) {
// allow autocomplete to fill the result textarea
setTimeout(() => this.save(), 100);
}
this.attributeDetailWidget.hide();
});
this.$editor.on("blur", () => setTimeout(() => this.save(), 100)); // Timeout to fix https://github.com/zadam/trilium/issues/4160
this.$addNewAttributeButton = this.$widget.find(".add-new-attribute-button");
this.$addNewAttributeButton.on("click", (e) => this.addNewAttribute(e));
this.$saveAttributesButton = this.$widget.find(".save-attributes-button");
this.$saveAttributesButton.on("click", () => this.save());
this.$errors = this.$widget.find(".attribute-errors");
}
addNewAttribute(e: JQuery.ClickEvent) {
contextMenuService.show<AttributeCommandNames>({
x: e.pageX,
y: e.pageY,
orientation: "left",
items: [
{ title: t("attribute_editor.add_new_label"), command: "addNewLabel", uiIcon: "bx bx-hash" },
{ title: t("attribute_editor.add_new_relation"), command: "addNewRelation", uiIcon: "bx bx-transfer" },
{ title: "----" },
{ title: t("attribute_editor.add_new_label_definition"), command: "addNewLabelDefinition", uiIcon: "bx bx-empty" },
{ title: t("attribute_editor.add_new_relation_definition"), command: "addNewRelationDefinition", uiIcon: "bx bx-empty" }
],
selectMenuItemHandler: ({ command }) => this.handleAddNewAttributeCommand(command)
});
}
// triggered from keyboard shortcut
async addNewLabelEvent({ ntxId }: EventData<"addNewLabel">) {
if (this.isNoteContext(ntxId)) {
await this.refresh();
this.handleAddNewAttributeCommand("addNewLabel");
}
}
// triggered from keyboard shortcut
async addNewRelationEvent({ ntxId }: EventData<"addNewRelation">) {
if (this.isNoteContext(ntxId)) {
await this.refresh();
this.handleAddNewAttributeCommand("addNewRelation");
}
}
async handleAddNewAttributeCommand(command: AttributeCommandNames | undefined) {
// TODO: Not sure what the relation between FAttribute[] and Attribute[] is.
const attrs = this.parseAttributes() as FAttribute[];
if (!attrs) {
return;
}
let type: AttributeType;
let name;
let value;
if (command === "addNewLabel") {
type = "label";
name = "myLabel";
value = "";
} else if (command === "addNewRelation") {
type = "relation";
name = "myRelation";
value = "";
} else if (command === "addNewLabelDefinition") {
type = "label";
name = "label:myLabel";
value = "promoted,single,text";
} else if (command === "addNewRelationDefinition") {
type = "label";
name = "relation:myRelation";
value = "promoted,single";
} else {
return;
}
// TODO: Incomplete type
//@ts-ignore
attrs.push({
type,
name,
value,
isInheritable: false
});
await this.renderOwnedAttributes(attrs, false);
this.$editor.scrollTop(this.$editor[0].scrollHeight);
const rect = this.$editor[0].getBoundingClientRect();
setTimeout(() => {
// showing a little bit later because there's a conflict with outside click closing the attr detail
this.attributeDetailWidget.showAttributeDetail({
allAttributes: attrs,
attribute: attrs[attrs.length - 1],
isOwned: true,
x: (rect.left + rect.right) / 2,
y: rect.bottom,
focus: "name"
});
}, 100);
}
async save() {
if (this.lastUpdatedNoteId !== this.noteId) {
// https://github.com/zadam/trilium/issues/3090
console.warn("Ignoring blur event because a different note is loaded.");
return;
}
const attributes = this.parseAttributes();
if (attributes) {
await server.put(`notes/${this.noteId}/attributes`, attributes, this.componentId);
this.$saveAttributesButton.fadeOut();
// blink the attribute text to give a visual hint that save has been executed
this.$editor.css("opacity", 0);
// revert back
setTimeout(() => this.$editor.css("opacity", 1), 100);
}
}
parseAttributes() {
try {
return attributeParser.lexAndParse(this.getPreprocessedData());
} catch (e: any) {
this.$errors.text(e.message).slideDown();
}
}
getPreprocessedData() {
const str = this.textEditor
.getData()
.replace(/<a[^>]+href="(#[A-Za-z0-9_/]*)"[^>]*>[^<]*<\/a>/g, "$1")
.replace(/&nbsp;/g, " "); // otherwise .text() below outputs non-breaking space in unicode
return $("<div>").html(str).text();
}
async initEditor() {
await libraryLoader.requireLibrary(libraryLoader.CKEDITOR);
this.$widget.show();
this.$editor.on("click", (e) => this.handleEditorClick(e));
this.textEditor = await CKEditor.BalloonEditor.create(this.$editor[0], editorConfig);
this.textEditor.model.document.on("change:data", () => this.dataChanged());
this.textEditor.editing.view.document.on(
"enter",
(event, data) => {
// disable entering new line - see https://github.com/ckeditor/ckeditor5/issues/9422
data.preventDefault();
event.stop();
},
{ priority: "high" }
);
// disable spellcheck for attribute editor
this.textEditor.editing.view.change((writer) => writer.setAttribute("spellcheck", "false", this.textEditor.editing.view.document.getRoot()));
}
dataChanged() {
this.lastUpdatedNoteId = this.noteId;
if (this.lastSavedContent === this.textEditor.getData()) {
this.$saveAttributesButton.fadeOut();
} else {
this.$saveAttributesButton.fadeIn();
}
if (this.$errors.is(":visible")) {
// using .hide() instead of .slideUp() since this will also hide the error after confirming
// mention for relation name which suits up. When using.slideUp() error will appear and the slideUp which is weird
this.$errors.hide();
}
}
async handleEditorClick(e: JQuery.ClickEvent) {
const pos = this.textEditor.model.document.selection.getFirstPosition();
if (pos && pos.textNode && pos.textNode.data) {
const clickIndex = this.getClickIndex(pos);
let parsedAttrs;
try {
parsedAttrs = attributeParser.lexAndParse(this.getPreprocessedData(), true);
} catch (e) {
// the input is incorrect because the user messed up with it and now needs to fix it manually
return null;
}
let matchedAttr: Attribute | null = null;
for (const attr of parsedAttrs) {
if (attr.startIndex && clickIndex > attr.startIndex && attr.endIndex && clickIndex <= attr.endIndex) {
matchedAttr = attr;
break;
}
}
setTimeout(() => {
if (matchedAttr) {
this.$editor.tooltip("hide");
this.attributeDetailWidget.showAttributeDetail({
allAttributes: parsedAttrs,
attribute: matchedAttr,
isOwned: true,
x: e.pageX,
y: e.pageY
});
} else {
this.showHelpTooltip();
}
}, 100);
} else {
this.showHelpTooltip();
}
}
showHelpTooltip() {
this.attributeDetailWidget.hide();
this.$editor.tooltip({
trigger: "focus",
html: true,
title: HELP_TEXT,
placement: "bottom",
offset: "0,30"
});
this.$editor.tooltip("show");
}
getClickIndex(pos: TextPosition) {
let clickIndex = pos.offset - pos.textNode.startOffset;
let curNode = pos.textNode;
while (curNode.previousSibling) {
curNode = curNode.previousSibling;
if (curNode.name === "reference") {
clickIndex += curNode._attrs.get("notePath").length + 1;
} else {
clickIndex += curNode.data.length;
}
}
return clickIndex;
}
async loadReferenceLinkTitle($el: JQuery<HTMLElement>, href: string) {
const { noteId } = linkService.parseNavigationStateFromUrl(href);
const note = noteId ? await froca.getNote(noteId, true) : null;
const title = note ? note.title : "[missing]";
$el.text(title);
}
async refreshWithNote(note: FNote) {
await this.renderOwnedAttributes(note.getOwnedAttributes(), true);
}
async renderOwnedAttributes(ownedAttributes: FAttribute[], saved: boolean) {
// attrs are not resorted if position changes after the initial load
ownedAttributes.sort((a, b) => a.position - b.position);
let htmlAttrs = (await attributeRenderer.renderAttributes(ownedAttributes, true)).html();
if (htmlAttrs.length > 0) {
htmlAttrs += "&nbsp;";
}
this.textEditor.setData(htmlAttrs);
if (saved) {
this.lastSavedContent = this.textEditor.getData();
this.$saveAttributesButton.fadeOut(0);
}
}
async createNoteForReferenceLink(title: string) {
let result;
if (this.notePath) {
result = await noteCreateService.createNoteWithTypePrompt(this.notePath, {
activate: false,
title: title
});
}
return result?.note?.getBestNotePathString();
}
async updateAttributeList(attributes: FAttribute[]) {
await this.renderOwnedAttributes(attributes, false);
}
focus() {
this.$editor.trigger("focus");
this.textEditor.model.change((writer) => {
const positionAt = writer.createPositionAt(this.textEditor.model.document.getRoot(), "end");
writer.setSelection(positionAt);
});
}
entitiesReloadedEvent({ loadResults }: EventData<"entitiesReloaded">) {
if (loadResults.getAttributeRows(this.componentId).find((attr) => attributeService.isAffecting(attr, this.note))) {
this.refresh();
}
}
}

View File

@@ -0,0 +1,260 @@
import Component, { TypedComponent } from "../components/component.js";
import froca from "../services/froca.js";
import { t } from "../services/i18n.js";
import toastService from "../services/toast.js";
export class TypedBasicWidget<T extends TypedComponent<any>> extends TypedComponent<T> {
protected attrs: Record<string, string>;
private classes: string[];
private childPositionCounter: number;
private cssEl?: string;
_noteId!: string;
constructor() {
super();
this.attrs = {
style: ""
};
this.classes = [];
this.children = [];
this.childPositionCounter = 10;
}
child(...components: T[]) {
if (!components) {
return this;
}
super.child(...components);
for (const component of components) {
if (component.position === undefined) {
component.position = this.childPositionCounter;
this.childPositionCounter += 10;
}
}
this.children.sort((a, b) => a.position - b.position);
return this;
}
/**
* Conditionally adds the given components as children to this component.
*
* @param condition whether to add the components.
* @param components the components to be added as children to this component provided the condition is truthy.
* @returns self for chaining.
*/
optChild(condition: boolean, ...components: T[]) {
if (condition) {
return this.child(...components);
} else {
return this;
}
}
id(id: string) {
this.attrs.id = id;
return this;
}
class(className: string) {
this.classes.push(className);
return this;
}
/**
* Sets the CSS attribute of the given name to the given value.
*
* @param name the name of the CSS attribute to set (e.g. `padding-left`).
* @param value the value of the CSS attribute to set (e.g. `12px`).
* @returns self for chaining.
*/
css(name: string, value: string) {
this.attrs.style += `${name}: ${value};`;
return this;
}
/**
* Sets the CSS attribute of the given name to the given value, but only if the condition provided is truthy.
*
* @param condition `true` in order to apply the CSS, `false` to ignore it.
* @param name the name of the CSS attribute to set (e.g. `padding-left`).
* @param value the value of the CSS attribute to set (e.g. `12px`).
* @returns self for chaining.
*/
optCss(condition: boolean, name: string, value: string) {
if (condition) {
return this.css(name, value);
}
return this;
}
contentSized() {
this.css("contain", "none");
return this;
}
collapsible() {
this.css("min-height", "0");
this.css("min-width", "0");
return this;
}
filling() {
this.css("flex-grow", "1");
return this;
}
/**
* Accepts a string of CSS to add with the widget.
* @returns for chaining
*/
cssBlock(block: string) {
this.cssEl = block;
return this;
}
render() {
try {
this.doRender();
} catch (e: any) {
this.logRenderingError(e);
}
this.$widget.attr("data-component-id", this.componentId);
this.$widget.addClass("component").prop("component", this);
if (!this.isEnabled()) {
this.toggleInt(false);
}
if (this.cssEl) {
const css = this.cssEl.trim().startsWith("<style>") ? this.cssEl : `<style>${this.cssEl}</style>`;
this.$widget.append(css);
}
for (const key in this.attrs) {
if (key === "style") {
if (this.attrs[key]) {
let style = this.$widget.attr("style");
style = style ? `${style}; ${this.attrs[key]}` : this.attrs[key];
this.$widget.attr(key, style);
}
} else {
this.$widget.attr(key, this.attrs[key]);
}
}
for (const className of this.classes) {
this.$widget.addClass(className);
}
return this.$widget;
}
logRenderingError(e: Error) {
console.log("Got issue in widget ", this);
console.error(e);
let noteId = this._noteId;
if (this._noteId) {
froca.getNote(noteId, true).then((note) => {
toastService.showPersistent({
title: t("toast.widget-error.title"),
icon: "alert",
message: t("toast.widget-error.message-custom", {
id: noteId,
title: note?.title,
message: e.message
})
});
});
return;
}
toastService.showPersistent({
title: t("toast.widget-error.title"),
icon: "alert",
message: t("toast.widget-error.message-unknown", {
message: e.message
})
});
}
/**
* Indicates if the widget is enabled. Widgets are enabled by default. Generally setting this to `false` will cause the widget not to be displayed, however it will still be available on the DOM but hidden.
* @returns whether the widget is enabled.
*/
isEnabled(): boolean | null | undefined {
return true;
}
/**
* Method used for rendering the widget.
*
* Your class should override this method.
* The method is expected to create a this.$widget containing jQuery object
*/
doRender() {}
toggleInt(show: boolean | null | undefined) {
this.$widget.toggleClass("hidden-int", !show)
.toggleClass("visible", !!show);
}
isHiddenInt() {
return this.$widget.hasClass("hidden-int");
}
toggleExt(show: boolean | null | "" | undefined) {
this.$widget.toggleClass("hidden-ext", !show)
.toggleClass("visible", !!show);
}
isHiddenExt() {
return this.$widget.hasClass("hidden-ext");
}
canBeShown() {
return !this.isHiddenInt() && !this.isHiddenExt();
}
isVisible() {
return this.$widget.is(":visible");
}
getPosition() {
return this.position;
}
remove() {
if (this.$widget) {
this.$widget.remove();
}
}
getClosestNtxId() {
if (this.$widget) {
return this.$widget.closest("[data-ntx-id]").attr("data-ntx-id");
} else {
return null;
}
}
cleanup() {}
}
/**
* This is the base widget for all other widgets.
*
* For information on using widgets, see the tutorial {@tutorial widget_basics}.
*/
export default class BasicWidget extends TypedBasicWidget<Component> {}

View File

@@ -0,0 +1,78 @@
import FlexContainer from "./containers/flex_container.js";
import OpenNoteButtonWidget from "./buttons/open_note_button_widget.js";
import BookmarkFolderWidget from "./buttons/bookmark_folder.js";
import froca from "../services/froca.js";
import utils from "../services/utils.js";
import type { EventData } from "../components/app_context.js";
import type Component from "../components/component.js";
interface BookmarkButtonsSettings {
titlePlacement?: string;
}
export default class BookmarkButtons extends FlexContainer<Component> {
private settings: BookmarkButtonsSettings;
private noteIds: string[];
constructor(isHorizontalLayout: boolean) {
super(isHorizontalLayout ? "row" : "column");
this.contentSized();
this.settings = {};
this.noteIds = [];
}
async refresh(): Promise<void> {
this.$widget.empty();
this.children = [];
this.noteIds = [];
const bookmarkParentNote = await froca.getNote("_lbBookmarks");
if (!bookmarkParentNote) {
return;
}
for (const note of await bookmarkParentNote.getChildNotes()) {
this.noteIds.push(note.noteId);
let buttonWidget: OpenNoteButtonWidget | BookmarkFolderWidget = note.isLabelTruthy("bookmarkFolder")
? new BookmarkFolderWidget(note)
: new OpenNoteButtonWidget(note).class("launcher-button");
if (this.settings.titlePlacement) {
if (!("settings" in buttonWidget)) {
(buttonWidget as any).settings = {};
}
(buttonWidget as any).settings.titlePlacement = this.settings.titlePlacement;
}
this.child(buttonWidget);
this.$widget.append(buttonWidget.render());
buttonWidget.refreshIcon();
}
utils.reloadTray();
}
initialRenderCompleteEvent(): void {
this.refresh();
}
entitiesReloadedEvent({ loadResults }: EventData<"entitiesReloaded">): void {
if (loadResults.getBranchRows().find((branch) => branch.parentNoteId === "_lbBookmarks")) {
this.refresh();
}
if (loadResults.getAttributeRows().find((attr) =>
attr.type === "label" &&
attr.name && ["iconClass", "workspaceIconClass", "bookmarkFolder"].includes(attr.name) &&
attr.noteId && this.noteIds.includes(attr.noteId)
)) {
this.refresh();
}
}
}

View File

@@ -0,0 +1,54 @@
import SwitchWidget from "./switch.js";
import server from "../services/server.js";
import toastService from "../services/toast.js";
import { t } from "../services/i18n.js";
import type FNote from "../entities/fnote.js";
import type { EventData } from "../components/app_context.js";
// TODO: Deduplicate
type Response = {
success: true;
} | {
success: false;
message: string;
}
export default class BookmarkSwitchWidget extends SwitchWidget {
isEnabled() {
return (
super.isEnabled() &&
// it's not possible to bookmark root because that would clone it under bookmarks and thus create a cycle
!["root", "_hidden"].includes(this.noteId ?? "")
);
}
doRender() {
super.doRender();
this.switchOnName = t("bookmark_switch.bookmark");
this.switchOnTooltip = t("bookmark_switch.bookmark_this_note");
this.switchOffName = t("bookmark_switch.bookmark");
this.switchOffTooltip = t("bookmark_switch.remove_bookmark");
}
async toggle(state: boolean | null | undefined) {
const resp = await server.put<Response>(`notes/${this.noteId}/toggle-in-parent/_lbBookmarks/${!!state}`);
if (!resp.success) {
toastService.showError(resp.message);
}
}
async refreshWithNote(note: FNote) {
const isBookmarked = !!note.getParentBranches().find((b) => b.parentNoteId === "_lbBookmarks");
this.isToggled = isBookmarked;
}
entitiesReloadedEvent({ loadResults }: EventData<"entitiesReloaded">) {
if (loadResults.getBranchRows().find((b) => b.noteId === this.noteId)) {
this.refresh();
}
}
}

View File

@@ -0,0 +1,74 @@
import { t } from "../../services/i18n.js";
import server from "../../services/server.js";
import ws from "../../services/ws.js";
import utils from "../../services/utils.js";
import type FAttribute from "../../entities/fattribute.js";
interface ActionDefinition {
script: string;
relationName: string;
targetNoteId: string;
targetParentNoteId: string;
oldRelationName?: string;
newRelationName?: string;
newTitle?: string;
labelName?: string;
labelValue?: string;
oldLabelName?: string;
newLabelName?: string;
}
export default abstract class AbstractBulkAction {
attribute: FAttribute;
actionDef: ActionDefinition;
constructor(attribute: FAttribute, actionDef: ActionDefinition) {
this.attribute = attribute;
this.actionDef = actionDef;
}
render() {
try {
const $rendered = this.doRender();
$rendered
.find(".action-conf-del")
.on("click", () => this.deleteAction())
.attr("title", t("abstract_bulk_action.remove_this_search_action"));
utils.initHelpDropdown($rendered);
return $rendered;
} catch (e: any) {
logError(`Failed rendering search action: ${JSON.stringify(this.attribute.dto)} with error: ${e.message} ${e.stack}`);
return null;
}
}
// to be overridden
abstract doRender(): JQuery<HTMLElement>;
static get actionName() {
return "";
}
async saveAction(data: {}) {
const actionObject = Object.assign({ name: (this.constructor as typeof AbstractBulkAction).actionName }, data);
await server.put(`notes/${this.attribute.noteId}/attribute`, {
attributeId: this.attribute.attributeId,
type: "label",
name: "action",
value: JSON.stringify(actionObject)
});
await ws.waitForMaxKnownEntityChangeId();
}
async deleteAction() {
await server.remove(`notes/${this.attribute.noteId}/attributes/${this.attribute.attributeId}`);
await ws.waitForMaxKnownEntityChangeId();
//await this.triggerCommand('refreshSearchDefinition');
}
}

View File

@@ -0,0 +1,58 @@
import { t } from "../../services/i18n.js";
import SpacedUpdate from "../../services/spaced_update.js";
import AbstractBulkAction from "./abstract_bulk_action.js";
const TPL = /*html*/`
<tr>
<td>
${t("execute_script.execute_script")}
</td>
<td>
<input type="text"
class="form-control script"
placeholder="note.title = note.title + '- suffix';"/>
</td>
<td class="button-column">
<div style="display: flex; align-items: center; justify-content: space-between;">
<div class="dropdown help-dropdown">
<span class="bx bx-help-circle icon-action" data-bs-toggle="dropdown" aria-haspopup="true" aria-expanded="false"></span>
<div class="dropdown-menu dropdown-menu-right p-4">
${t("execute_script.help_text")}
${t("execute_script.example_1")}
<pre>note.title = note.title + ' - suffix';</pre>
${t("execute_script.example_2")}
<pre>for (const attr of note.getOwnedAttributes) { attr.markAsDeleted(); }</pre>
</div>
</div>
<span class="bx bx-x icon-action action-conf-del"></span>
</div>
</td>
</tr>`;
export default class ExecuteScriptBulkAction extends AbstractBulkAction {
static get actionName() {
return "executeScript";
}
static get actionTitle() {
return t("execute_script.execute_script");
}
doRender() {
const $action = $(TPL);
const $script = $action.find(".script");
$script.val(this.actionDef.script || "");
const spacedUpdate = new SpacedUpdate(async () => {
await this.saveAction({ script: $script.val() });
}, 1000);
$script.on("input", () => spacedUpdate.scheduleUpdate());
return $action;
}
}

View File

@@ -0,0 +1,70 @@
import { t } from "../../../services/i18n.js";
import SpacedUpdate from "../../../services/spaced_update.js";
import AbstractBulkAction from "../abstract_bulk_action.js";
const TPL = /*html*/`
<tr>
<td colspan="2">
<div style="display: flex; align-items: center">
<div style="margin-right: 10px;" class="text-nowrap">${t("add_label.add_label")}</div>
<input type="text"
class="form-control label-name"
placeholder="${t("add_label.label_name_placeholder")}"
pattern="[\\p{L}\\p{N}_:]+"
title="${t("add_label.label_name_title")}"/>
<div style="margin-right: 10px; margin-left: 10px;" class="text-nowrap">${t("add_label.to_value")}</div>
<input type="text" class="form-control label-value" placeholder="${t("add_label.new_value_placeholder")}"/>
</div>
</td>
<td class="button-column">
<div class="dropdown help-dropdown">
<span class="bx bx-help-circle icon-action" data-bs-toggle="dropdown" aria-haspopup="true" aria-expanded="false"></span>
<div class="dropdown-menu dropdown-menu-right p-4">
<p>${t("add_label.help_text")}</p>
<ul>
<li>${t("add_label.help_text_item1")}</li>
<li>${t("add_label.help_text_item2")}</li>
</ul>
${t("add_label.help_text_note")}
</div>
</div>
<span class="bx bx-x icon-action action-conf-del"></span>
</td>
</tr>`;
export default class AddLabelBulkAction extends AbstractBulkAction {
static get actionName() {
return "addLabel";
}
static get actionTitle() {
return t("add_label.add_label");
}
doRender() {
const $action = $(TPL);
const $labelName = $action.find(".label-name");
$labelName.val(this.actionDef.labelName || "");
const $labelValue = $action.find(".label-value");
$labelValue.val(this.actionDef.labelValue || "");
const spacedUpdate = new SpacedUpdate(async () => {
await this.saveAction({
labelName: $labelName.val(),
labelValue: $labelValue.val()
});
}, 1000);
$labelName.on("input", () => spacedUpdate.scheduleUpdate());
$labelValue.on("input", () => spacedUpdate.scheduleUpdate());
return $action;
}
}

View File

@@ -0,0 +1,43 @@
import { t } from "../../../services/i18n.js";
import SpacedUpdate from "../../../services/spaced_update.js";
import AbstractBulkAction from "../abstract_bulk_action.js";
const TPL = /*html*/`
<tr>
<td>
${t("delete_label.delete_label")}
</td>
<td>
<input type="text"
class="form-control label-name"
pattern="[\\p{L}\\p{N}_:]+"
title="${t("delete_label.label_name_title")}"
placeholder="${t("delete_label.label_name_placeholder")}"/>
</td>
<td class="button-column">
<span class="bx bx-x icon-action action-conf-del"></span>
</td>
</tr>`;
export default class DeleteLabelBulkAction extends AbstractBulkAction {
static get actionName() {
return "deleteLabel";
}
static get actionTitle() {
return t("delete_label.delete_label");
}
doRender() {
const $action = $(TPL);
const $labelName = $action.find(".label-name");
$labelName.val(this.actionDef.labelName || "");
const spacedUpdate = new SpacedUpdate(async () => {
await this.saveAction({ labelName: $labelName.val() });
}, 1000);
$labelName.on("input", () => spacedUpdate.scheduleUpdate());
return $action;
}
}

View File

@@ -0,0 +1,60 @@
import { t } from "../../../services/i18n.js";
import SpacedUpdate from "../../../services/spaced_update.js";
import AbstractBulkAction from "../abstract_bulk_action.js";
const TPL = /*html*/`
<tr>
<td colspan="2">
<div style="display: flex; align-items: center">
<div style="margin-right: 10px; flex-shrink: 0;">${t("rename_label.rename_label_from")}</div>
<input type="text"
class="form-control old-label-name"
placeholder="${t("rename_label.old_name_placeholder")}"
pattern="[\\p{L}\\p{N}_:]+"
title="${t("rename_label.name_title")}"/>
<div style="margin-right: 10px; margin-left: 10px;" class="text-nowrap">${t("rename_label.to")}</div>
<input type="text"
class="form-control new-label-name"
placeholder="${t("rename_label.new_name_placeholder")}"
pattern="[\\p{L}\\p{N}_:]+"
title="${t("rename_label.name_title")}"/>
</div>
</td>
<td class="button-column">
<span class="bx bx-x icon-action action-conf-del"></span>
</td>
</tr>`;
export default class RenameLabelBulkAction extends AbstractBulkAction {
static get actionName() {
return "renameLabel";
}
static get actionTitle() {
return t("rename_label.rename_label");
}
doRender() {
const $action = $(TPL);
const $oldLabelName = $action.find(".old-label-name");
$oldLabelName.val(this.actionDef.oldLabelName || "");
const $newLabelName = $action.find(".new-label-name");
$newLabelName.val(this.actionDef.newLabelName || "");
const spacedUpdate = new SpacedUpdate(async () => {
await this.saveAction({
oldLabelName: $oldLabelName.val(),
newLabelName: $newLabelName.val()
});
}, 1000);
$oldLabelName.on("input", () => spacedUpdate.scheduleUpdate());
$newLabelName.on("input", () => spacedUpdate.scheduleUpdate());
return $action;
}
}

View File

@@ -0,0 +1,65 @@
import { t } from "../../../services/i18n.js";
import SpacedUpdate from "../../../services/spaced_update.js";
import AbstractBulkAction from "../abstract_bulk_action.js";
const TPL = /*html*/`
<tr>
<td colspan="2">
<div style="display: flex; align-items: center">
<div style="margin-right: 10px;" class="text-nowrap">${t("update_label_value.update_label_value")}</div>
<input type="text"
class="form-control label-name"
placeholder="${t("update_label_value.label_name_placeholder")}"
pattern="[\\p{L}\\p{N}_:]+"
title="${t("update_label_value.label_name_title")}"/>
<div style="margin-right: 10px; margin-left: 10px;" class="text-nowrap">${t("update_label_value.to_value")}</div>
<input type="text" class="form-control label-value" placeholder="${t("update_label_value.new_value_placeholder")}"/>
</div>
</td>
<td class="button-column">
<div class="dropdown help-dropdown">
<span class="bx bx-help-circle icon-action" data-bs-toggle="dropdown" aria-haspopup="true" aria-expanded="false"></span>
<div class="dropdown-menu dropdown-menu-right p-4">
<p>${t("update_label_value.help_text")}</p>
${t("update_label_value.help_text_note")}
</div>
</div>
<span class="bx bx-x icon-action action-conf-del"></span>
</td>
</tr>`;
export default class UpdateLabelValueBulkAction extends AbstractBulkAction {
static get actionName() {
return "updateLabelValue";
}
static get actionTitle() {
return t("update_label_value.update_label_value");
}
doRender() {
const $action = $(TPL);
const $labelName = $action.find(".label-name");
$labelName.val(this.actionDef.labelName || "");
const $labelValue = $action.find(".label-value");
$labelValue.val(this.actionDef.labelValue || "");
const spacedUpdate = new SpacedUpdate(async () => {
await this.saveAction({
labelName: $labelName.val(),
labelValue: $labelValue.val()
});
}, 1000);
$labelName.on("input", () => spacedUpdate.scheduleUpdate());
$labelValue.on("input", () => spacedUpdate.scheduleUpdate());
return $action;
}
}

View File

@@ -0,0 +1,38 @@
import { t } from "../../../services/i18n.js";
import AbstractBulkAction from "../abstract_bulk_action.js";
const TPL = /*html*/`
<tr>
<td colspan="2">
<span class="bx bx-trash"></span>
${t("delete_note.delete_matched_notes")}
</td>
<td class="button-column">
<div class="dropdown help-dropdown">
<span class="bx bx-help-circle icon-action" data-bs-toggle="dropdown" aria-haspopup="true" aria-expanded="false"></span>
<div class="dropdown-menu dropdown-menu-right p-4">
<p>${t("delete_note.delete_matched_notes_description")}</p>
<p>${t("delete_note.undelete_notes_instruction")}</p>
${t("delete_note.erase_notes_instruction")}
</div>
</div>
<span class="bx bx-x icon-action action-conf-del"></span>
</td>
</tr>`;
export default class DeleteNoteBulkAction extends AbstractBulkAction {
static get actionName() {
return "deleteNote";
}
static get actionTitle() {
return t("delete_note.delete_note");
}
doRender() {
return $(TPL);
}
}

View File

@@ -0,0 +1,32 @@
import { t } from "../../../services/i18n.js";
import AbstractBulkAction from "../abstract_bulk_action.js";
const TPL = /*html*/`
<tr>
<td colspan="2">
<span class="bx bx-trash"></span>
${t("delete_revisions.delete_note_revisions")}
</td>
<td class="button-column">
<div class="dropdown help-dropdown">
<span class="bx bx-help-circle icon-action" data-bs-toggle="dropdown" aria-haspopup="true" aria-expanded="false"></span>
<div class="dropdown-menu dropdown-menu-right p-4">
${t("delete_revisions.all_past_note_revisions")}
</div>
</div>
<span class="bx bx-x icon-action action-conf-del"></span>
</td>
</tr>`;
export default class DeleteRevisionsBulkAction extends AbstractBulkAction {
static get actionName() {
return "deleteRevisions";
}
static get actionTitle() {
return t("delete_revisions.delete_note_revisions");
}
doRender() {
return $(TPL);
}
}

View File

@@ -0,0 +1,64 @@
import { t } from "../../../services/i18n.js";
import SpacedUpdate from "../../../services/spaced_update.js";
import AbstractBulkAction from "../abstract_bulk_action.js";
import noteAutocompleteService from "../../../services/note_autocomplete.js";
const TPL = /*html*/`
<tr>
<td colspan="2">
<div style="display: flex; align-items: center">
<div style="margin-right: 10px;" class="text-nowrap">${t("move_note.move_note")}</div>
<div style="margin-right: 10px;" class="text-nowrap">${t("move_note.to")}</div>
<div class="input-group">
<input type="text" class="form-control target-parent-note" placeholder="${t("move_note.target_parent_note")}"/>
</div>
</div>
</td>
<td class="button-column">
<div class="dropdown help-dropdown">
<span class="bx bx-help-circle icon-action" data-bs-toggle="dropdown" aria-haspopup="true" aria-expanded="false"></span>
<div class="dropdown-menu dropdown-menu-right p-4">
<p>${t("move_note.on_all_matched_notes")}:</p>
<ul style="margin-bottom: 0;">
<li>${t("move_note.move_note_new_parent")}</li>
<li>${t("move_note.clone_note_new_parent")}</li>
<li>${t("move_note.nothing_will_happen")}</li>
</ul>
</div>
</div>
<span class="bx bx-x icon-action action-conf-del"></span>
</td>
</tr>`;
export default class MoveNoteBulkAction extends AbstractBulkAction {
static get actionName() {
return "moveNote";
}
static get actionTitle() {
return t("move_note.move_note");
}
doRender() {
const $action = $(TPL);
const $targetParentNote = $action.find(".target-parent-note");
noteAutocompleteService.initNoteAutocomplete($targetParentNote);
$targetParentNote.setNote(this.actionDef.targetParentNoteId);
$targetParentNote.on("autocomplete:closed", () => spacedUpdate.scheduleUpdate());
const spacedUpdate = new SpacedUpdate(async () => {
await this.saveAction({
targetParentNoteId: $targetParentNote.getSelectedNoteId()
});
}, 1000);
$targetParentNote.on("input", () => spacedUpdate.scheduleUpdate());
return $action;
}
}

View File

@@ -0,0 +1,61 @@
import SpacedUpdate from "../../../services/spaced_update.js";
import AbstractBulkAction from "../abstract_bulk_action.js";
import { t } from "../../../services/i18n.js";
const TPL = /*html*/`
<tr>
<td colspan="2">
<div style="display: flex; align-items: center">
<div style="margin-right: 10px; flex-shrink: 0;">${t("rename_note.rename_note_title_to")}</div>
<input type="text"
class="form-control new-title"
placeholder="${t("rename_note.new_note_title")}"
title="${t("rename_note.click_help_icon")}"/>
</div>
</td>
<td class="button-column">
<div class="dropdown help-dropdown">
<span class="bx bx-help-circle icon-action" data-bs-toggle="dropdown" aria-haspopup="true" aria-expanded="false"></span>
<div class="dropdown-menu dropdown-menu-right p-4">
<p>${t("rename_note.evaluated_as_js_string")}</p>
<ul>
<li>${t("rename_note.example_note")}</li>
<li>${t("rename_note.example_new_title")}</li>
<li>${t("rename_note.example_date_prefix")}</li>
</ul>
${t("rename_note.api_docs")}
</div>
</div>
<span class="bx bx-x icon-action action-conf-del"></span>
</td>
</tr>`;
export default class RenameNoteBulkAction extends AbstractBulkAction {
static get actionName() {
return "renameNote";
}
static get actionTitle() {
return t("rename_note.rename_note");
}
doRender() {
const $action = $(TPL);
const $newTitle = $action.find(".new-title");
$newTitle.val(this.actionDef.newTitle || "");
const spacedUpdate = new SpacedUpdate(async () => {
await this.saveAction({
newTitle: $newTitle.val()
});
}, 1000);
$newTitle.on("input", () => spacedUpdate.scheduleUpdate());
return $action;
}
}

View File

@@ -0,0 +1,70 @@
import SpacedUpdate from "../../../services/spaced_update.js";
import AbstractBulkAction from "../abstract_bulk_action.js";
import noteAutocompleteService from "../../../services/note_autocomplete.js";
import { t } from "../../../services/i18n.js";
const TPL = /*html*/`
<tr>
<td colspan="2">
<div style="display: flex; align-items: center">
<div style="margin-right: 10px;" class="text-nowrap">${t("add_relation.add_relation")}</div>
<input type="text"
class="form-control relation-name"
placeholder="${t("add_relation.relation_name")}"
pattern="[\\p{L}\\p{N}_:]+"
style="flex-shrink: 3"
title="${t("add_relation.allowed_characters")}"/>
<div style="margin-right: 10px; margin-left: 10px;" class="text-nowrap">${t("add_relation.to")}</div>
<div class="input-group" style="flex-shrink: 2">
<input type="text" class="form-control target-note" placeholder="${t("add_relation.target_note")}"/>
</div>
</div>
</td>
<td class="button-column">
<div class="dropdown help-dropdown">
<span class="bx bx-help-circle icon-action" data-bs-toggle="dropdown" aria-haspopup="true" aria-expanded="false"></span>
<div class="dropdown-menu dropdown-menu-right p-4">
${t("add_relation.create_relation_on_all_matched_notes")}
</div>
</div>
<span class="bx bx-x icon-action action-conf-del"></span>
</td>
</tr>`;
export default class AddRelationBulkAction extends AbstractBulkAction {
static get actionName() {
return "addRelation";
}
static get actionTitle() {
return t("add_relation.add_relation");
}
doRender() {
const $action = $(TPL);
const $relationName = $action.find(".relation-name");
$relationName.val(this.actionDef.relationName || "");
const $targetNote = $action.find(".target-note");
noteAutocompleteService.initNoteAutocomplete($targetNote);
$targetNote.setNote(this.actionDef.targetNoteId);
$targetNote.on("autocomplete:closed", () => spacedUpdate.scheduleUpdate());
const spacedUpdate = new SpacedUpdate(async () => {
await this.saveAction({
relationName: $relationName.val(),
targetNoteId: $targetNote.getSelectedNoteId()
});
}, 1000);
$relationName.on("input", () => spacedUpdate.scheduleUpdate());
$targetNote.on("input", () => spacedUpdate.scheduleUpdate());
return $action;
}
}

View File

@@ -0,0 +1,45 @@
import SpacedUpdate from "../../../services/spaced_update.js";
import AbstractBulkAction from "../abstract_bulk_action.js";
import { t } from "../../../services/i18n.js";
const TPL = /*html*/`
<tr>
<td>
${t("delete_relation.delete_relation")}
</td>
<td>
<div style="display: flex; align-items: center">
<input type="text"
class="form-control relation-name"
pattern="[\\p{L}\\p{N}_:]+"
placeholder="${t("delete_relation.relation_name")}"
title="${t("delete_relation.allowed_characters")}"/>
</div>
</td>
<td class="button-column">
<span class="bx bx-x icon-action action-conf-del"></span>
</td>
</tr>`;
export default class DeleteRelationBulkAction extends AbstractBulkAction {
static get actionName() {
return "deleteRelation";
}
static get actionTitle() {
return t("delete_relation.delete_relation");
}
doRender() {
const $action = $(TPL);
const $relationName = $action.find(".relation-name");
$relationName.val(this.actionDef.relationName || "");
const spacedUpdate = new SpacedUpdate(async () => {
await this.saveAction({ relationName: $relationName.val() });
}, 1000);
$relationName.on("input", () => spacedUpdate.scheduleUpdate());
return $action;
}
}

View File

@@ -0,0 +1,60 @@
import SpacedUpdate from "../../../services/spaced_update.js";
import AbstractBulkAction from "../abstract_bulk_action.js";
import { t } from "../../../services/i18n.js";
const TPL = /*html*/`
<tr>
<td colspan="2">
<div style="display: flex; align-items: center">
<div style="margin-right: 10px; flex-shrink: 0;">${t("rename_relation.rename_relation_from")}</div>
<input type="text"
class="form-control old-relation-name"
placeholder="${t("rename_relation.old_name")}"
pattern="[\\p{L}\\p{N}_:]+"
title="${t("rename_relation.allowed_characters")}"/>
<div style="margin-right: 10px; margin-left: 10px;" class="text-nowrap">${t("rename_relation.to")}</div>
<input type="text"
class="form-control new-relation-name"
placeholder="${t("rename_relation.new_name")}"
pattern="[\\p{L}\\p{N}_:]+"
title="${t("rename_relation.allowed_characters")}"/>
</div>
</td>
<td class="button-column">
<span class="bx bx-x icon-action action-conf-del"></span>
</td>
</tr>`;
export default class RenameRelationBulkAction extends AbstractBulkAction {
static get actionName() {
return "renameRelation";
}
static get actionTitle() {
return t("rename_relation.rename_relation");
}
doRender() {
const $action = $(TPL);
const $oldRelationName = $action.find(".old-relation-name");
$oldRelationName.val(this.actionDef.oldRelationName || "");
const $newRelationName = $action.find(".new-relation-name");
$newRelationName.val(this.actionDef.newRelationName || "");
const spacedUpdate = new SpacedUpdate(async () => {
await this.saveAction({
oldRelationName: $oldRelationName.val(),
newRelationName: $newRelationName.val()
});
}, 1000);
$oldRelationName.on("input", () => spacedUpdate.scheduleUpdate());
$newRelationName.on("input", () => spacedUpdate.scheduleUpdate());
return $action;
}
}

View File

@@ -0,0 +1,74 @@
import SpacedUpdate from "../../../services/spaced_update.js";
import AbstractBulkAction from "../abstract_bulk_action.js";
import noteAutocompleteService from "../../../services/note_autocomplete.js";
import { t } from "../../../services/i18n.js";
const TPL = /*html*/`
<tr>
<td colspan="2">
<div style="display: flex; align-items: center">
<div style="margin-right: 10px;" class="text-nowrap">${t("update_relation_target.update_relation")}</div>
<input type="text"
class="form-control relation-name"
placeholder="${t("update_relation_target.relation_name")}"
pattern="[\\p{L}\\p{N}_:]+"
style="flex-shrink: 3"
title="${t("update_relation_target.allowed_characters")}"/>
<div style="margin-right: 10px; margin-left: 10px;" class="text-nowrap">${t("update_relation_target.to")}</div>
<div class="input-group" style="flex-shrink: 2">
<input type="text" class="form-control target-note" placeholder="${t("update_relation_target.target_note")}"/>
</div>
</div>
</td>
<td class="button-column">
<div class="dropdown help-dropdown">
<span class="bx bx-help-circle icon-action" data-bs-toggle="dropdown" aria-haspopup="true" aria-expanded="false"></span>
<div class="dropdown-menu dropdown-menu-right p-4">
<p>${t("update_relation_target.on_all_matched_notes")}:</p>
<ul style="margin-bottom: 0;">
<li>${t("update_relation_target.change_target_note")}</li>
</ul>
</div>
</div>
<span class="bx bx-x icon-action action-conf-del"></span>
</td>
</tr>`;
export default class UpdateRelationTargetBulkAction extends AbstractBulkAction {
static get actionName() {
return "updateRelationTarget";
}
static get actionTitle() {
return t("update_relation_target.update_relation_target");
}
doRender() {
const $action = $(TPL);
const $relationName = $action.find(".relation-name");
$relationName.val(this.actionDef.relationName || "");
const $targetNote = $action.find(".target-note");
noteAutocompleteService.initNoteAutocomplete($targetNote);
$targetNote.setNote(this.actionDef.targetNoteId);
$targetNote.on("autocomplete:closed", () => spacedUpdate.scheduleUpdate());
const spacedUpdate = new SpacedUpdate(async () => {
await this.saveAction({
relationName: $relationName.val(),
targetNoteId: $targetNote.getSelectedNoteId()
});
}, 1000);
$relationName.on("input", () => spacedUpdate.scheduleUpdate());
$targetNote.on("input", () => spacedUpdate.scheduleUpdate());
return $action;
}
}

View File

@@ -0,0 +1,94 @@
import { Tooltip } from "bootstrap";
import NoteContextAwareWidget from "../note_context_aware_widget.js";
const TPL = /*html*/`<button class="button-widget bx"
data-bs-toggle="tooltip"
title=""></button>`;
type TitlePlacement = "top" | "bottom" | "left" | "right";
type StringOrCallback = string | (() => string);
type ContextMenuHandler = (e: JQuery.ContextMenuEvent<any, any, any, any> | null) => void;
export interface AbstractButtonWidgetSettings {
titlePlacement: TitlePlacement;
title: StringOrCallback | null;
icon: StringOrCallback | null;
onContextMenu: ContextMenuHandler | null;
}
export default class AbstractButtonWidget<SettingsT extends AbstractButtonWidgetSettings> extends NoteContextAwareWidget {
protected settings!: SettingsT;
protected tooltip!: Tooltip;
isEnabled(): boolean | null | undefined {
return true;
}
doRender() {
this.$widget = $(TPL);
this.tooltip = new Tooltip(this.$widget[0], {
html: true,
// in case getTitle() returns null -> use empty string as fallback
title: () => this.getTitle() || "",
trigger: "hover",
placement: this.settings.titlePlacement,
fallbackPlacements: [this.settings.titlePlacement]
});
if (this.settings.onContextMenu) {
this.$widget.on("contextmenu", (e) => {
this.tooltip.hide();
if (this.settings.onContextMenu) {
this.settings.onContextMenu(e);
}
return false; // blocks default browser right click menu
});
}
super.doRender();
}
getTitle() {
return typeof this.settings.title === "function" ? this.settings.title() : this.settings.title;
}
refreshIcon() {
for (const className of this.$widget[0].classList) {
if (className.startsWith("bx-")) {
this.$widget.removeClass(className);
}
}
const icon = typeof this.settings.icon === "function" ? this.settings.icon() : this.settings.icon;
if (icon) {
this.$widget.addClass(icon);
}
}
initialRenderCompleteEvent() {
this.refreshIcon();
}
icon(icon: StringOrCallback) {
this.settings.icon = icon;
return this;
}
title(title: StringOrCallback) {
this.settings.title = title;
return this;
}
titlePlacement(placement: TitlePlacement) {
this.settings.titlePlacement = placement;
return this;
}
onContextMenu(handler: ContextMenuHandler) {
this.settings.onContextMenu = handler;
return this;
}
}

View File

@@ -0,0 +1,26 @@
import type { EventData } from "../../components/app_context.js";
import type FNote from "../../entities/fnote.js";
import options from "../../services/options.js";
import CommandButtonWidget from "./command_button.js";
export default class AiChatButton extends CommandButtonWidget {
constructor(note: FNote) {
super();
this.command("createAiChat")
.title(() => note.title)
.icon(() => note.getIcon())
.class("launcher-button");
}
isEnabled() {
return options.get("aiEnabled") === "true";
}
entitiesReloadedEvent({ loadResults }: EventData<"entitiesReloaded">) {
if (loadResults.isOptionReloaded("aiEnabled")) {
this.refresh();
}
}
}

View File

@@ -0,0 +1,190 @@
import { t } from "../../services/i18n.js";
import BasicWidget from "../basic_widget.js";
import server from "../../services/server.js";
import dialogService from "../../services/dialog.js";
import toastService from "../../services/toast.js";
import ws from "../../services/ws.js";
import appContext from "../../components/app_context.js";
import openService from "../../services/open.js";
import utils from "../../services/utils.js";
import { Dropdown } from "bootstrap";
import type attachmentsApiRoute from "../../../../routes/api/attachments.js";
import type FAttachment from "../../entities/fattachment.js";
import type AttachmentDetailWidget from "../attachment_detail.js";
const TPL = /*html*/`
<div class="dropdown attachment-actions">
<style>
.attachment-actions {
width: 35px;
height: 35px;
}
.attachment-actions .dropdown-menu {
width: 20em;
}
.attachment-actions .dropdown-item .bx {
position: relative;
top: 3px;
font-size: 120%;
margin-right: 5px;
}
.attachment-actions .dropdown-item[disabled], .attachment-actions .dropdown-item[disabled]:hover {
color: var(--muted-text-color) !important;
background-color: transparent !important;
pointer-events: none; /* makes it unclickable */
}
</style>
<button type="button" data-bs-toggle="dropdown" aria-haspopup="true"
aria-expanded="false" class="icon-action icon-action-always-border bx bx-dots-vertical-rounded"
style="position: relative; top: 3px;"></button>
<div class="dropdown-menu dropdown-menu-right">
<li data-trigger-command="openAttachment" class="dropdown-item"
title="${t("attachments_actions.open_externally_title")}"><span class="bx bx-file-find"></span> ${t("attachments_actions.open_externally")}</li>
<li data-trigger-command="openAttachmentCustom" class="dropdown-item"
title="${t("attachments_actions.open_custom_title")}"><span class="bx bx-customize"></span> ${t("attachments_actions.open_custom")}</li>
<li data-trigger-command="downloadAttachment" class="dropdown-item">
<span class="bx bx-download"></span> ${t("attachments_actions.download")}</li>
<li data-trigger-command="copyAttachmentLinkToClipboard" class="dropdown-item"><span class="bx bx-link">
</span> ${t("attachments_actions.copy_link_to_clipboard")}</li>
<div class="dropdown-divider"></div>
<li data-trigger-command="uploadNewAttachmentRevision" class="dropdown-item"><span class="bx bx-upload">
</span> ${t("attachments_actions.upload_new_revision")}</li>
<li data-trigger-command="renameAttachment" class="dropdown-item">
<span class="bx bx-rename"></span> ${t("attachments_actions.rename_attachment")}</li>
<li data-trigger-command="deleteAttachment" class="dropdown-item">
<span class="bx bx-trash destructive-action-icon"></span> ${t("attachments_actions.delete_attachment")}</li>
<div class="dropdown-divider"></div>
<li data-trigger-command="convertAttachmentIntoNote" class="dropdown-item"><span class="bx bx-note">
</span> ${t("attachments_actions.convert_attachment_into_note")}</li>
</div>
<input type="file" class="attachment-upload-new-revision-input" style="display: none">
</div>`;
export default class AttachmentActionsWidget extends BasicWidget {
$uploadNewRevisionInput!: JQuery<HTMLInputElement>;
attachment: FAttachment;
isFullDetail: boolean;
dropdown!: Dropdown;
constructor(attachment: FAttachment, isFullDetail: boolean) {
super();
this.attachment = attachment;
this.isFullDetail = isFullDetail;
}
get attachmentId() {
return this.attachment.attachmentId;
}
doRender() {
this.$widget = $(TPL);
this.dropdown = Dropdown.getOrCreateInstance(this.$widget.find("[data-bs-toggle='dropdown']")[0]);
this.$widget.on("click", ".dropdown-item", () => this.dropdown.toggle());
this.$uploadNewRevisionInput = this.$widget.find(".attachment-upload-new-revision-input");
this.$uploadNewRevisionInput.on("change", async () => {
const fileToUpload = this.$uploadNewRevisionInput[0].files?.item(0); // copy to allow reset below
this.$uploadNewRevisionInput.val("");
if (fileToUpload) {
const result = await server.upload(`attachments/${this.attachmentId}/file`, fileToUpload);
if (result.uploaded) {
toastService.showMessage(t("attachments_actions.upload_success"));
} else {
toastService.showError(t("attachments_actions.upload_failed"));
}
}
});
const isElectron = utils.isElectron();
if (!this.isFullDetail) {
const $openAttachmentButton = this.$widget.find("[data-trigger-command='openAttachment']");
$openAttachmentButton.addClass("disabled").append($('<span class="bx bx-info-circle disabled-tooltip" />').attr("title", t("attachments_actions.open_externally_detail_page")));
if (isElectron) {
const $openAttachmentCustomButton = this.$widget.find("[data-trigger-command='openAttachmentCustom']");
$openAttachmentCustomButton.addClass("disabled").append($('<span class="bx bx-info-circle disabled-tooltip" />').attr("title", t("attachments_actions.open_externally_detail_page")));
}
}
if (!isElectron) {
const $openAttachmentCustomButton = this.$widget.find("[data-trigger-command='openAttachmentCustom']");
$openAttachmentCustomButton.addClass("disabled").append($('<span class="bx bx-info-circle disabled-tooltip" />').attr("title", t("attachments_actions.open_custom_client_only")));
}
}
async openAttachmentCommand() {
await openService.openAttachmentExternally(this.attachmentId, this.attachment.mime);
}
async openAttachmentCustomCommand() {
await openService.openAttachmentCustom(this.attachmentId, this.attachment.mime);
}
async downloadAttachmentCommand() {
await openService.downloadAttachment(this.attachmentId);
}
async uploadNewAttachmentRevisionCommand() {
this.$uploadNewRevisionInput.trigger("click");
}
async copyAttachmentLinkToClipboardCommand() {
if (this.parent && "copyAttachmentLinkToClipboard" in this.parent) {
(this.parent as AttachmentDetailWidget).copyAttachmentLinkToClipboard();
}
}
async deleteAttachmentCommand() {
if (!(await dialogService.confirm(t("attachments_actions.delete_confirm", { title: this.attachment.title })))) {
return;
}
await server.remove(`attachments/${this.attachmentId}`);
toastService.showMessage(t("attachments_actions.delete_success", { title: this.attachment.title }));
}
async convertAttachmentIntoNoteCommand() {
if (!(await dialogService.confirm(t("attachments_actions.convert_confirm", { title: this.attachment.title })))) {
return;
}
const { note: newNote } = await server.post<ReturnType<typeof attachmentsApiRoute.convertAttachmentToNote>>(`attachments/${this.attachmentId}/convert-to-note`);
toastService.showMessage(t("attachments_actions.convert_success", { title: this.attachment.title }));
await ws.waitForMaxKnownEntityChangeId();
await appContext.tabManager.getActiveContext()?.setNote(newNote.noteId);
}
async renameAttachmentCommand() {
const attachmentTitle = await dialogService.prompt({
title: t("attachments_actions.rename_attachment"),
message: t("attachments_actions.enter_new_name"),
defaultValue: this.attachment.title
});
if (!attachmentTitle?.trim()) {
return;
}
await server.put(`attachments/${this.attachmentId}/rename`, { title: attachmentTitle });
}
}

View File

@@ -0,0 +1,88 @@
import RightDropdownButtonWidget from "./right_dropdown_button.js";
import linkService from "../../services/link.js";
import utils from "../../services/utils.js";
import type FNote from "../../entities/fnote.js";
const DROPDOWN_TPL = `
<div class="bookmark-folder-widget">
<style>
.bookmark-folder-widget {
min-width: 400px;
max-height: 500px;
padding: 7px 15px 0 15px;
font-size: 1.2rem;
overflow: auto;
}
.bookmark-folder-widget ul {
padding: 0;
list-style-type: none;
}
.bookmark-folder-widget .note-link {
display: block;
padding: 5px 10px 5px 5px;
}
.bookmark-folder-widget .note-link:hover {
background-color: var(--accented-background-color);
text-decoration: none;
}
.dropdown-menu .bookmark-folder-widget a:hover {
text-decoration: none;
background: transparent !important;
}
.bookmark-folder-widget li .note-link {
padding-left: 35px;
}
</style>
<div class="parent-note"></div>
<ul class="children-notes"></ul>
</div>`;
interface LinkOptions {
showTooltip: boolean;
showNoteIcon: boolean;
}
export default class BookmarkFolderWidget extends RightDropdownButtonWidget {
private note: FNote;
private $parentNote!: JQuery<HTMLElement>;
private $childrenNotes!: JQuery<HTMLElement>;
declare $dropdownContent: JQuery<HTMLElement>;
constructor(note: FNote) {
super(utils.escapeHtml(note.title), note.getIcon(), DROPDOWN_TPL);
this.note = note;
}
doRender(): void {
super.doRender();
this.$parentNote = this.$dropdownContent.find(".parent-note");
this.$childrenNotes = this.$dropdownContent.find(".children-notes");
}
async dropdownShown(): Promise<void> {
this.$parentNote.empty();
this.$childrenNotes.empty();
const linkOptions: LinkOptions = {
showTooltip: false,
showNoteIcon: true
};
this.$parentNote.append((await linkService.createLink(this.note.noteId, linkOptions)).addClass("note-link"));
for (const childNote of await this.note.getChildNotes()) {
this.$childrenNotes.append($("<li>").append((await linkService.createLink(childNote.noteId, linkOptions)).addClass("note-link")));
}
}
refreshIcon(): void {}
}

View File

@@ -0,0 +1,63 @@
import froca from "../../services/froca.js";
import attributeService from "../../services/attributes.js";
import CommandButtonWidget from "./command_button.js";
import type { EventData } from "../../components/app_context.js";
export type ButtonNoteIdProvider = () => string;
export default class ButtonFromNoteWidget extends CommandButtonWidget {
constructor() {
super();
this.settings.buttonNoteIdProvider = null;
}
buttonNoteIdProvider(provider: ButtonNoteIdProvider) {
this.settings.buttonNoteIdProvider = provider;
return this;
}
doRender() {
super.doRender();
this.updateIcon();
}
updateIcon() {
if (!this.settings.buttonNoteIdProvider) {
console.error(`buttonNoteId for '${this.componentId}' is not defined.`);
return;
}
const buttonNoteId = this.settings.buttonNoteIdProvider();
if (!buttonNoteId) {
console.error(`buttonNoteId for '${this.componentId}' is not defined.`);
return;
}
froca.getNote(buttonNoteId).then((note) => {
const icon = note?.getIcon();
if (icon) {
this.settings.icon = icon;
}
this.refreshIcon();
});
}
entitiesReloadedEvent({ loadResults }: EventData<"entitiesReloaded">) {
// TODO: this seems incorrect
//@ts-ignore
const buttonNote = froca.getNoteFromCache(this.buttonNoteIdProvider());
if (!buttonNote) {
return;
}
if (loadResults.getAttributeRows(this.componentId).find((attr) => attr.type === "label" && attr.name === "iconClass" && attributeService.isAffecting(attr, buttonNote))) {
this.updateIcon();
}
}
}

View File

@@ -0,0 +1,490 @@
import { t } from "../../services/i18n.js";
import dateNoteService from "../../services/date_notes.js";
import server from "../../services/server.js";
import appContext from "../../components/app_context.js";
import RightDropdownButtonWidget from "./right_dropdown_button.js";
import toastService from "../../services/toast.js";
import options from "../../services/options.js";
import { Dropdown } from "bootstrap";
import type { EventData } from "../../components/app_context.js";
import dayjs, { Dayjs } from "dayjs";
import utc from "dayjs/plugin/utc.js";
import isSameOrAfter from "dayjs/plugin/isSameOrAfter.js";
import type BAttribute from "../../../../becca/entities/battribute.js";
import "../../../stylesheets/calendar.css";
dayjs.extend(utc);
dayjs.extend(isSameOrAfter);
const MONTHS = [
t("calendar.january"),
t("calendar.febuary"),
t("calendar.march"),
t("calendar.april"),
t("calendar.may"),
t("calendar.june"),
t("calendar.july"),
t("calendar.august"),
t("calendar.september"),
t("calendar.october"),
t("calendar.november"),
t("calendar.december")
];
const DROPDOWN_TPL = `
<div class="calendar-dropdown-widget">
<style>
.calendar-dropdown-widget {
width: 400px;
}
</style>
<div class="calendar-header">
<div class="calendar-month-selector">
<button class="calendar-btn tn-tool-button bx bx-chevron-left" data-calendar-toggle="previous"></button>
<button class="btn dropdown-toggle select-button" type="button"
data-bs-toggle="dropdown" data-bs-auto-close="true"
aria-expanded="false"
data-calendar-input="month"></button>
<ul class="dropdown-menu" data-calendar-input="month-list">
${Object.entries(MONTHS)
.map(([i, month]) => `<li><button class="dropdown-item" data-value=${i}>${month}</button></li>`)
.join("")}
</ul>
<button class="calendar-btn tn-tool-button bx bx-chevron-right" data-calendar-toggle="next"></button>
</div>
<div class="calendar-year-selector">
<button class="calendar-btn tn-tool-button bx bx-chevron-left" data-calendar-toggle="previousYear"></button>
<input type="number" min="1900" max="2999" step="1" data-calendar-input="year" />
<button class="calendar-btn tn-tool-button bx bx-chevron-right" data-calendar-toggle="nextYear"></button>
</div>
</div>
<div class="calendar-week"></div>
<div class="calendar-body" data-calendar-area="month"></div>
</div>`;
const DAYS_OF_WEEK = [t("calendar.sun"), t("calendar.mon"), t("calendar.tue"), t("calendar.wed"), t("calendar.thu"), t("calendar.fri"), t("calendar.sat")];
interface DateNotesForMonth {
[date: string]: string;
}
interface WeekCalculationOptions {
firstWeekType: number;
minDaysInFirstWeek: number;
}
export default class CalendarWidget extends RightDropdownButtonWidget {
private $month!: JQuery<HTMLElement>;
private $weekHeader!: JQuery<HTMLElement>;
private $monthSelect!: JQuery<HTMLElement>;
private $yearSelect!: JQuery<HTMLElement>;
private $next!: JQuery<HTMLElement>;
private $previous!: JQuery<HTMLElement>;
private $nextYear!: JQuery<HTMLElement>;
private $previousYear!: JQuery<HTMLElement>;
private monthDropdown!: Dropdown;
private firstDayOfWeek!: number;
private weekCalculationOptions!: WeekCalculationOptions;
private activeDate: Dayjs | null = null;
private todaysDate!: Dayjs;
private date!: Dayjs;
private weekNoteEnable: boolean = false;
private weekNotes: string[] = [];
constructor(title: string = "", icon: string = "") {
super(title, icon, DROPDOWN_TPL);
}
doRender() {
super.doRender();
this.$month = this.$dropdownContent.find('[data-calendar-area="month"]');
this.$weekHeader = this.$dropdownContent.find(".calendar-week");
this.manageFirstDayOfWeek();
this.initWeekCalculation();
// Month navigation
this.$monthSelect = this.$dropdownContent.find('[data-calendar-input="month"]');
this.$monthSelect.on("show.bs.dropdown", (e) => {
// Don't trigger dropdownShown() at widget level when the month selection dropdown is shown, since it would cause a redundant refresh.
e.stopPropagation();
});
this.monthDropdown = Dropdown.getOrCreateInstance(this.$monthSelect[0]);
this.$dropdownContent.find('[data-calendar-input="month-list"] button').on("click", (e) => {
const target = e.target as HTMLElement;
const value = target.dataset.value;
if (value) {
this.date = this.date.month(parseInt(value));
this.createMonth();
}
});
this.$next = this.$dropdownContent.find('[data-calendar-toggle="next"]');
this.$next.on("click", () => {
this.date = this.date.add(1, 'month');
this.createMonth();
});
this.$previous = this.$dropdownContent.find('[data-calendar-toggle="previous"]');
this.$previous.on("click", () => {
this.date = this.date.subtract(1, 'month');
this.createMonth();
});
// Year navigation
this.$yearSelect = this.$dropdownContent.find('[data-calendar-input="year"]');
this.$yearSelect.on("input", (e) => {
const target = e.target as HTMLInputElement;
this.date = this.date.year(parseInt(target.value));
this.createMonth();
});
this.$nextYear = this.$dropdownContent.find('[data-calendar-toggle="nextYear"]');
this.$nextYear.on("click", () => {
this.date = this.date.add(1, 'year');
this.createMonth();
});
this.$previousYear = this.$dropdownContent.find('[data-calendar-toggle="previousYear"]');
this.$previousYear.on("click", () => {
this.date = this.date.subtract(1, 'year');
this.createMonth();
});
this.$dropdownContent.on("click", ".calendar-date", async (ev) => {
const date = $(ev.target).closest(".calendar-date").attr("data-calendar-date");
if (date) {
const note = await dateNoteService.getDayNote(date);
if (note) {
appContext.tabManager.getActiveContext()?.setNote(note.noteId);
this.dropdown?.hide();
} else {
toastService.showError(t("calendar.cannot_find_day_note"));
}
}
ev.stopPropagation();
});
this.$dropdownContent.on("click", ".calendar-week-number", async (ev) => {
if (!this.weekNoteEnable) {
return;
}
const week = $(ev.target).closest(".calendar-week-number").attr("data-calendar-week-number");
if (week) {
const note = await dateNoteService.getWeekNote(week);
if (note) {
appContext.tabManager.getActiveContext()?.setNote(note.noteId);
this.dropdown?.hide();
} else {
toastService.showError(t("calendar.cannot_find_week_note"));
}
}
ev.stopPropagation();
});
// Handle click events for the entire calendar widget
this.$dropdownContent.on("click", (e) => {
const $target = $(e.target);
// Keep dropdown open when clicking on month select button or year selector area
if ($target.closest('.btn.dropdown-toggle.select-button').length ||
$target.closest('.calendar-year-selector').length) {
e.stopPropagation();
return;
}
// Hide dropdown for all other cases
this.monthDropdown.hide();
// Prevent dismissing the calendar popup by clicking on an empty space inside it.
e.stopPropagation();
});
}
private async getWeekNoteEnable() {
const noteId = await server.get<string[]>(`search/${encodeURIComponent('#calendarRoot')}`);
if (noteId.length === 0) {
this.weekNoteEnable = false;
return;
}
const noteAttributes = await server.get<BAttribute[]>(`notes/${noteId}/attributes`);
for (const attribute of noteAttributes) {
if (attribute.name === 'enableWeekNote') {
this.weekNoteEnable = true;
return
}
}
this.weekNoteEnable = false;
}
manageFirstDayOfWeek() {
this.firstDayOfWeek = options.getInt("firstDayOfWeek") || 0;
// Generate the list of days of the week taking into consideration the user's selected first day of week.
let localeDaysOfWeek = [...DAYS_OF_WEEK];
const daysToBeAddedAtEnd = localeDaysOfWeek.splice(0, this.firstDayOfWeek);
localeDaysOfWeek = ['', ...localeDaysOfWeek, ...daysToBeAddedAtEnd];
this.$weekHeader.html(localeDaysOfWeek.map((el) => `<span>${el}</span>`).join(''));
}
initWeekCalculation() {
this.weekCalculationOptions = {
firstWeekType: options.getInt("firstWeekOfYear") || 0,
minDaysInFirstWeek: options.getInt("minDaysInFirstWeek") || 4
};
}
getWeekNumber(date: Dayjs): number {
const year = date.year();
const dayOfWeek = (day: number) => (day - this.firstDayOfWeek + 7) % 7;
// Get first day of the year and adjust to first week start
const jan1 = date.clone().year(year).month(0).date(1);
const jan1Weekday = jan1.day();
const dayOffset = dayOfWeek(jan1Weekday);
let firstWeekStart = jan1.clone().subtract(dayOffset, 'day');
// Adjust based on week rule
switch (this.weekCalculationOptions.firstWeekType) {
case 1: { // ISO 8601: first week contains Thursday
const thursday = firstWeekStart.clone().add(3, 'day'); // Monday + 3 = Thursday
if (thursday.year() < year) {
firstWeekStart = firstWeekStart.add(7, 'day');
}
break;
}
case 2: { // minDaysInFirstWeek rule
const daysInFirstWeek = 7 - dayOffset;
if (daysInFirstWeek < this.weekCalculationOptions.minDaysInFirstWeek) {
firstWeekStart = firstWeekStart.add(7, 'day');
}
break;
}
// default case 0: week containing Jan 1 → already handled
}
const diffDays = date.startOf('day').diff(firstWeekStart.startOf('day'), 'day');
const weekNumber = Math.floor(diffDays / 7) + 1;
// Handle case when date is before first week start → belongs to last week of previous year
if (weekNumber <= 0) {
return this.getWeekNumber(date.subtract(1, 'day'));
}
// Handle case when date belongs to first week of next year
const nextYear = year + 1;
const jan1Next = date.clone().year(nextYear).month(0).date(1);
const jan1WeekdayNext = jan1Next.day();
const offsetNext = dayOfWeek(jan1WeekdayNext);
let nextYearWeekStart = jan1Next.clone().subtract(offsetNext, 'day');
switch (this.weekCalculationOptions.firstWeekType) {
case 1: {
const thursday = nextYearWeekStart.clone().add(3, 'day');
if (thursday.year() < nextYear) {
nextYearWeekStart = nextYearWeekStart.add(7, 'day');
}
break;
}
case 2: {
const daysInFirstWeek = 7 - offsetNext;
if (daysInFirstWeek < this.weekCalculationOptions.minDaysInFirstWeek) {
nextYearWeekStart = nextYearWeekStart.add(7, 'day');
}
break;
}
}
if (date.isSameOrAfter(nextYearWeekStart)) {
return 1;
}
return weekNumber;
}
async dropdownShown() {
await this.getWeekNoteEnable();
this.weekNotes = await server.get<string[]>(`attribute-values/weekNote`);
this.init(appContext.tabManager.getActiveContextNote()?.getOwnedLabelValue("dateNote") ?? null);
}
init(activeDate: string | null) {
// attaching time fixes local timezone handling
this.activeDate = activeDate ? dayjs(`${activeDate}T12:00:00`) : null;
this.todaysDate = dayjs();
this.date = dayjs(this.activeDate || this.todaysDate).startOf('month');
this.createMonth();
}
createDay(dateNotesForMonth: DateNotesForMonth, num: number) {
const $newDay = $("<a>").addClass("calendar-date").attr("data-calendar-date", this.date.local().format('YYYY-MM-DD'));
const $date = $("<span>").html(String(num));
const dateNoteId = dateNotesForMonth[this.date.local().format('YYYY-MM-DD')];
if (dateNoteId) {
$newDay.addClass("calendar-date-exists");
$newDay.attr("data-href", `#root/${dateNoteId}`);
}
if (this.date.isSame(this.activeDate, 'day')) {
$newDay.addClass("calendar-date-active");
}
if (this.date.isSame(this.todaysDate, 'day')) {
$newDay.addClass("calendar-date-today");
}
$newDay.append($date);
return $newDay;
}
createWeekNumber(weekNumber: number) {
const weekNoteId = this.date.local().format('YYYY-') + 'W' + String(weekNumber).padStart(2, '0');
let $newWeekNumber;
if (this.weekNoteEnable) {
// Utilize the hover effect of calendar-date
$newWeekNumber = $("<a>").addClass("calendar-date");
if (this.weekNotes.includes(weekNoteId)) {
$newWeekNumber.addClass("calendar-date-exists");
$newWeekNumber.attr("data-href", `#root/${weekNoteId}`);
}
} else {
$newWeekNumber = $("<span>").addClass("calendar-week-number-disabled");
}
$newWeekNumber.addClass("calendar-week-number").attr("data-calendar-week-number", weekNoteId);
$newWeekNumber.append($("<span>").html(String(weekNumber)));
return $newWeekNumber;
}
private getPrevMonthDays(firstDayOfWeek: number): { weekNumber: number, dates: Dayjs[] } {
const prevMonthLastDay = this.date.subtract(1, 'month').endOf('month');
const daysToAdd = (firstDayOfWeek - this.firstDayOfWeek + 7) % 7;
const dates: Dayjs[] = [];
const firstDay = this.date.startOf('month');
const weekNumber = this.getWeekNumber(firstDay);
// Get dates from previous month
for (let i = daysToAdd - 1; i >= 0; i--) {
dates.push(prevMonthLastDay.subtract(i, 'day'));
}
return { weekNumber, dates };
}
private getNextMonthDays(lastDayOfWeek: number): Dayjs[] {
const nextMonthFirstDay = this.date.add(1, 'month').startOf('month');
const dates: Dayjs[] = [];
const lastDayOfUserWeek = (this.firstDayOfWeek + 6) % 7;
const daysToAdd = (lastDayOfUserWeek - lastDayOfWeek + 7) % 7;
// Get dates from next month
for (let i = 0; i < daysToAdd; i++) {
dates.push(nextMonthFirstDay.add(i, 'day'));
}
return dates;
}
async createMonth() {
const month = this.date.format('YYYY-MM');
const dateNotesForMonth: DateNotesForMonth = await server.get(`special-notes/notes-for-month/${month}`);
this.$month.empty();
const firstDay = this.date.startOf('month');
const firstDayOfWeek = firstDay.day();
// Add dates from previous month
if (firstDayOfWeek !== this.firstDayOfWeek) {
const { weekNumber, dates } = this.getPrevMonthDays(firstDayOfWeek);
const prevMonth = this.date.subtract(1, 'month').format('YYYY-MM');
const dateNotesForPrevMonth: DateNotesForMonth = await server.get(`special-notes/notes-for-month/${prevMonth}`);
const $weekNumber = this.createWeekNumber(weekNumber);
this.$month.append($weekNumber);
dates.forEach(date => {
const tempDate = this.date;
this.date = date;
const $day = this.createDay(dateNotesForPrevMonth, date.date());
$day.addClass('calendar-date-prev-month');
this.$month.append($day);
this.date = tempDate;
});
}
const currentMonth = this.date.month();
while (this.date.month() === currentMonth) {
const weekNumber = this.getWeekNumber(this.date);
// Add week number if it's first day of week
if (this.date.day() === this.firstDayOfWeek) {
const $weekNumber = this.createWeekNumber(weekNumber);
this.$month.append($weekNumber);
}
const $day = this.createDay(dateNotesForMonth, this.date.date());
this.$month.append($day);
this.date = this.date.add(1, 'day');
}
// while loop trips over and day is at 30/31, bring it back
this.date = this.date.startOf('month').subtract(1, 'month');
// Add dates from next month
const lastDayOfMonth = this.date.endOf('month');
const lastDayOfWeek = lastDayOfMonth.day();
const lastDayOfUserWeek = (this.firstDayOfWeek + 6) % 7;
if (lastDayOfWeek !== lastDayOfUserWeek) {
const dates = this.getNextMonthDays(lastDayOfWeek);
const nextMonth = this.date.add(1, 'month').format('YYYY-MM');
const dateNotesForNextMonth: DateNotesForMonth = await server.get(`special-notes/notes-for-month/${nextMonth}`);
dates.forEach(date => {
const tempDate = this.date;
this.date = date;
const $day = this.createDay(dateNotesForNextMonth, date.date());
$day.addClass('calendar-date-next-month');
this.$month.append($day);
this.date = tempDate;
});
}
this.$monthSelect.text(MONTHS[this.date.month()]);
this.$yearSelect.val(this.date.year());
}
async entitiesReloadedEvent({ loadResults }: EventData<"entitiesReloaded">) {
if (!loadResults.getOptionNames().includes("firstDayOfWeek") &&
!loadResults.getOptionNames().includes("firstWeekOfYear") &&
!loadResults.getOptionNames().includes("minDaysInFirstWeek")) {
return;
}
this.manageFirstDayOfWeek();
this.initWeekCalculation();
this.createMonth();
}
}

View File

@@ -0,0 +1,34 @@
import type { EventData } from "../../components/app_context.js";
import { t } from "../../services/i18n.js";
import OnClickButtonWidget from "./onclick_button.js";
export default class ClosePaneButton extends OnClickButtonWidget {
isEnabled() {
return (
super.isEnabled() &&
// main note context should not be closeable
this.noteContext &&
!!this.noteContext.mainNtxId
);
}
async noteContextReorderEvent({ ntxIdsInOrder }: EventData<"noteContextReorder">) {
this.refresh();
}
constructor() {
super();
this.icon("bx-x")
.title(t("close_pane_button.close_this_pane"))
.titlePlacement("bottom")
.onClick((widget, e) => {
// to avoid split pane container detecting click within the pane which would try to activate this
// pane (which is being removed)
e.stopPropagation();
widget.triggerCommand("closeThisNoteSplit", { ntxId: widget.getClosestNtxId() });
})
.class("icon-action");
}
}

View File

@@ -0,0 +1,72 @@
import type { CommandNames } from "../../components/app_context.js";
import keyboardActionsService, { type Action } from "../../services/keyboard_actions.js";
import AbstractButtonWidget, { type AbstractButtonWidgetSettings } from "./abstract_button.js";
import type { ButtonNoteIdProvider } from "./button_from_note.js";
let actions: Action[];
keyboardActionsService.getActions().then((as) => (actions = as));
// TODO: Is this actually used?
export type ClickHandler = (widget: CommandButtonWidget, e: JQuery.ClickEvent<any, any, any, any>) => void;
type CommandOrCallback = CommandNames | (() => CommandNames);
interface CommandButtonWidgetSettings extends AbstractButtonWidgetSettings {
command?: CommandOrCallback;
onClick?: ClickHandler;
buttonNoteIdProvider?: ButtonNoteIdProvider | null;
}
export default class CommandButtonWidget extends AbstractButtonWidget<CommandButtonWidgetSettings> {
constructor() {
super();
this.settings = {
titlePlacement: "right",
title: null,
icon: null,
onContextMenu: null
};
}
doRender() {
super.doRender();
if (this.settings.command) {
this.$widget.on("click", () => {
this.tooltip.hide();
if (this._command) {
this.triggerCommand(this._command);
}
});
} else {
console.warn(`Button widget '${this.componentId}' has no defined command`, this.settings);
}
}
getTitle() {
const title = super.getTitle();
const action = actions.find((act) => act.actionName === this._command);
if (action && action.effectiveShortcuts.length > 0) {
return `${title} (${action.effectiveShortcuts.join(", ")})`;
} else {
return title;
}
}
onClick(handler: ClickHandler) {
this.settings.onClick = handler;
return this;
}
command(command: CommandOrCallback) {
this.settings.command = command;
return this;
}
get _command() {
return typeof this.settings.command === "function" ? this.settings.command() : this.settings.command;
}
}

View File

@@ -0,0 +1,27 @@
import { t } from "../../services/i18n.js";
import options from "../../services/options.js";
import CommandButtonWidget from "./command_button.js";
export default class CreateAiChatButton extends CommandButtonWidget {
constructor() {
super();
this.icon("bx bx-bot")
.title(t("ai.create_new_ai_chat"))
.titlePlacement("bottom")
.command("createAiChat")
.class("icon-action");
}
isEnabled() {
return options.get("aiEnabled") === "true";
}
async refreshWithNote() {
if (this.isEnabled()) {
this.$widget.show();
} else {
this.$widget.hide();
}
}
}

View File

@@ -0,0 +1,14 @@
import { t } from "../../services/i18n.js";
import OnClickButtonWidget from "./onclick_button.js";
export default class CreatePaneButton extends OnClickButtonWidget {
constructor() {
super();
this.icon("bx-dock-right")
.title(t("create_pane_button.create_new_split"))
.titlePlacement("bottom")
.onClick((widget) => widget.triggerCommand("openNewNoteSplit", { ntxId: widget.getClosestNtxId() }))
.class("icon-action");
}
}

View File

@@ -0,0 +1,441 @@
import { t } from "../../services/i18n.js";
import BasicWidget from "../basic_widget.js";
import utils from "../../services/utils.js";
import UpdateAvailableWidget from "./update_available.js";
import options from "../../services/options.js";
import { Tooltip, Dropdown } from "bootstrap";
const TPL = /*html*/`
<div class="dropdown global-menu">
<style>
.global-menu {
width: 53px;
height: 53px;
flex-shrink: 0;
}
.global-menu .dropdown-menu {
min-width: 20em;
}
.global-menu-button {
width: 100%;
height: 100%;
position: relative;
padding: 6px;
border: 0;
}
.global-menu-button > svg path {
fill: var(--launcher-pane-text-color);
}
.global-menu-button:hover { border: 0; }
.global-menu-button:hover > svg path {
transition: 200ms ease-in-out fill;
}
.global-menu-button:hover > svg path.st0 { fill:#95C980; }
.global-menu-button:hover > svg path.st1 { fill:#72B755; }
.global-menu-button:hover > svg path.st2 { fill:#4FA52B; }
.global-menu-button:hover > svg path.st3 { fill:#EE8C89; }
.global-menu-button:hover > svg path.st4 { fill:#E96562; }
.global-menu-button:hover > svg path.st5 { fill:#E33F3B; }
.global-menu-button:hover > svg path.st6 { fill:#EFB075; }
.global-menu-button:hover > svg path.st7 { fill:#E99547; }
.global-menu-button:hover > svg path.st8 { fill:#E47B19; }
.global-menu-button-update-available {
position: absolute;
right: -30px;
bottom: -30px;
width: 100%;
height: 100%;
pointer-events: none;
}
.update-to-latest-version-button {
display: none;
}
.global-menu .zoom-container {
display: flex;
flex-direction: row;
justify-content: space-between;
align-items: baseline;
}
.global-menu .zoom-buttons a {
display: inline-block;
border: 1px solid var(--button-border-color);
border-radius: var(--button-border-radius);
color: var(--button-text-color);
background-color: var(--button-background-color);
padding: 3px;
margin-left: 3px;
text-decoration: none;
}
.global-menu .zoom-buttons a:hover {
text-decoration: none;
}
.global-menu .zoom-state {
margin-left: 5px;
margin-right: 5px;
}
.global-menu .dropdown-item .bx {
position: relative;
top: 3px;
font-size: 120%;
margin-right: 6px;
}
</style>
<button type="button" data-bs-toggle="dropdown" aria-haspopup="true"
aria-expanded="false" class="icon-action global-menu-button">
<div class="global-menu-button-update-available"></div>
</button>
<ul class="dropdown-menu dropdown-menu-right">
<li class="dropdown-item" data-trigger-command="openNewWindow">
<span class="bx bx-window-open"></span>
${t("global_menu.open_new_window")}
<kbd data-command="openNewWindow"></kbd>
</li>
<li class="dropdown-item" data-trigger-command="showShareSubtree">
<span class="bx bx-share-alt"></span>
${t("global_menu.show_shared_notes_subtree")}
</li>
<div class="dropdown-divider"></div>
<span class="zoom-container dropdown-item dropdown-item-container">
<div>
<span class="bx bx-empty"></span>
${t("global_menu.zoom")}
</div>
<div class="zoom-buttons">
<a data-trigger-command="toggleFullscreen" title="${t("global_menu.toggle_fullscreen")}" class="bx bx-expand-alt"></a>
&nbsp;
<a data-trigger-command="zoomOut" title="${t("global_menu.zoom_out")}" class="bx bx-minus"></a>
<span data-trigger-command="zoomReset" title="${t("global_menu.reset_zoom_level")}" class="zoom-state"></span>
<a data-trigger-command="zoomIn" title="${t("global_menu.zoom_in")}" class="bx bx-plus"></a>
</div>
</span>
<li class="dropdown-item toggle-pin">
<span class="bx bx-pin"></span>
${t("title_bar_buttons.window-on-top")}
</li>
<li class="dropdown-item" data-trigger-command="toggleZenMode">
<span class="bx bxs-yin-yang"></span>
${t("global_menu.toggle-zen-mode")}
<kbd data-command="toggleZenMode"></kbd>
</li>
<div class="dropdown-divider desktop-only"></div>
<li class="dropdown-item switch-to-mobile-version-button" data-trigger-command="switchToMobileVersion">
<span class="bx bx-mobile"></span>
${t("global_menu.switch_to_mobile_version")}
</li>
<li class="dropdown-item switch-to-desktop-version-button" data-trigger-command="switchToDesktopVersion">
<span class="bx bx-desktop"></span>
${t("global_menu.switch_to_desktop_version")}
</li>
<li class="dropdown-item" data-trigger-command="showLaunchBarSubtree">
<span class="bx ${utils.isMobile() ? "bx-mobile" : "bx-sidebar"}"></span>
${t("global_menu.configure_launchbar")}
</li>
<li class="dropdown-item dropdown-submenu">
<span class="dropdown-toggle">
<span class="bx bx-chip"></span>${t("global_menu.advanced")}
</span>
<ul class="dropdown-menu">
<li class="dropdown-item" data-trigger-command="showHiddenSubtree">
<span class="bx bx-hide"></span>
${t("global_menu.show_hidden_subtree")}
</li>
<li class="dropdown-item" data-trigger-command="showSearchHistory">
<span class="bx bx-search-alt"></span>
${t("global_menu.open_search_history")}
</li>
<div class="dropdown-divider"></div>
<li class="dropdown-item" data-trigger-command="showBackendLog">
<span class="bx bx-detail"></span>
${t("global_menu.show_backend_log")}
<kbd data-command="showBackendLog"></kbd>
</li>
<li class="dropdown-item" data-trigger-command="showSQLConsole">
<span class="bx bx-data"></span>
${t("global_menu.open_sql_console")}
<kbd data-command="showSQLConsole"></kbd>
</li>
<li class="dropdown-item" data-trigger-command="showSQLConsoleHistory">
<span class="bx bx-data"></span>
${t("global_menu.open_sql_console_history")}
</li>
<div class="dropdown-divider"></div>
<li class="dropdown-item open-dev-tools-button" data-trigger-command="openDevTools">
<span class="bx bx-bug-alt"></span>
${t("global_menu.open_dev_tools")}
<kbd data-command="openDevTools"></kbd>
</li>
<li class="dropdown-item" data-trigger-command="reloadFrontendApp"
title="${t("global_menu.reload_hint")}">
<span class="bx bx-refresh"></span>
${t("global_menu.reload_frontend")}
<kbd data-command="reloadFrontendApp"></kbd>
</li>
</ul>
</li>
<li class="dropdown-item" data-trigger-command="showOptions">
<span class="bx bx-cog"></span>
${t("global_menu.options")}
</li>
<div class="dropdown-divider desktop-only"></div>
<li class="dropdown-item show-help-button" data-trigger-command="showHelp">
<span class="bx bx-help-circle"></span>
${t("global_menu.show_help")}
<kbd data-command="showHelp"></kbd>
</li>
<li class="dropdown-item show-help-button" data-trigger-command="showCheatsheet">
<span class="bx bxs-keyboard"></span>
${t("global_menu.show-cheatsheet")}
<kbd data-command="showCheatsheet"></kbd>
</li>
<li class="dropdown-item show-about-dialog-button">
<span class="bx bx-info-circle"></span>
${t("global_menu.about")}
</li>
<li class="dropdown-item update-to-latest-version-button" data-trigger-command="downloadLatestVersion">
<span class="bx bx-sync"></span>
<span class="version-text"></span>
</li>
<div class="dropdown-divider logout-button-separator"></div>
<li class="dropdown-item logout-button" data-trigger-command="logout">
<span class="bx bx-log-out"></span>
${t("global_menu.logout")}
</li>
</ul>
</div>
`;
export default class GlobalMenuWidget extends BasicWidget {
private updateAvailableWidget: UpdateAvailableWidget;
private isHorizontalLayout: boolean;
private tooltip!: Tooltip;
private dropdown!: Dropdown;
private $updateToLatestVersionButton!: JQuery<HTMLElement>;
private $zoomState!: JQuery<HTMLElement>;
private $toggleZenMode!: JQuery<HTMLElement>;
constructor(isHorizontalLayout: boolean) {
super();
this.updateAvailableWidget = new UpdateAvailableWidget();
this.isHorizontalLayout = isHorizontalLayout;
}
doRender() {
this.$widget = $(TPL);
if (!this.isHorizontalLayout) {
this.$widget.addClass("dropend");
}
const $globalMenuButton = this.$widget.find(".global-menu-button");
if (!this.isHorizontalLayout) {
$globalMenuButton.prepend(
$(`\
<svg viewBox="0 0 256 256" data-bs-toggle="tooltip" title="${t("global_menu.menu")}">
<g>
<path class="st0" d="m202.9 112.7c-22.5 16.1-54.5 12.8-74.9 6.3l14.8-11.8 14.1-11.3 49.1-39.3-51.2 35.9-14.3 10-14.9 10.5c0.7-21.2 7-49.9 28.6-65.4 1.8-1.3 3.9-2.6 6.1-3.8 2.7-1.5 5.7-2.9 8.8-4.1 27.1-11.1 68.5-15.3 85.2-9.5 0.1 16.2-15.9 45.4-33.9 65.9-2.4 2.8-4.9 5.4-7.4 7.8-3.4 3.5-6.8 6.4-10.1 8.8z"/>
<path class="st1" d="m213.1 104c-22.2 12.6-51.4 9.3-70.3 3.2l14.1-11.3 49.1-39.3-51.2 35.9-14.3 10c0.5-18.1 4.9-42.1 19.7-58.6 2.7-1.5 5.7-2.9 8.8-4.1 27.1-11.1 68.5-15.3 85.2-9.5 0.1 16.2-15.9 45.4-33.9 65.9-2.3 2.8-4.8 5.4-7.2 7.8z"/>
<path class="st2" d="m220.5 96.2c-21.1 8.6-46.6 5.3-63.7-0.2l49.2-39.4-51.2 35.9c0.3-15.8 3.5-36.6 14.3-52.8 27.1-11.1 68.5-15.3 85.2-9.5 0.1 16.2-15.9 45.4-33.8 66z"/>
<path class="st3" d="m106.7 179c-5.8-21 5.2-43.8 15.5-57.2l4.8 14.2 4.5 13.4 15.9 47-12.8-47.6-3.6-13.2-3.7-13.9c15.5 6.2 35.1 18.6 40.7 38.8 0.5 1.7 0.9 3.6 1.2 5.5 0.4 2.4 0.6 5 0.7 7.7 0.9 23.1-7.1 54.9-15.9 65.7-12-4.3-29.3-24-39.7-42.8-1.4-2.6-2.7-5.1-3.8-7.6-1.6-3.5-2.9-6.8-3.8-10z"/>
<path class="st4" d="m110.4 188.9c-3.4-19.8 6.9-40.5 16.6-52.9l4.5 13.4 15.9 47-12.8-47.6-3.6-13.2c13.3 5.2 29.9 15 38.1 30.4 0.4 2.4 0.6 5 0.7 7.7 0.9 23.1-7.1 54.9-15.9 65.7-12-4.3-29.3-24-39.7-42.8-1.4-2.6-2.7-5.2-3.8-7.7z"/>
<path class="st5" d="m114.2 196.5c-0.7-18 8.6-35.9 17.3-47.1l15.9 47-12.8-47.6c11.6 4.4 26.1 12.4 35.2 24.8 0.9 23.1-7.1 54.9-15.9 65.7-12-4.3-29.3-24-39.7-42.8z"/>
<path class="st6" d="m86.3 59.1c21.7 10.9 32.4 36.6 35.8 54.9l-15.2-6.6-14.5-6.3-50.6-22 48.8 24.9 13.6 6.9 14.3 7.3c-16.6 7.9-41.3 14.5-62.1 4.1-1.8-0.9-3.6-1.9-5.4-3.2-2.3-1.5-4.5-3.2-6.8-5.1-19.9-16.4-40.3-46.4-42.7-61.5 12.4-6.5 41.5-5.8 64.8-0.3 3.2 0.8 6.2 1.6 9.1 2.5 4 1.3 7.6 2.8 10.9 4.4z"/>
<path class="st7" d="m75.4 54.8c18.9 12 28.4 35.6 31.6 52.6l-14.5-6.3-50.6-22 48.7 24.9 13.6 6.9c-14.1 6.8-34.5 13-53.3 8.2-2.3-1.5-4.5-3.2-6.8-5.1-19.8-16.4-40.2-46.4-42.6-61.5 12.4-6.5 41.5-5.8 64.8-0.3 3.1 0.8 6.2 1.6 9.1 2.6z"/>
<path class="st8" d="m66.3 52.2c15.3 12.8 23.3 33.6 26.1 48.9l-50.6-22 48.8 24.9c-12.2 6-29.6 11.8-46.5 10-19.8-16.4-40.2-46.4-42.6-61.5 12.4-6.5 41.5-5.8 64.8-0.3z"/>
</g>
</svg>`)
);
this.tooltip = new Tooltip(this.$widget.find("[data-bs-toggle='tooltip']")[0], { trigger: "hover" });
} else {
$globalMenuButton.toggleClass("bx bx-menu");
}
this.dropdown = Dropdown.getOrCreateInstance(this.$widget.find("[data-bs-toggle='dropdown']")[0], {
popperConfig: {
placement: "bottom"
}
});
this.$widget.find(".show-about-dialog-button").on("click", () => this.triggerCommand("openAboutDialog"));
const isElectron = utils.isElectron();
this.$widget.find(".toggle-pin").toggle(isElectron);
if (isElectron) {
this.$widget.on("click", ".toggle-pin", (e) => {
const $el = $(e.target);
const remote = utils.dynamicRequire("@electron/remote");
const focusedWindow = remote.BrowserWindow.getFocusedWindow();
const isAlwaysOnTop = focusedWindow.isAlwaysOnTop();
if (isAlwaysOnTop) {
focusedWindow.setAlwaysOnTop(false);
$el.removeClass("active");
} else {
focusedWindow.setAlwaysOnTop(true);
$el.addClass("active");
}
});
}
this.$widget.find(".logout-button").toggle(!isElectron);
this.$widget.find(".logout-button-separator").toggle(!isElectron);
this.$widget.find(".open-dev-tools-button").toggle(isElectron);
this.$widget.find(".switch-to-mobile-version-button").toggle(!isElectron && utils.isDesktop());
this.$widget.find(".switch-to-desktop-version-button").toggle(!isElectron && utils.isMobile());
this.$widget.on("click", ".dropdown-item", (e) => {
if ($(e.target).parent(".zoom-buttons")) {
return;
}
this.dropdown.toggle();
});
if (utils.isMobile()) {
this.$widget.on("click", ".dropdown-submenu .dropdown-toggle", (e) => {
const $submenu = $(e.target).closest(".dropdown-item");
$submenu.toggleClass("submenu-open");
$submenu.find("ul.dropdown-menu").toggleClass("show");
e.stopPropagation();
return;
});
}
this.$widget.on("click", ".dropdown-submenu", (e) => {
if ($(e.target).children(".dropdown-menu").length === 1 || $(e.target).hasClass("dropdown-toggle")) {
e.stopPropagation();
}
});
this.$widget.find(".global-menu-button-update-available").append(this.updateAvailableWidget.render());
this.$updateToLatestVersionButton = this.$widget.find(".update-to-latest-version-button");
if (!utils.isElectron()) {
this.$widget.find(".zoom-container").hide();
}
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());
}
this.$widget.find(".zoom-buttons").on(
"click",
// delay to wait for the actual zoom change
() => setTimeout(() => this.updateZoomState(), 300)
);
this.updateVersionStatus();
setInterval(() => this.updateVersionStatus(), 8 * 60 * 60 * 1000);
}
#onShown() {
this.$toggleZenMode.toggleClass("active", $("body").hasClass("zen"));
this.updateZoomState();
if (this.tooltip) {
this.tooltip.hide();
this.tooltip.disable();
}
}
updateZoomState() {
if (!utils.isElectron()) {
return;
}
const zoomFactor = utils.dynamicRequire("electron").webFrame.getZoomFactor();
const zoomPercent = Math.round(zoomFactor * 100);
this.$zoomState.text(`${zoomPercent}%`);
}
async updateVersionStatus() {
await options.initializedPromise;
if (options.get("checkForUpdates") !== "true") {
return;
}
const latestVersion = await this.fetchLatestVersion();
this.updateAvailableWidget.updateVersionStatus(latestVersion);
// Show "click to download" button in options menu if there's a new version available
this.$updateToLatestVersionButton.toggle(utils.isUpdateAvailable(latestVersion, glob.triliumVersion));
this.$updateToLatestVersionButton.find(".version-text").text(`Version ${latestVersion} is available, click to download.`);
}
async fetchLatestVersion() {
const RELEASES_API_URL = "https://api.github.com/repos/TriliumNext/Notes/releases/latest";
const resp = await fetch(RELEASES_API_URL);
const data = await resp.json();
return data?.tag_name?.substring(1);
}
downloadLatestVersionCommand() {
window.open("https://github.com/TriliumNext/Notes/releases/latest");
}
activeContextChangedEvent() {
this.dropdown.hide();
}
noteSwitchedEvent() {
this.dropdown.hide();
}
}

View File

@@ -0,0 +1,106 @@
import utils from "../../services/utils.js";
import contextMenu from "../../menus/context_menu.js";
import treeService from "../../services/tree.js";
import ButtonFromNoteWidget from "./button_from_note.js";
import type FNote from "../../entities/fnote.js";
import type { CommandNames } from "../../components/app_context.js";
interface WebContents {
history: string[];
getActiveIndex(): number;
clearHistory(): void;
canGoBack(): boolean;
canGoForward(): boolean;
goToIndex(index: string): void;
}
interface ContextMenuItem {
title: string;
idx: string;
uiIcon: string;
}
export default class HistoryNavigationButton extends ButtonFromNoteWidget {
private webContents?: WebContents;
constructor(launcherNote: FNote, command: string) {
super();
this.title(() => launcherNote.title)
.icon(() => launcherNote.getIcon())
.command(() => command as CommandNames)
.titlePlacement("right")
.buttonNoteIdProvider(() => launcherNote.noteId)
.onContextMenu((e) => { if (e) this.showContextMenu(e); })
.class("launcher-button");
}
doRender() {
super.doRender();
if (utils.isElectron()) {
this.webContents = utils.dynamicRequire("@electron/remote").getCurrentWebContents();
// without this, the history is preserved across frontend reloads
this.webContents?.clearHistory();
this.refresh();
}
}
async showContextMenu(e: JQuery.ContextMenuEvent) {
e.preventDefault();
if (!this.webContents || this.webContents.history.length < 2) {
return;
}
let items: ContextMenuItem[] = [];
const activeIndex = this.webContents.getActiveIndex();
const history = this.webContents.history;
for (const idx in history) {
const url = history[idx];
const parts = url.split("#");
if (parts.length < 2) continue;
const notePathWithTab = parts[1];
const notePath = notePathWithTab.split("-")[0];
const title = await treeService.getNotePathTitle(notePath);
items.push({
title,
idx,
uiIcon:
parseInt(idx) === activeIndex
? "bx bx-radio-circle-marked" // compare with type coercion!
: parseInt(idx) < activeIndex
? "bx bx-left-arrow-alt"
: "bx bx-right-arrow-alt"
});
}
items.reverse();
if (items.length > 20) {
items = items.slice(0, 50);
}
contextMenu.show({
x: e.pageX,
y: e.pageY,
items,
selectMenuItemHandler: (item: any) => {
if (item && item.idx && this.webContents) {
this.webContents.goToIndex(item.idx);
}
}
});
}
activeNoteChangedEvent() {
this.refresh();
}
}

View File

@@ -0,0 +1,47 @@
import shortcutService from "../../../services/shortcuts.js";
import attributesService from "../../../services/attributes.js";
import OnClickButtonWidget from "../onclick_button.js";
import type FNote from "../../../entities/fnote.js";
import type FAttribute from "../../../entities/fattribute.js";
import type { EventData } from "../../../components/app_context.js";
import type { AttributeRow } from "../../../services/load_results.js";
export default abstract class AbstractLauncher extends OnClickButtonWidget {
protected launcherNote: FNote;
constructor(launcherNote: FNote) {
super();
this.class("launcher-button");
this.launcherNote = launcherNote;
for (const label of launcherNote.getOwnedLabels("keyboardShortcut")) {
this.bindNoteShortcutHandler(label);
}
}
abstract launch(): void;
bindNoteShortcutHandler(labelOrRow: FAttribute | AttributeRow) {
const namespace = labelOrRow.attributeId;
if ("isDeleted" in labelOrRow && labelOrRow.isDeleted) {
// only applicable if row
shortcutService.removeGlobalShortcut(namespace);
} else if (labelOrRow.value) {
shortcutService.bindGlobalShortcut(labelOrRow.value, () => this.launch(), namespace);
}
}
entitiesReloadedEvent({ loadResults }: EventData<"entitiesReloaded">) {
for (const attr of loadResults.getAttributeRows()) {
if (attr.noteId === this.launcherNote.noteId && attr.type === "label" && attr.name === "keyboardShortcut") {
this.bindNoteShortcutHandler(attr);
} else if (attr.type === "label" && attr.name === "iconClass" && attributesService.isAffecting(attr, this.launcherNote)) {
this.refreshIcon();
}
}
}
}

View File

@@ -0,0 +1,98 @@
import { t } from "../../../services/i18n.js";
import AbstractLauncher from "./abstract_launcher.js";
import dialogService from "../../../services/dialog.js";
import appContext from "../../../components/app_context.js";
import utils from "../../../services/utils.js";
import linkContextMenuService from "../../../menus/link_context_menu.js";
import type FNote from "../../../entities/fnote.js";
// we're intentionally displaying the launcher title and icon instead of the target,
// e.g. you want to make launchers to 2 mermaid diagrams which both have mermaid icon (ok),
// but on the launchpad you want them distinguishable.
// for titles, the note titles may follow a different scheme than maybe desirable on the launchpad
// another reason is the discrepancy between what user sees on the launchpad and in the config (esp. icons).
// The only downside is more work in setting up the typical case
// where you actually want to have both title and icon in sync, but for those cases there are bookmarks
export default class NoteLauncher extends AbstractLauncher {
constructor(launcherNote: FNote) {
super(launcherNote);
this.title(() => this.launcherNote.title)
.icon(() => this.launcherNote.getIcon())
.onClick((widget, evt) => this.launch(evt))
.onAuxClick((widget, evt) => this.launch(evt))
.onContextMenu(async (evt) => {
let targetNoteId = await Promise.resolve(this.getTargetNoteId());
if (!targetNoteId || !evt) {
return;
}
const hoistedNoteId = this.getHoistedNoteId();
linkContextMenuService.openContextMenu(targetNoteId, evt, {}, hoistedNoteId);
});
}
async launch(evt?: JQuery.ClickEvent | JQuery.ContextMenuEvent | JQuery.TriggeredEvent) {
// await because subclass overrides can be async
const targetNoteId = await this.getTargetNoteId();
if (!targetNoteId || evt?.which === 3) {
return;
}
const hoistedNoteId = await this.getHoistedNoteId();
if (!hoistedNoteId) {
return;
}
if (!evt) {
// keyboard shortcut
// TODO: Fix once tabManager is ported.
//@ts-ignore
await appContext.tabManager.openInSameTab(targetNoteId, hoistedNoteId);
} else {
const ctrlKey = utils.isCtrlKey(evt);
if ((evt.which === 1 && ctrlKey) || evt.which === 2) {
// TODO: Fix once tabManager is ported.
//@ts-ignore
await appContext.tabManager.openInNewTab(targetNoteId, hoistedNoteId);
} else {
// TODO: Fix once tabManager is ported.
//@ts-ignore
await appContext.tabManager.openInSameTab(targetNoteId, hoistedNoteId);
}
}
}
getTargetNoteId(): void | string | Promise<string | undefined> {
const targetNoteId = this.launcherNote.getRelationValue("target");
if (!targetNoteId) {
dialogService.info(t("note_launcher.this_launcher_doesnt_define_target_note"));
return;
}
return targetNoteId;
}
getHoistedNoteId() {
return this.launcherNote.getRelationValue("hoistedNote") || appContext.tabManager.getActiveContext()?.hoistedNoteId;
}
getTitle() {
const shortcuts = this.launcherNote
.getLabels("keyboardShortcut")
.map((l) => l.value)
.filter((v) => !!v)
.join(", ");
let title = super.getTitle();
if (shortcuts) {
title += ` (${shortcuts})`;
}
return title;
}
}

View File

@@ -0,0 +1,23 @@
import type FNote from "../../../entities/fnote.js";
import AbstractLauncher from "./abstract_launcher.js";
export default class ScriptLauncher extends AbstractLauncher {
constructor(launcherNote: FNote) {
super(launcherNote);
this.title(() => this.launcherNote.title)
.icon(() => this.launcherNote.getIcon())
.onClick(() => this.launch());
}
async launch() {
if (this.launcherNote.isLabelTruthy("scriptInLauncherContent")) {
await this.launcherNote.executeScript();
} else {
const script = await this.launcherNote.getRelationTarget("script");
if (script) {
await script.executeScript();
}
}
}
}

View File

@@ -0,0 +1,15 @@
import NoteLauncher from "./note_launcher.js";
import dateNotesService from "../../../services/date_notes.js";
import appContext from "../../../components/app_context.js";
export default class TodayLauncher extends NoteLauncher {
async getTargetNoteId() {
const todayNote = await dateNotesService.getTodayNote();
return todayNote?.noteId;
}
getHoistedNoteId() {
return appContext.tabManager.getActiveContext()?.hoistedNoteId;
}
}

View File

@@ -0,0 +1,42 @@
import options from "../../services/options.js";
import splitService from "../../services/resizer.js";
import CommandButtonWidget from "./command_button.js";
import { t } from "../../services/i18n.js";
import type { EventData } from "../../components/app_context.js";
export default class LeftPaneToggleWidget extends CommandButtonWidget {
constructor(isHorizontalLayout: boolean) {
super();
this.class(isHorizontalLayout ? "toggle-button" : "launcher-button");
this.settings.icon = () => {
if (options.get("layoutOrientation") === "horizontal") {
return "bx-sidebar";
}
return options.is("leftPaneVisible") ? "bx-chevrons-left" : "bx-chevrons-right";
};
this.settings.title = () => (options.is("leftPaneVisible") ? t("left_pane_toggle.hide_panel") : t("left_pane_toggle.show_panel"));
this.settings.command = () => (options.is("leftPaneVisible") ? "hideLeftPane" : "showLeftPane");
if (isHorizontalLayout) {
this.settings.titlePlacement = "bottom";
}
}
refreshIcon() {
super.refreshIcon();
splitService.setupLeftPaneResizer(options.is("leftPaneVisible"));
}
entitiesReloadedEvent({ loadResults }: EventData<"entitiesReloaded">) {
if (loadResults.isOptionReloaded("leftPaneVisible")) {
this.refreshIcon();
}
}
}

View File

@@ -0,0 +1,55 @@
import OnClickButtonWidget from "./onclick_button.js";
import appContext from "../../components/app_context.js";
import { t } from "../../services/i18n.js";
export default class MovePaneButton extends OnClickButtonWidget {
private isMovingLeft: boolean;
constructor(isMovingLeft: boolean) {
super();
this.isMovingLeft = isMovingLeft;
this.icon(isMovingLeft ? "bx-chevron-left" : "bx-chevron-right")
.title(isMovingLeft ? t("move_pane_button.move_left") : t("move_pane_button.move_right"))
.titlePlacement("bottom")
.onClick(async (widget, e) => {
e.stopPropagation();
widget.triggerCommand("moveThisNoteSplit", { ntxId: widget.getClosestNtxId(), isMovingLeft: this.isMovingLeft });
})
.class("icon-action");
}
isEnabled() {
if (!super.isEnabled()) {
return false;
}
if (this.isMovingLeft) {
// movable if the current context is not a main context, i.e. non-null mainNtxId
return !!this.noteContext?.mainNtxId;
} else {
const currentIndex = appContext.tabManager.noteContexts.findIndex((c) => c.ntxId === this.ntxId);
const nextContext = appContext.tabManager.noteContexts[currentIndex + 1];
// movable if the next context is not null and not a main context, i.e. non-null mainNtxId
return !!nextContext?.mainNtxId;
}
}
async noteContextRemovedEvent() {
this.refresh();
}
async newNoteContextCreatedEvent() {
this.refresh();
}
async noteContextReorderEvent() {
this.refresh();
}
async contextsReopenedEvent() {
this.refresh();
}
}

View File

@@ -0,0 +1,252 @@
import NoteContextAwareWidget from "../note_context_aware_widget.js";
import utils from "../../services/utils.js";
import branchService from "../../services/branches.js";
import dialogService from "../../services/dialog.js";
import server from "../../services/server.js";
import toastService from "../../services/toast.js";
import ws from "../../services/ws.js";
import appContext, { type EventData } from "../../components/app_context.js";
import { t } from "../../services/i18n.js";
import type FNote from "../../entities/fnote.js";
import type { FAttachmentRow } from "../../entities/fattachment.js";
// TODO: Deduplicate with server
interface ConvertToAttachmentResponse {
attachment: FAttachmentRow;
}
const TPL = /*html*/`
<div class="dropdown note-actions">
<style>
.note-actions {
width: 35px;
height: 35px;
}
.note-actions .dropdown-menu {
min-width: 15em;
}
.note-actions .dropdown-item .bx {
position: relative;
top: 3px;
font-size: 120%;
margin-right: 5px;
}
.note-actions .dropdown-item[disabled], .note-actions .dropdown-item[disabled]:hover {
color: var(--muted-text-color) !important;
background-color: transparent !important;
pointer-events: none; /* makes it unclickable */
}
</style>
<button type="button" data-bs-toggle="dropdown" aria-haspopup="true" aria-expanded="false"
class="icon-action bx bx-dots-vertical-rounded"></button>
<div class="dropdown-menu dropdown-menu-right">
<li data-trigger-command="convertNoteIntoAttachment" class="dropdown-item">
<span class="bx bx-paperclip"></span> ${t("note_actions.convert_into_attachment")}
</li>
<li data-trigger-command="renderActiveNote" class="dropdown-item render-note-button">
<span class="bx bx-extension"></span> ${t("note_actions.re_render_note")}<kbd data-command="renderActiveNote"></kbd>
</li>
<li data-trigger-command="findInText" class="dropdown-item find-in-text-button">
<span class='bx bx-search'></span> ${t("note_actions.search_in_note")}<kbd data-command="findInText"></kbd>
</li>
<li data-trigger-command="printActiveNote" class="dropdown-item print-active-note-button">
<span class="bx bx-printer"></span> ${t("note_actions.print_note")}<kbd data-command="printActiveNote"></kbd>
</li>
<li data-trigger-command="exportAsPdf" class="dropdown-item export-as-pdf-button">
<span class="bx bxs-file-pdf"></span> ${t("note_actions.print_pdf")}<kbd data-command="exportAsPdf"></kbd>
</li>
<div class="dropdown-divider"></div>
<li class="dropdown-item import-files-button"><span class="bx bx-import"></span> ${t("note_actions.import_files")}</li>
<li class="dropdown-item export-note-button"><span class="bx bx-export"></span> ${t("note_actions.export_note")}</li>
<div class="dropdown-divider"></div>
<li data-trigger-command="openNoteExternally" class="dropdown-item open-note-externally-button" title="${t("note_actions.open_note_externally_title")}">
<span class="bx bx-file-find"></span> ${t("note_actions.open_note_externally")}<kbd data-command="openNoteExternally"></kbd>
</li>
<li data-trigger-command="openNoteCustom" class="dropdown-item open-note-custom-button">
<span class="bx bx-customize"></span> ${t("note_actions.open_note_custom")}<kbd data-command="openNoteCustom"></kbd>
</li>
<li data-trigger-command="showNoteSource" class="dropdown-item show-source-button">
<span class="bx bx-code"></span> ${t("note_actions.note_source")}<kbd data-command="showNoteSource"></kbd>
</li>
<div class="dropdown-divider"></div>
<li data-trigger-command="forceSaveRevision" class="dropdown-item save-revision-button">
<span class="bx bx-save"></span> ${t("note_actions.save_revision")}<kbd data-command="forceSaveRevision"></kbd>
</li>
<li class="dropdown-item delete-note-button"><span class="bx bx-trash destructive-action-icon"></span> ${t("note_actions.delete_note")}</li>
<div class="dropdown-divider"></div>
<li data-trigger-command="showAttachments" class="dropdown-item show-attachments-button">
<span class="bx bx-paperclip"></span> ${t("note_actions.note_attachments")}<kbd data-command="showAttachments"></kbd>
</li>
</div>
</div>`;
export default class NoteActionsWidget extends NoteContextAwareWidget {
private $convertNoteIntoAttachmentButton!: JQuery<HTMLElement>;
private $findInTextButton!: JQuery<HTMLElement>;
private $printActiveNoteButton!: JQuery<HTMLElement>;
private $exportAsPdfButton!: JQuery<HTMLElement>;
private $showSourceButton!: JQuery<HTMLElement>;
private $showAttachmentsButton!: JQuery<HTMLElement>;
private $renderNoteButton!: JQuery<HTMLElement>;
private $saveRevisionButton!: JQuery<HTMLElement>;
private $exportNoteButton!: JQuery<HTMLElement>;
private $importNoteButton!: JQuery<HTMLElement>;
private $openNoteExternallyButton!: JQuery<HTMLElement>;
private $openNoteCustomButton!: JQuery<HTMLElement>;
private $deleteNoteButton!: JQuery<HTMLElement>;
isEnabled() {
return this.note?.type !== "launcher";
}
doRender() {
this.$widget = $(TPL);
this.$widget.on("show.bs.dropdown", () => {
if (this.note) {
this.refreshVisibility(this.note);
}
});
this.$convertNoteIntoAttachmentButton = this.$widget.find("[data-trigger-command='convertNoteIntoAttachment']");
this.$findInTextButton = this.$widget.find(".find-in-text-button");
this.$printActiveNoteButton = this.$widget.find(".print-active-note-button");
this.$exportAsPdfButton = this.$widget.find(".export-as-pdf-button");
this.$showSourceButton = this.$widget.find(".show-source-button");
this.$showAttachmentsButton = this.$widget.find(".show-attachments-button");
this.$renderNoteButton = this.$widget.find(".render-note-button");
this.$saveRevisionButton = this.$widget.find(".save-revision-button");
this.$exportNoteButton = this.$widget.find(".export-note-button");
this.$exportNoteButton.on("click", () => {
if (this.$exportNoteButton.hasClass("disabled") || !this.noteContext?.notePath) {
return;
}
this.triggerCommand("showExportDialog", {
notePath: this.noteContext.notePath,
defaultType: "single"
});
});
this.$importNoteButton = this.$widget.find(".import-files-button");
this.$importNoteButton.on("click", () => {
if (this.noteId) {
this.triggerCommand("showImportDialog", { noteId: this.noteId });
}
});
this.$widget.on("click", ".dropdown-item", () => this.$widget.find("[data-bs-toggle='dropdown']").dropdown("toggle"));
this.$openNoteExternallyButton = this.$widget.find(".open-note-externally-button");
this.$openNoteCustomButton = this.$widget.find(".open-note-custom-button");
this.$deleteNoteButton = this.$widget.find(".delete-note-button");
this.$deleteNoteButton.on("click", () => {
if (!this.note || this.note.noteId === "root") {
return;
}
branchService.deleteNotes([this.note.getParentBranches()[0].branchId], true);
});
}
async refreshVisibility(note: FNote) {
const isInOptions = note.noteId.startsWith("_options");
this.$convertNoteIntoAttachmentButton.toggle(note.isEligibleForConversionToAttachment());
this.toggleDisabled(this.$findInTextButton, ["text", "code", "book"].includes(note.type));
this.toggleDisabled(this.$showAttachmentsButton, !isInOptions);
this.toggleDisabled(this.$showSourceButton, ["text", "code", "relationMap", "mermaid", "canvas", "mindMap", "geoMap"].includes(note.type));
const canPrint = ["text", "code"].includes(note.type);
this.toggleDisabled(this.$printActiveNoteButton, canPrint);
this.toggleDisabled(this.$exportAsPdfButton, canPrint);
this.$exportAsPdfButton.toggleClass("hidden-ext", !utils.isElectron());
this.$renderNoteButton.toggle(note.type === "render");
this.toggleDisabled(this.$openNoteExternallyButton, utils.isElectron() && !["search", "book"].includes(note.type));
this.toggleDisabled(
this.$openNoteCustomButton,
utils.isElectron() &&
!utils.isMac() && // no implementation for Mac yet
!["search", "book"].includes(note.type)
);
// I don't want to handle all special notes like this, but intuitively user might want to export content of backend log
this.toggleDisabled(this.$exportNoteButton, !["_backendLog"].includes(note.noteId) && !isInOptions);
this.toggleDisabled(this.$importNoteButton, !["search"].includes(note.type) && !isInOptions);
this.toggleDisabled(this.$deleteNoteButton, !isInOptions);
this.toggleDisabled(this.$saveRevisionButton, !isInOptions);
}
async convertNoteIntoAttachmentCommand() {
if (!this.note || !(await dialogService.confirm(t("note_actions.convert_into_attachment_prompt", { title: this.note.title })))) {
return;
}
const { attachment: newAttachment } = await server.post<ConvertToAttachmentResponse>(`notes/${this.noteId}/convert-to-attachment`);
if (!newAttachment) {
toastService.showMessage(t("note_actions.convert_into_attachment_failed", { title: this.note.title }));
return;
}
toastService.showMessage(t("note_actions.convert_into_attachment_successful", { title: newAttachment.title }));
await ws.waitForMaxKnownEntityChangeId();
await appContext.tabManager.getActiveContext()?.setNote(newAttachment.ownerId, {
viewScope: {
viewMode: "attachments",
attachmentId: newAttachment.attachmentId
}
});
}
toggleDisabled($el: JQuery<HTMLElement>, enable: boolean) {
if (enable) {
$el.removeAttr("disabled");
} else {
$el.attr("disabled", "disabled");
}
}
entitiesReloadedEvent({ loadResults }: EventData<"entitiesReloaded">) {
if (loadResults.isNoteReloaded(this.noteId)) {
this.refresh();
}
}
}

View File

@@ -0,0 +1,58 @@
import AbstractButtonWidget, { type AbstractButtonWidgetSettings } from "./abstract_button.js";
import { t } from "../../services/i18n.js";
export type ClickHandler = (widget: OnClickButtonWidget, e: JQuery.ClickEvent<any, any, any, any>) => void;
export type AuxClickHandler = (widget: OnClickButtonWidget, e: JQuery.TriggeredEvent<any, any, any, any>) => void;
interface OnClickButtonWidgetSettings extends AbstractButtonWidgetSettings {
onClick?: ClickHandler;
onAuxClick?: AuxClickHandler;
}
export default class OnClickButtonWidget extends AbstractButtonWidget<OnClickButtonWidgetSettings> {
constructor() {
super();
this.settings = {
titlePlacement: "right",
title: null,
icon: null,
onContextMenu: null
};
}
doRender() {
super.doRender();
if (this.settings.onClick) {
this.$widget.on("click", (e) => {
this.$widget.tooltip("hide");
if (this.settings.onClick) {
this.settings.onClick(this, e);
}
});
} else {
console.warn(t("onclick_button.no_click_handler", { componentId: this.componentId }), this.settings);
}
if (this.settings.onAuxClick) {
this.$widget.on("auxclick", (e) => {
this.$widget.tooltip("hide");
if (this.settings.onAuxClick) {
this.settings.onAuxClick(this, e);
}
});
}
}
onClick(handler: ClickHandler) {
this.settings.onClick = handler;
return this;
}
onAuxClick(handler: AuxClickHandler) {
this.settings.onAuxClick = handler;
return this;
}
}

View File

@@ -0,0 +1,43 @@
import OnClickButtonWidget from "./onclick_button.js";
import linkContextMenuService from "../../menus/link_context_menu.js";
import utils from "../../services/utils.js";
import appContext from "../../components/app_context.js";
import type FNote from "../../entities/fnote.js";
export default class OpenNoteButtonWidget extends OnClickButtonWidget {
private noteToOpen: FNote;
constructor(noteToOpen: FNote) {
super();
this.noteToOpen = noteToOpen;
this.title(() => utils.escapeHtml(this.noteToOpen.title))
.icon(() => this.noteToOpen.getIcon())
.onClick((widget, evt) => this.launch(evt))
.onAuxClick((widget, evt) => this.launch(evt))
.onContextMenu((evt) => {
if (evt) {
linkContextMenuService.openContextMenu(this.noteToOpen.noteId, evt);
}
});
}
async launch(evt: JQuery.ClickEvent | JQuery.TriggeredEvent | JQuery.ContextMenuEvent) {
if (evt.which === 3) {
return;
}
const ctrlKey = utils.isCtrlKey(evt);
if ((evt.which === 1 && ctrlKey) || evt.which === 2) {
await appContext.tabManager.openInNewTab(this.noteToOpen.noteId);
} else {
await appContext.tabManager.openInSameTab(this.noteToOpen.noteId);
}
}
initialRenderCompleteEvent() {
// we trigger refresh above
}
}

View File

@@ -0,0 +1,21 @@
import { t } from "../../services/i18n.js";
import protectedSessionHolder from "../../services/protected_session_holder.js";
import CommandButtonWidget from "./command_button.js";
export default class ProtectedSessionStatusWidget extends CommandButtonWidget {
constructor() {
super();
this.class("launcher-button");
this.settings.icon = () => (protectedSessionHolder.isProtectedSessionAvailable() ? "bx-check-shield" : "bx-shield-quarter");
this.settings.title = () => (protectedSessionHolder.isProtectedSessionAvailable() ? t("protected_session_status.active") : t("protected_session_status.inactive"));
this.settings.command = () => (protectedSessionHolder.isProtectedSessionAvailable() ? "leaveProtectedSession" : "enterProtectedSession");
}
protectedSessionStartedEvent() {
this.refreshIcon();
}
}

View File

@@ -0,0 +1,14 @@
import { t } from "../../services/i18n.js";
import CommandButtonWidget from "./command_button.js";
export default class RevisionsButton extends CommandButtonWidget {
constructor() {
super();
this.icon("bx-history").title(t("revisions_button.note_revisions")).command("showRevisions").titlePlacement("bottom").class("icon-action");
}
isEnabled() {
return super.isEnabled() && !["launcher", "doc"].includes(this.note?.type ?? "");
}
}

View File

@@ -0,0 +1,80 @@
import BasicWidget from "../basic_widget.js";
import { Tooltip, Dropdown } from "bootstrap";
type PopoverPlacement = Tooltip.PopoverPlacement;
const TPL = /*html*/`
<div class="dropdown right-dropdown-widget">
<button type="button" data-bs-toggle="dropdown"
aria-haspopup="true" aria-expanded="false"
class="bx right-dropdown-button launcher-button"></button>
<div class="tooltip-trigger"></div>
<div class="dropdown-menu"></div>
</div>
`;
export default class RightDropdownButtonWidget extends BasicWidget {
protected iconClass: string;
protected title: string;
protected dropdownTpl: string;
protected settings: { titlePlacement: PopoverPlacement };
protected $dropdownMenu!: JQuery<HTMLElement>;
protected dropdown!: Dropdown;
protected $tooltip!: JQuery<HTMLElement>;
protected tooltip!: Tooltip;
public $dropdownContent!: JQuery<HTMLElement>;
constructor(title: string, iconClass: string, dropdownTpl: string) {
super();
this.iconClass = iconClass;
this.title = title;
this.dropdownTpl = dropdownTpl;
this.settings = {
titlePlacement: "right"
};
}
doRender() {
this.$widget = $(TPL);
this.$dropdownMenu = this.$widget.find(".dropdown-menu");
this.dropdown = Dropdown.getOrCreateInstance(this.$widget.find("[data-bs-toggle='dropdown']")[0], {
popperConfig: {
placement: this.settings.titlePlacement,
}
});
this.$tooltip = this.$widget.find(".tooltip-trigger").attr("title", this.title);
this.tooltip = new Tooltip(this.$tooltip[0], {
placement: this.settings.titlePlacement,
fallbackPlacements: [this.settings.titlePlacement]
});
this.$widget
.find(".right-dropdown-button")
.addClass(this.iconClass)
.on("click", () => this.tooltip.hide())
.on("mouseenter", () => this.tooltip.show())
.on("mouseleave", () => this.tooltip.hide());
this.$widget.on("show.bs.dropdown", async () => {
await this.dropdownShown();
const rect = this.$dropdownMenu[0].getBoundingClientRect();
const windowHeight = $(window).height() || 0;
const pixelsToBottom = windowHeight - rect.bottom;
if (pixelsToBottom < 0) {
this.$dropdownMenu.css("top", pixelsToBottom);
}
});
this.$dropdownContent = $(this.dropdownTpl);
this.$widget.find(".dropdown-menu").append(this.$dropdownContent);
}
// to be overridden
async dropdownShown(): Promise<void> {}
}

View File

@@ -0,0 +1,62 @@
import OnClickButtonWidget from "./onclick_button.js";
import appContext from "../../components/app_context.js";
import attributeService from "../../services/attributes.js";
import { t } from "../../services/i18n.js";
import LoadResults from "../../services/load_results.js";
import type { AttributeRow } from "../../services/load_results.js";
export default class ShowHighlightsListWidgetButton extends OnClickButtonWidget {
isEnabled(): boolean {
return Boolean(super.isEnabled() && this.note && this.note.type === "text" && this.noteContext?.viewScope?.viewMode === "default");
}
constructor() {
super();
this.icon("bx-bookmarks")
.title(t("show_highlights_list_widget_button.show_highlights_list"))
.titlePlacement("bottom")
.onClick(() => {
if (this.noteContext?.viewScope && this.noteId) {
this.noteContext.viewScope.highlightsListTemporarilyHidden = false;
appContext.triggerEvent("showHighlightsListWidget", { noteId: this.noteId });
}
this.toggleInt(false);
});
}
async refreshWithNote(): Promise<void> {
if (this.noteContext?.viewScope) {
this.toggleInt(this.noteContext.viewScope.highlightsListTemporarilyHidden);
}
}
async reEvaluateHighlightsListWidgetVisibilityEvent({ noteId }: { noteId: string }): Promise<void> {
if (noteId === this.noteId) {
await this.refresh();
}
}
async entitiesReloadedEvent({ loadResults }: { loadResults: LoadResults }): Promise<void> {
if (this.noteId && loadResults.isNoteContentReloaded(this.noteId)) {
await this.refresh();
} else if (
loadResults
.getAttributeRows()
.find((attr: AttributeRow) =>
attr.type === "label" &&
(attr.name?.toLowerCase().includes("readonly") || attr.name === "hideHighlightWidget") &&
this.note &&
attributeService.isAffecting(attr, this.note)
)
) {
await this.refresh();
}
}
async noteTypeMimeChangedEvent({ noteId }: { noteId: string }): Promise<void> {
if (this.isNote(noteId)) {
await this.refresh();
}
}
}

View File

@@ -0,0 +1,62 @@
import OnClickButtonWidget from "./onclick_button.js";
import appContext from "../../components/app_context.js";
import attributeService from "../../services/attributes.js";
import { t } from "../../services/i18n.js";
import LoadResults from "../../services/load_results.js";
import type { AttributeRow } from "../../services/load_results.js";
export default class ShowTocWidgetButton extends OnClickButtonWidget {
isEnabled(): boolean {
return Boolean(super.isEnabled() && this.note && this.note.type === "text" && this.noteContext?.viewScope?.viewMode === "default");
}
constructor() {
super();
this.icon("bx-tn-toc")
.title(t("show_toc_widget_button.show_toc"))
.titlePlacement("bottom")
.onClick(() => {
if (this.noteContext?.viewScope && this.noteId) {
this.noteContext.viewScope.tocTemporarilyHidden = false;
appContext.triggerEvent("showTocWidget", { noteId: this.noteId });
}
this.toggleInt(false);
});
}
async refreshWithNote(): Promise<void> {
if (this.noteContext?.viewScope) {
this.toggleInt(this.noteContext.viewScope.tocTemporarilyHidden);
}
}
async reEvaluateTocWidgetVisibilityEvent({ noteId }: { noteId: string }): Promise<void> {
if (noteId === this.noteId) {
await this.refresh();
}
}
async entitiesReloadedEvent({ loadResults }: { loadResults: LoadResults }): Promise<void> {
if (this.noteId && loadResults.isNoteContentReloaded(this.noteId)) {
await this.refresh();
} else if (
loadResults
.getAttributeRows()
.find((attr: AttributeRow) =>
attr.type === "label" &&
(attr.name?.toLowerCase().includes("readonly") || attr.name === "toc") &&
this.note &&
attributeService.isAffecting(attr, this.note)
)
) {
await this.refresh();
}
}
async noteTypeMimeChangedEvent({ noteId }: { noteId: string }): Promise<void> {
if (this.isNote(noteId)) {
await this.refresh();
}
}
}

View File

@@ -0,0 +1,40 @@
import { t } from "../../services/i18n.js";
import BasicWidget from "../basic_widget.js";
import utils from "../../services/utils.js";
const TPL = /*html*/`
<div style="display: none;">
<style>
.global-menu-button-update-available-button {
width: 21px !important;
height: 21px !important;
padding: 0 !important;
border-radius: var(--button-border-radius);
transform: scale(0.9);
border: none;
opacity: 0.8;
display: flex;
align-items: center;
justify-content: center;
}
.global-menu-button-wrapper:hover .global-menu-button-update-available-button {
opacity: 1;
}
</style>
<span class="bx bx-sync global-menu-button-update-available-button" title="${t("update_available.update_available")}"></span>
</div>
`;
export default class UpdateAvailableWidget extends BasicWidget {
doRender() {
this.$widget = $(TPL);
}
updateVersionStatus(latestVersion: string) {
this.$widget.toggle(utils.isUpdateAvailable(latestVersion, glob.triliumVersion));
}
}

View File

@@ -0,0 +1,49 @@
import BasicWidget from "./basic_widget.js";
import { t } from "../services/i18n.js";
const TPL = /*html*/`\
<div class="close-zen-container">
<button class="button-widget bx icon-action bxs-yin-yang"
data-trigger-command="toggleZenMode"
title="${t("zen_mode.button_exit")}"
/>
<style>
:root {
--zen-button-size: 32px;
}
.close-zen-container {
display: none;
width: var(--zen-button-size);
height: var(--zen-button-size);
}
body.zen .close-zen-container {
display: block;
position: fixed;
top: 2px;
right: 2px;
z-index: 9999;
-webkit-app-region: no-drag;
}
body.zen.electron:not(.platform-darwin):not(.native-titlebar) .close-zen-container {
left: calc(env(titlebar-area-width) - var(--zen-button-size) - 2px);
right: unset;
}
</style>
</div>
`;
export default class CloseZenButton extends BasicWidget {
doRender(): void {
this.$widget = $(TPL);
}
zenChangedEvent() {
this.toggleInt(true);
}
}

View File

@@ -0,0 +1,27 @@
import type { default as Component, TypedComponent } from "../../components/component.js";
import BasicWidget, { TypedBasicWidget } from "../basic_widget.js";
export default class Container<T extends TypedComponent<any>> extends TypedBasicWidget<T> {
doRender() {
this.$widget = $(`<div>`);
this.renderChildren();
}
renderChildren() {
for (const widget of this.children) {
if (!("render" in widget)) {
throw "Non-renderable widget encountered.";
}
const typedWidget = widget as unknown as TypedBasicWidget<any>;
try {
if ("render" in widget) {
this.$widget.append(typedWidget.render());
}
} catch (e: any) {
typedWidget.logRenderingError(e);
}
}
}
}

View File

@@ -0,0 +1,16 @@
import type { TypedComponent } from "../../components/component.js";
import Container from "./container.js";
export type FlexDirection = "row" | "column";
export default class FlexContainer<T extends TypedComponent<any>> extends Container<T> {
constructor(direction: FlexDirection) {
super();
if (!direction || !["row", "column"].includes(direction)) {
throw new Error(`Direction argument given as '${direction}', use either 'row' or 'column'`);
}
this.attrs.style = `display: flex; flex-direction: ${direction};`;
}
}

View File

@@ -0,0 +1,133 @@
import CalendarWidget from "../buttons/calendar.js";
import SpacerWidget from "../spacer.js";
import BookmarkButtons from "../bookmark_buttons.js";
import ProtectedSessionStatusWidget from "../buttons/protected_session_status.js";
import SyncStatusWidget from "../sync_status.js";
import BasicWidget from "../basic_widget.js";
import NoteLauncher from "../buttons/launcher/note_launcher.js";
import ScriptLauncher from "../buttons/launcher/script_launcher.js";
import CommandButtonWidget from "../buttons/command_button.js";
import utils from "../../services/utils.js";
import TodayLauncher from "../buttons/launcher/today_launcher.js";
import HistoryNavigationButton from "../buttons/history_navigation.js";
import QuickSearchLauncherWidget from "../quick_search_launcher.js";
import type FNote from "../../entities/fnote.js";
import type { CommandNames } from "../../components/app_context.js";
import AiChatButton from "../buttons/ai_chat_button.js";
interface InnerWidget extends BasicWidget {
settings?: {
titlePlacement: "bottom";
};
}
export default class LauncherWidget extends BasicWidget {
private innerWidget!: InnerWidget;
private isHorizontalLayout: boolean;
constructor(isHorizontalLayout: boolean) {
super();
this.isHorizontalLayout = isHorizontalLayout;
}
isEnabled() {
return this.innerWidget.isEnabled();
}
doRender() {
this.$widget = this.innerWidget.render();
}
async initLauncher(note: FNote) {
if (note.type !== "launcher") {
throw new Error(`Note '${note.noteId}' '${note.title}' is not a launcher even though it's in the launcher subtree`);
}
if (!utils.isDesktop() && note.isLabelTruthy("desktopOnly")) {
return false;
}
const launcherType = note.getLabelValue("launcherType");
if (glob.TRILIUM_SAFE_MODE && launcherType === "customWidget") {
return false;
}
let widget: BasicWidget;
if (launcherType === "command") {
widget = this.initCommandLauncherWidget(note).class("launcher-button");
} else if (launcherType === "note") {
widget = new NoteLauncher(note).class("launcher-button");
} else if (launcherType === "script") {
widget = new ScriptLauncher(note).class("launcher-button");
} else if (launcherType === "customWidget") {
widget = await this.initCustomWidget(note);
} else if (launcherType === "builtinWidget") {
widget = this.initBuiltinWidget(note);
} else {
throw new Error(`Unrecognized launcher type '${launcherType}' for launcher '${note.noteId}' title '${note.title}'`);
}
if (!widget) {
throw new Error(`Unknown initialization error for note '${note.noteId}', title '${note.title}'`);
}
this.child(widget);
this.innerWidget = widget as InnerWidget;
if (this.isHorizontalLayout && this.innerWidget.settings) {
this.innerWidget.settings.titlePlacement = "bottom";
}
return true;
}
initCommandLauncherWidget(note: FNote) {
return new CommandButtonWidget()
.title(() => note.title)
.icon(() => note.getIcon())
.command(() => note.getLabelValue("command") as CommandNames);
}
async initCustomWidget(note: FNote) {
const widget = await note.getRelationTarget("widget");
if (widget) {
return await widget.executeScript();
} else {
throw new Error(`Custom widget of launcher '${note.noteId}' '${note.title}' is not defined.`);
}
}
initBuiltinWidget(note: FNote) {
const builtinWidget = note.getLabelValue("builtinWidget");
switch (builtinWidget) {
case "calendar":
return new CalendarWidget(note.title, note.getIcon());
case "spacer":
// || has to be inside since 0 is a valid value
const baseSize = parseInt(note.getLabelValue("baseSize") || "40");
const growthFactor = parseInt(note.getLabelValue("growthFactor") || "100");
return new SpacerWidget(baseSize, growthFactor);
case "bookmarks":
return new BookmarkButtons(this.isHorizontalLayout);
case "protectedSession":
return new ProtectedSessionStatusWidget();
case "syncStatus":
return new SyncStatusWidget();
case "backInHistoryButton":
return new HistoryNavigationButton(note, "backInNoteHistory");
case "forwardInHistoryButton":
return new HistoryNavigationButton(note, "forwardInNoteHistory");
case "todayInJournal":
return new TodayLauncher(note);
case "quickSearch":
return new QuickSearchLauncherWidget(this.isHorizontalLayout);
case "aiChatLauncher":
return new AiChatButton(note);
default:
throw new Error(`Unrecognized builtin widget ${builtinWidget} for launcher ${note.noteId} "${note.title}"`);
}
}
}

View File

@@ -0,0 +1,78 @@
import FlexContainer from "./flex_container.js";
import froca from "../../services/froca.js";
import appContext, { type EventData } from "../../components/app_context.js";
import LauncherWidget from "./launcher.js";
import utils from "../../services/utils.js";
export default class LauncherContainer extends FlexContainer<LauncherWidget> {
private isHorizontalLayout: boolean;
constructor(isHorizontalLayout: boolean) {
super(isHorizontalLayout ? "row" : "column");
this.id("launcher-container");
this.filling();
this.isHorizontalLayout = isHorizontalLayout;
this.load();
}
async load() {
await froca.initializedPromise;
const visibleLaunchersRootId = utils.isMobile() ? "_lbMobileVisibleLaunchers" : "_lbVisibleLaunchers";
const visibleLaunchersRoot = await froca.getNote(visibleLaunchersRootId, true);
if (!visibleLaunchersRoot) {
console.log("Visible launchers root note doesn't exist.");
return;
}
const newChildren = [];
for (const launcherNote of await visibleLaunchersRoot.getChildNotes()) {
try {
const launcherWidget = new LauncherWidget(this.isHorizontalLayout);
const success = await launcherWidget.initLauncher(launcherNote);
if (success) {
newChildren.push(launcherWidget);
}
} catch (e) {
console.error(e);
}
}
this.children = [];
this.child(...newChildren);
this.$widget.empty();
this.renderChildren();
await this.handleEventInChildren("initialRenderComplete", {});
const activeContext = appContext.tabManager.getActiveContext();
if (activeContext) {
await this.handleEvent("setNoteContext", {
noteContext: activeContext
});
if (activeContext.notePath) {
await this.handleEvent("noteSwitched", {
noteContext: activeContext,
notePath: activeContext.notePath
});
}
}
}
entitiesReloadedEvent({ loadResults }: EventData<"entitiesReloaded">) {
if (loadResults.getBranchRows().find((branch) => branch.parentNoteId && froca.getNoteFromCache(branch.parentNoteId)?.isLaunchBarConfig())) {
// changes in note placement require reload of all launchers, all other changes are handled by individual
// launchers
this.load();
}
}
}

View File

@@ -0,0 +1,31 @@
import options from "../../services/options.js";
import FlexContainer from "./flex_container.js";
import appContext, { type EventData } from "../../components/app_context.js";
import type Component from "../../components/component.js";
export default class LeftPaneContainer extends FlexContainer<Component> {
constructor() {
super("column");
this.id("left-pane");
this.css("height", "100%");
this.collapsible();
}
isEnabled() {
return super.isEnabled() && options.is("leftPaneVisible");
}
entitiesReloadedEvent({ loadResults }: EventData<"entitiesReloaded">) {
if (loadResults.isOptionReloaded("leftPaneVisible")) {
const visible = this.isEnabled();
this.toggleInt(visible);
if (visible) {
this.triggerEvent("focusTree", {});
} else {
this.triggerEvent("focusOnDetail", { ntxId: appContext.tabManager.getActiveContext()?.ntxId });
}
}
}
}

View File

@@ -0,0 +1,388 @@
import NoteContextAwareWidget from "../note_context_aware_widget.js";
import keyboardActionsService from "../../services/keyboard_actions.js";
import attributeService from "../../services/attributes.js";
import type CommandButtonWidget from "../buttons/command_button.js";
import type FNote from "../../entities/fnote.js";
import type { NoteType } from "../../entities/fnote.js";
import type { EventData, EventNames } from "../../components/app_context.js";
import type NoteActionsWidget from "../buttons/note_actions.js";
const TPL = /*html*/`
<div class="ribbon-container">
<style>
.ribbon-container {
margin-bottom: 5px;
}
.ribbon-top-row {
display: flex;
}
.ribbon-tab-container {
display: flex;
flex-direction: row;
justify-content: center;
margin-left: 10px;
flex-grow: 1;
flex-flow: row wrap;
}
.ribbon-tab-title {
color: var(--muted-text-color);
border-bottom: 1px solid var(--main-border-color);
min-width: 24px;
flex-basis: 24px;
max-width: max-content;
flex-grow: 10;
}
.ribbon-tab-title .bx {
font-size: 150%;
position: relative;
top: 3px;
}
.ribbon-tab-title.active {
color: var(--main-text-color);
border-bottom: 3px solid var(--main-text-color);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.ribbon-tab-title:hover {
cursor: pointer;
}
.ribbon-tab-title:hover {
color: var(--main-text-color);
}
.ribbon-tab-title:first-of-type {
padding-left: 10px;
}
.ribbon-tab-spacer {
flex-basis: 0;
min-width: 0;
max-width: 35px;
flex-grow: 1;
border-bottom: 1px solid var(--main-border-color);
}
.ribbon-tab-spacer:last-of-type {
flex-grow: 1;
flex-basis: 0;
min-width: 0;
max-width: 10000px;
}
.ribbon-button-container {
display: flex;
border-bottom: 1px solid var(--main-border-color);
margin-right: 5px;
}
.ribbon-button-container > * {
position: relative;
top: -3px;
margin-left: 10px;
}
.ribbon-body {
display: none;
border-bottom: 1px solid var(--main-border-color);
margin-left: 10px;
margin-right: 5px; /* needs to have this value so that the bottom border is the same width as the top one */
}
.ribbon-body.active {
display: block;
}
.ribbon-tab-title-label {
display: none;
}
.ribbon-tab-title.active .ribbon-tab-title-label {
display: inline;
}
</style>
<div class="ribbon-top-row">
<div class="ribbon-tab-container"></div>
<div class="ribbon-button-container"></div>
</div>
<div class="ribbon-body-container"></div>
</div>`;
type ButtonWidget = (CommandButtonWidget | NoteActionsWidget);
export default class RibbonContainer extends NoteContextAwareWidget {
private lastActiveComponentId?: string | null;
private lastNoteType?: NoteType;
private ribbonWidgets: NoteContextAwareWidget[];
private buttonWidgets: ButtonWidget[];
private $tabContainer!: JQuery<HTMLElement>;
private $buttonContainer!: JQuery<HTMLElement>;
private $bodyContainer!: JQuery<HTMLElement>;
constructor() {
super();
this.contentSized();
this.ribbonWidgets = [];
this.buttonWidgets = [];
}
isEnabled() {
return super.isEnabled() && this.noteContext?.viewScope?.viewMode === "default";
}
ribbon(widget: NoteContextAwareWidget) {
// TODO: Base class
super.child(widget);
this.ribbonWidgets.push(widget);
return this;
}
button(widget: ButtonWidget) {
super.child(widget);
this.buttonWidgets.push(widget);
return this;
}
doRender() {
this.$widget = $(TPL);
this.$tabContainer = this.$widget.find(".ribbon-tab-container");
this.$buttonContainer = this.$widget.find(".ribbon-button-container");
this.$bodyContainer = this.$widget.find(".ribbon-body-container");
for (const ribbonWidget of this.ribbonWidgets) {
this.$bodyContainer.append($('<div class="ribbon-body">').attr("data-ribbon-component-id", ribbonWidget.componentId).append(ribbonWidget.render()));
}
for (const buttonWidget of this.buttonWidgets) {
this.$buttonContainer.append(buttonWidget.render());
}
this.$tabContainer.on("click", ".ribbon-tab-title", (e) => {
const $ribbonTitle = $(e.target).closest(".ribbon-tab-title");
this.toggleRibbonTab($ribbonTitle);
});
}
toggleRibbonTab($ribbonTitle: JQuery<HTMLElement>, refreshActiveTab = true) {
const activate = !$ribbonTitle.hasClass("active");
this.$tabContainer.find(".ribbon-tab-title").removeClass("active");
this.$bodyContainer.find(".ribbon-body").removeClass("active");
if (activate) {
const ribbonComponendId = $ribbonTitle.attr("data-ribbon-component-id");
const wasAlreadyActive = this.lastActiveComponentId === ribbonComponendId;
this.lastActiveComponentId = ribbonComponendId;
this.$tabContainer.find(`.ribbon-tab-title[data-ribbon-component-id="${ribbonComponendId}"]`).addClass("active");
this.$bodyContainer.find(`.ribbon-body[data-ribbon-component-id="${ribbonComponendId}"]`).addClass("active");
const activeChild = this.getActiveRibbonWidget();
if (activeChild && (refreshActiveTab || !wasAlreadyActive) && this.noteContext && this.notePath) {
const handleEventPromise = activeChild.handleEvent("noteSwitched", { noteContext: this.noteContext, notePath: this.notePath });
if (refreshActiveTab) {
if (handleEventPromise) {
handleEventPromise.then(() => (activeChild as any).focus?.()); // TODO: Base class
} else {
// TODO: Base class
(activeChild as any).focus?.();
}
}
}
} else {
this.lastActiveComponentId = null;
}
}
async noteSwitched() {
this.lastActiveComponentId = null;
await super.noteSwitched();
}
async refreshWithNote(note: FNote, noExplicitActivation = false) {
this.lastNoteType = note.type;
let $ribbonTabToActivate, $lastActiveRibbon;
this.$tabContainer.empty();
for (const ribbonWidget of this.ribbonWidgets) {
// TODO: Base class for ribbon widget
const ret = await (ribbonWidget as any).getTitle(note);
if (!ret.show) {
continue;
}
const $ribbonTitle = $('<div class="ribbon-tab-title">')
.attr("data-ribbon-component-id", ribbonWidget.componentId)
.attr("data-ribbon-component-name", (ribbonWidget as any).name as string) // TODO: base class for ribbon widgets
.append(
$('<span class="ribbon-tab-title-icon">')
.addClass(ret.icon)
.attr("title", ret.title)
.attr("data-toggle-command", (ribbonWidget as any).toggleCommand)
) // TODO: base class
.append(" ")
.append($('<span class="ribbon-tab-title-label">').text(ret.title));
this.$tabContainer.append($ribbonTitle);
this.$tabContainer.append('<div class="ribbon-tab-spacer">');
if (ret.activate && !this.lastActiveComponentId && !$ribbonTabToActivate && !noExplicitActivation) {
$ribbonTabToActivate = $ribbonTitle;
}
if (this.lastActiveComponentId === ribbonWidget.componentId) {
$lastActiveRibbon = $ribbonTitle;
}
}
keyboardActionsService.getActions().then((actions) => {
this.$tabContainer.find(".ribbon-tab-title-icon").tooltip({
title: () => {
const toggleCommandName = $(this).attr("data-toggle-command");
const action = actions.find((act) => act.actionName === toggleCommandName);
const title = $(this).attr("data-title");
if (action && action.effectiveShortcuts.length > 0) {
return `${title} (${action.effectiveShortcuts.join(", ")})`;
} else {
return title ?? "";
}
}
});
});
if (!$ribbonTabToActivate) {
$ribbonTabToActivate = $lastActiveRibbon;
}
if ($ribbonTabToActivate) {
this.toggleRibbonTab($ribbonTabToActivate, false);
} else {
this.$bodyContainer.find(".ribbon-body").removeClass("active");
}
}
isRibbonTabActive(name: string) {
const $ribbonComponent = this.$widget.find(`.ribbon-tab-title[data-ribbon-component-name='${name}']`);
return $ribbonComponent.hasClass("active");
}
ensureOwnedAttributesAreOpen(ntxId: string | null | undefined) {
if (ntxId && this.isNoteContext(ntxId) && !this.isRibbonTabActive("ownedAttributes")) {
this.toggleRibbonTabWithName("ownedAttributes", ntxId);
}
}
addNewLabelEvent({ ntxId }: EventData<"addNewLabel">) {
this.ensureOwnedAttributesAreOpen(ntxId);
}
addNewRelationEvent({ ntxId }: EventData<"addNewRelation">) {
this.ensureOwnedAttributesAreOpen(ntxId);
}
toggleRibbonTabWithName(name: string, ntxId?: string) {
if (!this.isNoteContext(ntxId)) {
return false;
}
const $ribbonComponent = this.$widget.find(`.ribbon-tab-title[data-ribbon-component-name='${name}']`);
if ($ribbonComponent) {
this.toggleRibbonTab($ribbonComponent);
}
}
handleEvent<T extends EventNames>(name: T, data: EventData<T>) {
const PREFIX = "toggleRibbonTab";
if (name.startsWith(PREFIX)) {
let componentName = name.substr(PREFIX.length);
componentName = componentName[0].toLowerCase() + componentName.substr(1);
this.toggleRibbonTabWithName(componentName, (data as any).ntxId);
} else {
return super.handleEvent(name, data);
}
}
async handleEventInChildren<T extends EventNames>(name: T, data: EventData<T>) {
if (["activeContextChanged", "setNoteContext"].includes(name)) {
// won't trigger .refresh();
await super.handleEventInChildren("setNoteContext", data as EventData<"activeContextChanged" | "setNoteContext">);
} else if (this.isEnabled() || name === "initialRenderComplete") {
const activeRibbonWidget = this.getActiveRibbonWidget();
// forward events only to active ribbon tab, inactive ones don't need to be updated
if (activeRibbonWidget) {
await activeRibbonWidget.handleEvent(name, data);
}
for (const buttonWidget of this.buttonWidgets) {
await buttonWidget.handleEvent(name, data);
}
}
}
entitiesReloadedEvent({ loadResults }: EventData<"entitiesReloaded">) {
if (!this.note) {
return;
}
if (this.noteId && loadResults.isNoteReloaded(this.noteId) && this.lastNoteType !== this.note.type) {
// note type influences the list of available ribbon tabs the most
// check for the type is so that we don't update on each title rename
this.lastNoteType = this.note.type;
this.refresh();
} else if (loadResults.getAttributeRows(this.componentId).find((attr) => attributeService.isAffecting(attr, this.note))) {
this.refreshWithNote(this.note, true);
}
}
async noteTypeMimeChangedEvent() {
// We are ignoring the event which triggers a refresh since it is usually already done by a different
// event and causing a race condition in which the items appear twice.
}
/**
* Executed as soon as the user presses the "Edit" floating button in a read-only text note.
*
* <p>
* We need to refresh the ribbon for cases such as the classic editor which relies on the read-only state.
*/
readOnlyTemporarilyDisabledEvent() {
this.refresh();
}
getActiveRibbonWidget() {
return this.ribbonWidgets.find((ch) => ch.componentId === this.lastActiveComponentId);
}
}

View File

@@ -0,0 +1,56 @@
import FlexContainer from "./flex_container.js";
import splitService from "../../services/resizer.js";
import type RightPanelWidget from "../right_panel_widget.js";
import type { EventData, EventNames } from "../../components/app_context.js";
export default class RightPaneContainer extends FlexContainer<RightPanelWidget> {
private rightPaneHidden: boolean;
constructor() {
super("column");
this.id("right-pane");
this.css("height", "100%");
this.collapsible();
this.rightPaneHidden = false;
}
isEnabled() {
return super.isEnabled() && !this.rightPaneHidden && this.children.length > 0 && !!this.children.find((ch) => ch.isEnabled() && ch.canBeShown());
}
async handleEventInChildren<T extends EventNames>(name: T, data: EventData<T>) {
const promise = super.handleEventInChildren(name, data);
if (["activeContextChanged", "noteSwitchedAndActivated", "noteSwitched"].includes(name)) {
// the right pane is displayed only if some child widget is active,
// we'll reevaluate the visibility based on events which are probable to cause visibility change
// but these events need to be finished and only then we check
if (promise) {
promise.then(() => this.reEvaluateRightPaneVisibilityCommand());
} else {
this.reEvaluateRightPaneVisibilityCommand();
}
}
return promise;
}
reEvaluateRightPaneVisibilityCommand() {
const oldToggle = !this.isHiddenInt();
const newToggle = this.isEnabled();
if (oldToggle !== newToggle) {
this.toggleInt(newToggle);
splitService.setupRightPaneResizer();
}
}
toggleRightPaneEvent() {
this.rightPaneHidden = !this.rightPaneHidden;
this.reEvaluateRightPaneVisibilityCommand();
}
}

View File

@@ -0,0 +1,43 @@
import utils from "../../services/utils.js";
import type BasicWidget from "../basic_widget.js";
import FlexContainer from "./flex_container.js";
/**
* The root container is the top-most widget/container, from which the entire layout derives.
*
* For convenience, the root container has a few class selectors that can be used to target some global state:
*
* - `#root-container.virtual-keyboard-opened`, on mobile devices if the virtual keyboard is open.
* - `#root-container.horizontal-layout`, if the current layout is horizontal.
* - `#root-container.vertical-layout`, if the current layout is horizontal.
*/
export default class RootContainer extends FlexContainer<BasicWidget> {
private originalViewportHeight: number;
constructor(isHorizontalLayout: boolean) {
super(isHorizontalLayout ? "column" : "row");
this.id("root-widget");
this.css("height", "100dvh");
this.originalViewportHeight = getViewportHeight();
}
render(): JQuery<HTMLElement> {
if (utils.isMobile()) {
window.visualViewport?.addEventListener("resize", () => this.#onMobileResize());
}
return super.render();
}
#onMobileResize() {
const currentViewportHeight = getViewportHeight();
const isKeyboardOpened = (currentViewportHeight < this.originalViewportHeight);
this.$widget.toggleClass("virtual-keyboard-opened", isKeyboardOpened);
}
}
function getViewportHeight() {
return window.visualViewport?.height ?? window.innerHeight;
}

View File

@@ -0,0 +1,57 @@
import type { CommandListenerData, EventData, EventNames } from "../../components/app_context.js";
import type NoteContext from "../../components/note_context.js";
import type BasicWidget from "../basic_widget.js";
import Container from "./container.js";
export default class ScrollingContainer extends Container<BasicWidget> {
private noteContext?: NoteContext;
constructor() {
super();
this.class("scrolling-container");
this.css("overflow", "auto");
this.css("scroll-behavior", "smooth");
this.css("position", "relative");
}
setNoteContextEvent({ noteContext }: EventData<"setNoteContext">) {
this.noteContext = noteContext;
}
async noteSwitchedEvent({ noteContext, notePath }: EventData<"noteSwitched">) {
this.$widget.scrollTop(0);
}
async noteSwitchedAndActivatedEvent({ noteContext, notePath }: EventData<"noteSwitchedAndActivated">) {
this.noteContext = noteContext;
this.$widget.scrollTop(0);
}
async activeContextChangedEvent({ noteContext }: EventData<"activeContextChanged">) {
this.noteContext = noteContext;
}
async handleEventInChildren<T extends EventNames>(name: T, data: EventData<T>) {
if (name === "readOnlyTemporarilyDisabled" && this.noteContext && "noteContext" in data && this.noteContext.ntxId === data.noteContext?.ntxId) {
const scrollTop = this.$widget.scrollTop() ?? 0;
const promise = super.handleEventInChildren(name, data);
// there seems to be some asynchronicity, and we need to wait a bit before scrolling
if (promise) {
promise.then(() => setTimeout(() => this.$widget.scrollTop(scrollTop), 500));
}
return promise;
} else {
return super.handleEventInChildren(name, data);
}
}
scrollContainerToCommand({ position }: CommandListenerData<"scrollContainerToCommand">) {
this.$widget.scrollTop(position);
}
}

View File

@@ -0,0 +1,239 @@
import FlexContainer from "./flex_container.js";
import appContext, { type CommandData, type CommandListenerData, type EventData, type EventNames, type NoteSwitchedContext } from "../../components/app_context.js";
import type BasicWidget from "../basic_widget.js";
import type NoteContext from "../../components/note_context.js";
interface NoteContextEvent {
noteContext: NoteContext;
}
interface SplitNoteWidget extends BasicWidget {
hasBeenAlreadyShown?: boolean;
ntxId?: string;
}
type WidgetFactory = () => SplitNoteWidget;
export default class SplitNoteContainer extends FlexContainer<SplitNoteWidget> {
private widgetFactory: WidgetFactory;
private widgets: Record<string, SplitNoteWidget>;
constructor(widgetFactory: WidgetFactory) {
super("row");
this.widgetFactory = widgetFactory;
this.widgets = {};
this.class("split-note-container-widget");
this.css("flex-grow", "1");
this.collapsible();
}
async newNoteContextCreatedEvent({ noteContext }: EventData<"newNoteContextCreated">) {
const widget = this.widgetFactory();
const $renderedWidget = widget.render();
$renderedWidget.attr("data-ntx-id", noteContext.ntxId);
$renderedWidget.on("click", () => appContext.tabManager.activateNoteContext(noteContext.ntxId));
this.$widget.append($renderedWidget);
widget.handleEvent("initialRenderComplete", {});
widget.toggleExt(false);
if (noteContext.ntxId) {
this.widgets[noteContext.ntxId] = widget;
}
await widget.handleEvent("setNoteContext", { noteContext });
this.child(widget);
}
async openNewNoteSplitEvent({ ntxId, notePath, hoistedNoteId, viewScope }: EventData<"openNewNoteSplit">) {
const mainNtxId = appContext.tabManager.getActiveMainContext()?.ntxId;
if (!mainNtxId) {
console.warn("Missing main note context ID");
return;
}
if (!ntxId) {
logError("empty ntxId!");
ntxId = mainNtxId;
}
hoistedNoteId = hoistedNoteId || appContext.tabManager.getActiveContext()?.hoistedNoteId;
const noteContext = await appContext.tabManager.openEmptyTab(null, hoistedNoteId, mainNtxId);
if (!noteContext.ntxId) {
logError("Failed to create new note context!");
return;
}
// remove the original position of newly created note context
const ntxIds = appContext.tabManager.children.map((c) => c.ntxId).filter((id) => id !== noteContext.ntxId) as string[];
// insert the note context after the originating note context
ntxIds.splice(ntxIds.indexOf(ntxId) + 1, 0, noteContext.ntxId);
this.triggerCommand("noteContextReorder", { ntxIdsInOrder: ntxIds });
// move the note context rendered widget after the originating widget
this.$widget.find(`[data-ntx-id="${noteContext.ntxId}"]`).insertAfter(this.$widget.find(`[data-ntx-id="${ntxId}"]`));
await appContext.tabManager.activateNoteContext(noteContext.ntxId);
if (notePath) {
await noteContext.setNote(notePath, { viewScope });
} else {
await noteContext.setEmpty();
}
}
closeThisNoteSplitCommand({ ntxId }: CommandListenerData<"closeThisNoteSplit">) {
if (ntxId) {
appContext.tabManager.removeNoteContext(ntxId);
}
}
async moveThisNoteSplitCommand({ ntxId, isMovingLeft }: CommandListenerData<"moveThisNoteSplit">) {
if (!ntxId) {
logError("empty ntxId!");
return;
}
const contexts = appContext.tabManager.noteContexts;
const currentIndex = contexts.findIndex((c) => c.ntxId === ntxId);
const leftIndex = isMovingLeft ? currentIndex - 1 : currentIndex;
if (currentIndex === -1 || leftIndex < 0 || leftIndex + 1 >= contexts.length) {
logError(`invalid context! currentIndex: ${currentIndex}, leftIndex: ${leftIndex}, contexts.length: ${contexts.length}`);
return;
}
if (contexts[leftIndex].isEmpty() && contexts[leftIndex + 1].isEmpty()) {
// no op
return;
}
const ntxIds = contexts.map((c) => c.ntxId).filter((c) => !!c) as string[];
const newNtxIds = [...ntxIds.slice(0, leftIndex), ntxIds[leftIndex + 1], ntxIds[leftIndex], ...ntxIds.slice(leftIndex + 2)];
const isChangingMainContext = !contexts[leftIndex].mainNtxId;
this.triggerCommand("noteContextReorder", {
ntxIdsInOrder: newNtxIds,
oldMainNtxId: isChangingMainContext ? ntxIds[leftIndex] : null,
newMainNtxId: isChangingMainContext ? ntxIds[leftIndex + 1] : null
});
// reorder the note context widgets
this.$widget.find(`[data-ntx-id="${ntxIds[leftIndex]}"]`).insertAfter(this.$widget.find(`[data-ntx-id="${ntxIds[leftIndex + 1]}"]`));
// activate context that now contains the original note
await appContext.tabManager.activateNoteContext(isMovingLeft ? ntxIds[leftIndex + 1] : ntxIds[leftIndex]);
}
activeContextChangedEvent() {
this.refresh();
}
noteSwitchedAndActivatedEvent() {
this.refresh();
}
noteContextRemovedEvent({ ntxIds }: EventData<"noteContextRemoved">) {
this.children = this.children.filter((c) => !ntxIds.includes(c.ntxId ?? ""));
for (const ntxId of ntxIds) {
this.$widget.find(`[data-ntx-id="${ntxId}"]`).remove();
delete this.widgets[ntxId];
}
}
contextsReopenedEvent({ ntxId, afterNtxId }: EventData<"contextsReopened">) {
if (ntxId === undefined || afterNtxId === undefined) {
// no single split reopened
return;
}
this.$widget.find(`[data-ntx-id="${ntxId}"]`).insertAfter(this.$widget.find(`[data-ntx-id="${afterNtxId}"]`));
}
async refresh() {
this.toggleExt(true);
}
toggleInt(show: boolean) {} // not needed
toggleExt(show: boolean) {
const activeMainContext = appContext.tabManager.getActiveMainContext();
const activeNtxId = activeMainContext ? activeMainContext.ntxId : null;
for (const ntxId in this.widgets) {
const noteContext = appContext.tabManager.getNoteContextById(ntxId);
const widget = this.widgets[ntxId];
widget.toggleExt(show && activeNtxId && [noteContext.ntxId, noteContext.mainNtxId].includes(activeNtxId));
}
}
/**
* widget.hasBeenAlreadyShown is intended for lazy loading of cached tabs - initial note switches of new tabs
* are not executed, we're waiting for the first tab activation, and then we update the tab. After this initial
* activation, further note switches are always propagated to the tabs.
*/
async handleEventInChildren<T extends EventNames>(name: T, data: EventData<T>) {
if (["noteSwitched", "noteSwitchedAndActivated"].includes(name)) {
// this event is propagated only to the widgets of a particular tab
const noteSwitchedContext = data as NoteSwitchedContext;
if (!noteSwitchedContext?.noteContext.ntxId) {
return Promise.resolve();
}
const widget = this.widgets[noteSwitchedContext.noteContext.ntxId];
if (!widget) {
return Promise.resolve();
}
if (widget.hasBeenAlreadyShown || name === "noteSwitchedAndActivated" || appContext.tabManager.getActiveMainContext() === noteSwitchedContext.noteContext.getMainContext()) {
widget.hasBeenAlreadyShown = true;
return [widget.handleEvent("noteSwitched", noteSwitchedContext), this.refreshNotShown(noteSwitchedContext)];
} else {
return Promise.resolve();
}
}
if (name === "activeContextChanged") {
return this.refreshNotShown(data as EventData<"activeContextChanged">);
} else {
return super.handleEventInChildren(name, data);
}
}
refreshNotShown(data: NoteSwitchedContext | EventData<"activeContextChanged">) {
const promises = [];
for (const subContext of data.noteContext.getMainContext().getSubContexts()) {
if (!subContext.ntxId) {
continue;
}
const widget = this.widgets[subContext.ntxId];
if (!widget.hasBeenAlreadyShown) {
widget.hasBeenAlreadyShown = true;
promises.push(widget.handleEvent("activeContextChanged", { noteContext: subContext }));
}
}
this.refresh();
return Promise.all(promises);
}
}

View File

@@ -0,0 +1,116 @@
import { formatDateTime } from "../../utils/formatters.js";
import { t } from "../../services/i18n.js";
import BasicWidget from "../basic_widget.js";
import openService from "../../services/open.js";
import server from "../../services/server.js";
import utils from "../../services/utils.js";
interface AppInfo {
appVersion: string;
dbVersion: number;
syncVersion: number;
buildDate: string;
buildRevision: string;
dataDirectory: string;
}
const TPL = /*html*/`
<div class="about-dialog modal fade mx-auto" tabindex="-1" role="dialog">
<div class="modal-dialog modal-lg" role="document">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">${t("about.title")}</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="${t("about.close")}"></button>
</div>
<div class="modal-body">
<table class="table table-borderless">
<tr>
<th>${t("about.homepage")}</th>
<td><a class="tn-link" href="https://github.com/TriliumNext/Notes" class="external">https://github.com/TriliumNext/Notes</a></td>
</tr>
<tr>
<th>${t("about.app_version")}</th>
<td class="app-version"></td>
</tr>
<tr>
<th>${t("about.db_version")}</th>
<td class="db-version"></td>
</tr>
<tr>
<th>${t("about.sync_version")}</th>
<td class="sync-version"></td>
</tr>
<tr>
<th>${t("about.build_date")}</th>
<td class="build-date"></td>
</tr>
<tr>
<th>${t("about.build_revision")}</th>
<td><a class="tn-link build-revision external" href="" target="_blank"></a></td>
</tr>
<tr>
<th>${t("about.data_directory")}</th>
<td class="data-directory"></td>
</tr>
</table>
</div>
</div>
</div>
</div>
<style>
.about-dialog a {
word-break: break-all;
}
</style>
`;
export default class AboutDialog extends BasicWidget {
private $appVersion!: JQuery<HTMLElement>;
private $dbVersion!: JQuery<HTMLElement>;
private $syncVersion!: JQuery<HTMLElement>;
private $buildDate!: JQuery<HTMLElement>;
private $buildRevision!: JQuery<HTMLElement>;
private $dataDirectory!: JQuery<HTMLElement>;
doRender(): void {
this.$widget = $(TPL);
this.$appVersion = this.$widget.find(".app-version");
this.$dbVersion = this.$widget.find(".db-version");
this.$syncVersion = this.$widget.find(".sync-version");
this.$buildDate = this.$widget.find(".build-date");
this.$buildRevision = this.$widget.find(".build-revision");
this.$dataDirectory = this.$widget.find(".data-directory");
}
async refresh() {
const appInfo = await server.get<AppInfo>("app-info");
this.$appVersion.text(appInfo.appVersion);
this.$dbVersion.text(appInfo.dbVersion.toString());
this.$syncVersion.text(appInfo.syncVersion.toString());
this.$buildDate.text(formatDateTime(appInfo.buildDate));
this.$buildRevision.text(appInfo.buildRevision);
this.$buildRevision.attr("href", `https://github.com/TriliumNext/Notes/commit/${appInfo.buildRevision}`);
if (utils.isElectron()) {
this.$dataDirectory.html(
$("<a></a>", {
href: "#",
class: "tn-link",
text: appInfo.dataDirectory
}).prop("outerHTML")
);
this.$dataDirectory.find("a").on("click", (event: JQuery.ClickEvent) => {
event.preventDefault();
openService.openDirectory(appInfo.dataDirectory);
});
} else {
this.$dataDirectory.text(appInfo.dataDirectory);
}
}
async openAboutDialogEvent() {
await this.refresh();
utils.openDialog(this.$widget);
}
}

View File

@@ -0,0 +1,188 @@
import { t } from "../../services/i18n.js";
import treeService from "../../services/tree.js";
import noteAutocompleteService from "../../services/note_autocomplete.js";
import utils from "../../services/utils.js";
import BasicWidget from "../basic_widget.js";
import type { Suggestion } from "../../services/note_autocomplete.js";
import type { default as TextTypeWidget } from "../type_widgets/editable_text.js";
import type { EventData } from "../../components/app_context.js";
const TPL = /*html*/`
<div class="add-link-dialog modal mx-auto" tabindex="-1" role="dialog">
<div class="modal-dialog modal-lg" style="max-width: 1000px" role="document">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title flex-grow-1">${t("add_link.add_link")}</h5>
<button type="button" class="help-button" title="${t("add_link.help_on_links")}" data-help-page="links.html">?</button>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="${t("add_link.close")}"></button>
</div>
<form class="add-link-form">
<div class="modal-body">
<div class="form-group">
<label for="add-link-note-autocomplete">${t("add_link.note")}</label>
<div class="input-group">
<input class="add-link-note-autocomplete form-control" placeholder="${t("add_link.search_note")}">
</div>
</div>
<div class="add-link-title-settings">
<div class="add-link-title-radios form-check">
<label class="form-check-label">
<input class="form-check-input" type="radio" name="link-type" value="reference-link" checked>
${t("add_link.link_title_mirrors")}
</label>
</div>
<div class="add-link-title-radios form-check">
<label class="form-check-label">
<input class="form-check-input" type="radio" name="link-type" value="hyper-link">
${t("add_link.link_title_arbitrary")}
</label>
</div>
<div class="add-link-title-form-group form-group">
<br/>
<label>
${t("add_link.link_title")}
<input class="link-title form-control" style="width: 100%;">
</label>
</div>
</div>
</div>
<div class="modal-footer">
<button type="submit" class="btn btn-primary">${t("add_link.button_add_link")}</button>
</div>
</form>
</div>
</div>
</div>`;
export default class AddLinkDialog extends BasicWidget {
private $form!: JQuery<HTMLElement>;
private $autoComplete!: JQuery<HTMLElement>;
private $linkTitle!: JQuery<HTMLElement>;
private $addLinkTitleSettings!: JQuery<HTMLElement>;
private $addLinkTitleRadios!: JQuery<HTMLElement>;
private $addLinkTitleFormGroup!: JQuery<HTMLElement>;
private textTypeWidget: TextTypeWidget | null = null;
doRender() {
this.$widget = $(TPL);
this.$form = this.$widget.find(".add-link-form");
this.$autoComplete = this.$widget.find(".add-link-note-autocomplete");
this.$linkTitle = this.$widget.find(".link-title");
this.$addLinkTitleSettings = this.$widget.find(".add-link-title-settings");
this.$addLinkTitleRadios = this.$widget.find(".add-link-title-radios");
this.$addLinkTitleFormGroup = this.$widget.find(".add-link-title-form-group");
this.$form.on("submit", () => {
if (this.$autoComplete.getSelectedNotePath()) {
this.$widget.modal("hide");
const linkTitle = this.getLinkType() === "reference-link" ? null : this.$linkTitle.val() as string;
this.textTypeWidget?.addLink(this.$autoComplete.getSelectedNotePath()!, linkTitle);
} else if (this.$autoComplete.getSelectedExternalLink()) {
this.$widget.modal("hide");
this.textTypeWidget?.addLink(this.$autoComplete.getSelectedExternalLink()!, this.$linkTitle.val() as string, true);
} else {
logError("No link to add.");
}
return false;
});
}
async showAddLinkDialogEvent({ textTypeWidget, text = "" }: EventData<"showAddLinkDialog">) {
this.textTypeWidget = textTypeWidget;
this.$addLinkTitleSettings.toggle(!this.textTypeWidget.hasSelection());
this.$addLinkTitleSettings.find("input[type=radio]").on("change", () => this.updateTitleSettingsVisibility());
// with selection hyperlink is implied
if (this.textTypeWidget.hasSelection()) {
this.$addLinkTitleSettings.find("input[value='hyper-link']").prop("checked", true);
} else {
this.$addLinkTitleSettings.find("input[value='reference-link']").prop("checked", true);
}
this.updateTitleSettingsVisibility();
utils.openDialog(this.$widget);
this.$autoComplete.val("");
this.$linkTitle.val("");
const setDefaultLinkTitle = async (noteId: string) => {
const noteTitle = await treeService.getNoteTitle(noteId);
this.$linkTitle.val(noteTitle);
};
noteAutocompleteService.initNoteAutocomplete(this.$autoComplete, {
allowExternalLinks: true,
allowCreatingNotes: true
});
this.$autoComplete.on("autocomplete:noteselected", (event: JQuery.Event, suggestion: Suggestion) => {
if (!suggestion.notePath) {
return false;
}
this.updateTitleSettingsVisibility();
const noteId = treeService.getNoteIdFromUrl(suggestion.notePath);
if (noteId) {
setDefaultLinkTitle(noteId);
}
});
this.$autoComplete.on("autocomplete:externallinkselected", (event: JQuery.Event, suggestion: Suggestion) => {
if (!suggestion.externalLink) {
return false;
}
this.updateTitleSettingsVisibility();
this.$linkTitle.val(suggestion.externalLink);
});
this.$autoComplete.on("autocomplete:cursorchanged", (event: JQuery.Event, suggestion: Suggestion) => {
if (suggestion.externalLink) {
this.$linkTitle.val(suggestion.externalLink);
} else {
const noteId = treeService.getNoteIdFromUrl(suggestion.notePath!);
if (noteId) {
setDefaultLinkTitle(noteId);
}
}
});
if (text && text.trim()) {
noteAutocompleteService.setText(this.$autoComplete, text);
} else {
noteAutocompleteService.showRecentNotes(this.$autoComplete);
}
this.$autoComplete.trigger("focus").trigger("select"); // to be able to quickly remove entered text
}
private getLinkType() {
if (this.$autoComplete.getSelectedExternalLink()) {
return "external-link";
}
return this.$addLinkTitleSettings.find("input[type=radio]:checked").val();
}
private updateTitleSettingsVisibility() {
const linkType = this.getLinkType();
this.$addLinkTitleFormGroup.toggle(linkType !== "reference-link");
this.$addLinkTitleRadios.toggle(linkType !== "external-link");
}
}

View File

@@ -0,0 +1,108 @@
import treeService from "../../services/tree.js";
import server from "../../services/server.js";
import froca from "../../services/froca.js";
import toastService from "../../services/toast.js";
import utils from "../../services/utils.js";
import BasicWidget from "../basic_widget.js";
import appContext from "../../components/app_context.js";
import { t } from "../../services/i18n.js";
import { Modal } from "bootstrap";
const TPL = /*html*/`<div class="branch-prefix-dialog modal fade mx-auto" tabindex="-1" role="dialog">
<div class="modal-dialog modal-lg" role="document">
<form class="branch-prefix-form">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title flex-grow-1">${t("branch_prefix.edit_branch_prefix")}</h5>
<button class="help-button" type="button" data-help-page="tree-concepts.html#prefix" title="${t("branch_prefix.help_on_tree_prefix")}">?</button>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="${t("branch_prefix.close")}"></button>
</div>
<div class="modal-body">
<div class="form-group">
<label for="branch-prefix-input">${t("branch_prefix.prefix")}</label> &nbsp;
<div class="input-group">
<input class="branch-prefix-input form-control">
<div class="branch-prefix-note-title input-group-text"></div>
</div>
</div>
</div>
<div class="modal-footer">
<button class="btn btn-primary btn-sm">${t("branch_prefix.save")}</button>
</div>
</div>
</form>
</div>
</div>`;
export default class BranchPrefixDialog extends BasicWidget {
private modal!: Modal;
private $form!: JQuery<HTMLElement>;
private $treePrefixInput!: JQuery<HTMLElement>;
private $noteTitle!: JQuery<HTMLElement>;
private branchId: string | null = null;
doRender() {
this.$widget = $(TPL);
this.modal = Modal.getOrCreateInstance(this.$widget[0]);
this.$form = this.$widget.find(".branch-prefix-form");
this.$treePrefixInput = this.$widget.find(".branch-prefix-input");
this.$noteTitle = this.$widget.find(".branch-prefix-note-title");
this.$form.on("submit", () => {
this.savePrefix();
return false;
});
this.$widget.on("shown.bs.modal", () => this.$treePrefixInput.trigger("focus"));
}
async refresh(notePath: string) {
const { noteId, parentNoteId } = treeService.getNoteIdAndParentIdFromUrl(notePath);
if (!noteId || !parentNoteId) {
return;
}
const newBranchId = await froca.getBranchId(parentNoteId, noteId);
if (!newBranchId) {
return;
}
this.branchId = newBranchId;
const branch = froca.getBranch(this.branchId);
if (!branch || branch.noteId === "root") {
return;
}
const parentNote = await froca.getNote(branch.parentNoteId);
if (!parentNote || parentNote.type === "search") {
return;
}
this.$treePrefixInput.val(branch.prefix || "");
const noteTitle = await treeService.getNoteTitle(noteId);
this.$noteTitle.text(` - ${noteTitle}`);
}
async editBranchPrefixEvent() {
const notePath = appContext.tabManager.getActiveContextNotePath();
if (!notePath) {
return;
}
await this.refresh(notePath);
utils.openDialog(this.$widget);
}
async savePrefix() {
const prefix = this.$treePrefixInput.val();
await server.put(`branches/${this.branchId}/set-prefix`, { prefix: prefix });
this.modal.hide();
toastService.showMessage(t("branch_prefix.branch_prefix_saved"));
}
}

View File

@@ -0,0 +1,175 @@
import BasicWidget from "../basic_widget.js";
import froca from "../../services/froca.js";
import bulkActionService from "../../services/bulk_action.js";
import utils from "../../services/utils.js";
import server from "../../services/server.js";
import toastService from "../../services/toast.js";
import { t } from "../../services/i18n.js";
import type { EventData } from "../../components/app_context.js";
const TPL = /*html*/`
<div class="bulk-actions-dialog modal mx-auto" tabindex="-1" role="dialog">
<style>
.bulk-actions-dialog .modal-body h4:not(:first-child) {
margin-top: 20px;
}
.bulk-actions-dialog .bulk-available-action-list button {
padding: 2px 7px;
margin-right: 10px;
margin-bottom: 5px;
}
.bulk-actions-dialog .bulk-existing-action-list {
width: 100%;
}
.bulk-actions-dialog .bulk-existing-action-list td {
padding: 7px;
}
.bulk-actions-dialog .bulk-existing-action-list .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;
}
</style>
<div class="modal-dialog modal-xl" role="document">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">${t("bulk_actions.bulk_actions")}</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="${t("bulk_actions.close")}"></button>
</div>
<div class="modal-body">
<h4>${t("bulk_actions.affected_notes")}: <span class="affected-note-count">0</span></h4>
<div class="form-check">
<label for="include-descendants" class="form-check-label tn-checkbox">
<input id="include-descendants" class="include-descendants form-check-input" type="checkbox" value="">
${t("bulk_actions.include_descendants")}
</label>
</div>
<h4>${t("bulk_actions.available_actions")}</h4>
<table class="bulk-available-action-list"></table>
<h4>${t("bulk_actions.chosen_actions")}</h4>
<table class="bulk-existing-action-list"></table>
</div>
<div class="modal-footer">
<button type="submit" class="execute-bulk-actions btn btn-primary">${t("bulk_actions.execute_bulk_actions")}</button>
</div>
</div>
</div>
</div>`;
export default class BulkActionsDialog extends BasicWidget {
private $includeDescendants!: JQuery<HTMLElement>;
private $affectedNoteCount!: JQuery<HTMLElement>;
private $availableActionList!: JQuery<HTMLElement>;
private $existingActionList!: JQuery<HTMLElement>;
private $executeButton!: JQuery<HTMLElement>;
private selectedOrActiveNoteIds: string[] | null = null;
doRender() {
this.$widget = $(TPL);
this.$includeDescendants = this.$widget.find(".include-descendants");
this.$includeDescendants.on("change", () => this.refresh());
this.$affectedNoteCount = this.$widget.find(".affected-note-count");
this.$availableActionList = this.$widget.find(".bulk-available-action-list");
this.$existingActionList = this.$widget.find(".bulk-existing-action-list");
this.$widget.on("click", "[data-action-add]", async (event) => {
const actionName = $(event.target).attr("data-action-add");
if (!actionName) {
return;
}
await bulkActionService.addAction("_bulkAction", actionName);
await this.refresh();
});
this.$executeButton = this.$widget.find(".execute-bulk-actions");
this.$executeButton.on("click", async () => {
await server.post("bulk-action/execute", {
noteIds: this.selectedOrActiveNoteIds,
includeDescendants: this.$includeDescendants.is(":checked")
});
toastService.showMessage(t("bulk_actions.bulk_actions_executed"), 3000);
utils.closeActiveDialog();
});
}
async refresh() {
this.renderAvailableActions();
if (!this.selectedOrActiveNoteIds) {
return;
}
const { affectedNoteCount } = await server.post("bulk-action/affected-notes", {
noteIds: this.selectedOrActiveNoteIds,
includeDescendants: this.$includeDescendants.is(":checked")
}) as { affectedNoteCount: number };
this.$affectedNoteCount.text(affectedNoteCount);
const bulkActionNote = await froca.getNote("_bulkAction");
if (!bulkActionNote) {
return;
}
const actions = bulkActionService.parseActions(bulkActionNote);
this.$existingActionList.empty();
if (actions.length > 0) {
this.$existingActionList.append(...actions.map((action) => action.render()).filter((action) => action !== null));
} else {
this.$existingActionList.append($("<p>").text(t("bulk_actions.none_yet")));
}
}
renderAvailableActions() {
this.$availableActionList.empty();
for (const actionGroup of bulkActionService.ACTION_GROUPS) {
const $actionGroupList = $("<td>");
const $actionGroup = $("<tr>")
.append($("<td>").text(`${actionGroup.title}: `))
.append($actionGroupList);
for (const action of actionGroup.actions) {
$actionGroupList.append($('<button class="btn btn-sm">').attr("data-action-add", action.actionName).text(action.actionTitle));
}
this.$availableActionList.append($actionGroup);
}
}
entitiesReloadedEvent({ loadResults }: EventData<"entitiesReloaded">) {
// only refreshing deleted attrs, otherwise components update themselves
if (loadResults.getAttributeRows().find((row) => row.type === "label" && row.name === "action" && row.noteId === "_bulkAction" && row.isDeleted)) {
// this may be triggered from e.g., sync without open widget, then no need to refresh the widget
if (this.selectedOrActiveNoteIds && this.$widget.is(":visible")) {
this.refresh();
}
}
}
async openBulkActionsDialogEvent({ selectedOrActiveNoteIds }: EventData<"openBulkActionsDialog">) {
this.selectedOrActiveNoteIds = selectedOrActiveNoteIds;
this.$includeDescendants.prop("checked", false);
await this.refresh();
utils.openDialog(this.$widget);
}
}

View File

@@ -0,0 +1,140 @@
import noteAutocompleteService from "../../services/note_autocomplete.js";
import utils from "../../services/utils.js";
import treeService from "../../services/tree.js";
import toastService from "../../services/toast.js";
import froca from "../../services/froca.js";
import branchService from "../../services/branches.js";
import appContext from "../../components/app_context.js";
import BasicWidget from "../basic_widget.js";
import { t } from "../../services/i18n.js";
import type { EventData } from "../../components/app_context.js";
const TPL = /*html*/`
<div class="clone-to-dialog modal mx-auto" tabindex="-1" role="dialog">
<div class="modal-dialog modal-lg" style="max-width: 1000px" role="document">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title flex-grow-1">${t("clone_to.clone_notes_to")}</h5>
<button type="button" class="help-button" title="${t("clone_to.help_on_links")}" data-help-page="cloning-notes.html">?</button>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="${t("clone_to.close")}"></button>
</div>
<form class="clone-to-form">
<div class="modal-body">
<h5>${t("clone_to.notes_to_clone")}</h5>
<ul class="clone-to-note-list" style="max-height: 200px; overflow: auto;"></ul>
<div class="form-group">
<label style="width: 100%">
${t("clone_to.target_parent_note")}
<div class="input-group">
<input class="clone-to-note-autocomplete form-control" placeholder="${t("clone_to.search_for_note_by_its_name")}">
</div>
</label>
</div>
<div class="form-group" title="${t("clone_to.cloned_note_prefix_title")}">
<label style="width: 100%">
${t("clone_to.prefix_optional")}
<input class="clone-prefix form-control" style="width: 100%;">
</label>
</div>
</div>
<div class="modal-footer">
<button type="submit" class="btn btn-primary">${t("clone_to.clone_to_selected_note")}</button>
</div>
</form>
</div>
</div>
</div>`;
export default class CloneToDialog extends BasicWidget {
private $form!: JQuery<HTMLElement>;
private $noteAutoComplete!: JQuery<HTMLElement>;
private $clonePrefix!: JQuery<HTMLElement>;
private $noteList!: JQuery<HTMLElement>;
private clonedNoteIds: string[] | null = null;
constructor() {
super();
}
doRender() {
this.$widget = $(TPL);
this.$form = this.$widget.find(".clone-to-form");
this.$noteAutoComplete = this.$widget.find(".clone-to-note-autocomplete");
this.$clonePrefix = this.$widget.find(".clone-prefix");
this.$noteList = this.$widget.find(".clone-to-note-list");
this.$form.on("submit", () => {
const notePath = this.$noteAutoComplete.getSelectedNotePath();
if (notePath) {
this.$widget.modal("hide");
this.cloneNotesTo(notePath);
} else {
logError(t("clone_to.no_path_to_clone_to"));
}
return false;
});
}
async cloneNoteIdsToEvent({ noteIds }: EventData<"cloneNoteIdsTo">) {
if (!noteIds || noteIds.length === 0) {
noteIds = [appContext.tabManager.getActiveContextNoteId() ?? ""];
}
this.clonedNoteIds = [];
for (const noteId of noteIds) {
if (!this.clonedNoteIds.includes(noteId)) {
this.clonedNoteIds.push(noteId);
}
}
utils.openDialog(this.$widget);
this.$noteAutoComplete.val("").trigger("focus");
this.$noteList.empty();
for (const noteId of this.clonedNoteIds) {
const note = await froca.getNote(noteId);
if (!note) {
continue;
}
this.$noteList.append($("<li>").text(note.title));
}
noteAutocompleteService.initNoteAutocomplete(this.$noteAutoComplete);
noteAutocompleteService.showRecentNotes(this.$noteAutoComplete);
}
async cloneNotesTo(notePath: string) {
const { noteId, parentNoteId } = treeService.getNoteIdAndParentIdFromUrl(notePath);
if (!noteId || !parentNoteId) {
return;
}
const targetBranchId = await froca.getBranchId(parentNoteId, noteId);
if (!targetBranchId || !this.clonedNoteIds) {
return;
}
for (const cloneNoteId of this.clonedNoteIds) {
await branchService.cloneNoteToBranch(cloneNoteId, targetBranchId, this.$clonePrefix.val() as string);
const clonedNote = await froca.getNote(cloneNoteId);
const targetBranch = froca.getBranch(targetBranchId);
if (!clonedNote || !targetBranch) {
continue;
}
const targetNote = await targetBranch.getNote();
if (!targetNote) {
continue;
}
toastService.showMessage(t("clone_to.note_cloned", { clonedTitle: clonedNote.title, targetTitle: targetNote.title }));
}
}
}

View File

@@ -0,0 +1,151 @@
import BasicWidget from "../basic_widget.js";
import { t } from "../../services/i18n.js";
import { Modal } from "bootstrap";
const DELETE_NOTE_BUTTON_CLASS = "confirm-dialog-delete-note";
const TPL = /*html*/`
<div class="confirm-dialog modal mx-auto" tabindex="-1" role="dialog" style="z-index: 2000;">
<div class="modal-dialog modal-dialog-scrollable" role="document">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">${t("confirm.confirmation")}</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="${t("confirm.close")}"></button>
</div>
<div class="modal-body">
<div class="confirm-dialog-content"></div>
<div class="confirm-dialog-custom"></div>
</div>
<div class="modal-footer">
<button class="confirm-dialog-cancel-button btn btn-sm">${t("confirm.cancel")}</button>
&nbsp;
<button class="confirm-dialog-ok-button btn btn-primary btn-sm">${t("confirm.ok")}</button>
</div>
</div>
</div>
</div>`;
export type ConfirmDialogResult = false | ConfirmDialogOptions;
export type ConfirmDialogCallback = (val?: ConfirmDialogResult) => void;
export interface ConfirmDialogOptions {
confirmed: boolean;
isDeleteNoteChecked: boolean;
}
// For "showConfirmDialog"
export interface ConfirmWithMessageOptions {
message: string | HTMLElement | JQuery<HTMLElement>;
callback: ConfirmDialogCallback;
}
export interface ConfirmWithTitleOptions {
title: string;
callback: ConfirmDialogCallback;
}
export default class ConfirmDialog extends BasicWidget {
private resolve: ConfirmDialogCallback | null;
private modal!: Modal;
private $originallyFocused!: JQuery<HTMLElement> | null;
private $confirmContent!: JQuery<HTMLElement>;
private $okButton!: JQuery<HTMLElement>;
private $cancelButton!: JQuery<HTMLElement>;
private $custom!: JQuery<HTMLElement>;
constructor() {
super();
this.resolve = null;
this.$originallyFocused = null; // element focused before the dialog was opened, so we can return to it afterward
}
doRender() {
this.$widget = $(TPL);
this.modal = Modal.getOrCreateInstance(this.$widget[0]);
this.$confirmContent = this.$widget.find(".confirm-dialog-content");
this.$okButton = this.$widget.find(".confirm-dialog-ok-button");
this.$cancelButton = this.$widget.find(".confirm-dialog-cancel-button");
this.$custom = this.$widget.find(".confirm-dialog-custom");
this.$widget.on("shown.bs.modal", () => this.$okButton.trigger("focus"));
this.$widget.on("hidden.bs.modal", () => {
if (this.resolve) {
this.resolve(false);
}
if (this.$originallyFocused) {
this.$originallyFocused.trigger("focus");
this.$originallyFocused = null;
}
});
this.$cancelButton.on("click", () => this.doResolve(false));
this.$okButton.on("click", () => this.doResolve(true));
}
showConfirmDialogEvent({ message, callback }: ConfirmWithMessageOptions) {
this.$originallyFocused = $(":focus");
this.$custom.hide();
glob.activeDialog = this.$widget;
if (typeof message === "string") {
message = $("<div>").text(message);
}
this.$confirmContent.empty().append(message);
this.modal.show();
this.resolve = callback;
}
showConfirmDeleteNoteBoxWithNoteDialogEvent({ title, callback }: ConfirmWithTitleOptions) {
glob.activeDialog = this.$widget;
this.$confirmContent.text(`${t("confirm.are_you_sure_remove_note", { title: title })}`);
this.$custom
.empty()
.append("<br/>")
.append(
$("<div>")
.addClass("form-check")
.append(
$("<label>")
.addClass("form-check-label")
.attr("style", "text-decoration: underline dotted var(--main-text-color)")
.attr("title", `${t("confirm.if_you_dont_check")}`)
.append($("<input>").attr("type", "checkbox").addClass(`form-check-input ${DELETE_NOTE_BUTTON_CLASS}`))
.append(`${t("confirm.also_delete_note")}`)
)
);
this.$custom.show();
this.modal.show();
this.resolve = callback;
}
doResolve(ret: boolean) {
if (this.resolve) {
this.resolve({
confirmed: ret,
isDeleteNoteChecked: this.$widget.find(`.${DELETE_NOTE_BUTTON_CLASS}:checked`).length > 0
});
}
this.resolve = null;
this.modal.hide();
}
}

View File

@@ -0,0 +1,198 @@
import server from "../../services/server.js";
import froca from "../../services/froca.js";
import linkService from "../../services/link.js";
import utils from "../../services/utils.js";
import BasicWidget from "../basic_widget.js";
import { t } from "../../services/i18n.js";
import type { FAttributeRow } from "../../entities/fattribute.js";
// TODO: Use common with server.
interface Response {
noteIdsToBeDeleted: string[];
brokenRelations: FAttributeRow[];
}
export interface ResolveOptions {
proceed: boolean;
deleteAllClones?: boolean;
eraseNotes?: boolean;
}
interface ShowDeleteNotesDialogOpts {
branchIdsToDelete: string[];
callback: (opts: ResolveOptions) => void;
forceDeleteAllClones: boolean;
}
const TPL = /*html*/`
<div class="delete-notes-dialog modal mx-auto" tabindex="-1" role="dialog">
<div class="modal-dialog modal-dialog-scrollable modal-xl" role="document">
<div class="modal-content">
<div class="modal-header">
<h4 class="modal-title">${t("delete_notes.delete_notes_preview")}</h4>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="${t("delete_notes.close")}"></button>
</div>
<div class="modal-body">
<div class="form-checkbox">
<label for="delete-all-clones" class="form-check-label tn-checkbox">
<input id="delete-all-clones" class="delete-all-clones form-check-input" value="1" type="checkbox">
${t("delete_notes.delete_all_clones_description")}
</label>
</div>
<div class="form-checkbox" style="margin-bottom: 1rem">
<label for="erase-notes" class="form-check-label tn-checkbox">
<input id="erase-notes" class="erase-notes form-check-input" value="1" type="checkbox">
${t("delete_notes.erase_notes_warning")}
</label>
</div>
<div class="delete-notes-list-wrapper">
<h4>${t("delete_notes.notes_to_be_deleted", { noteCount: '<span class="deleted-notes-count"></span>' })}</h4>
<ul class="delete-notes-list" style="max-height: 200px; overflow: auto;"></ul>
</div>
<div class="no-note-to-delete-wrapper alert alert-info">
${t("delete_notes.no_note_to_delete")}
</div>
<div class="broken-relations-wrapper">
<div class="alert alert-danger">
<h4>${t("delete_notes.broken_relations_to_be_deleted", { relationCount: '<span class="broke-relations-count"></span>' })}</h4>
<ul class="broken-relations-list" style="max-height: 200px; overflow: auto;"></ul>
</div>
</div>
</div>
<div class="modal-footer">
<button class="delete-notes-dialog-cancel-button btn btn-sm">${t("delete_notes.cancel")}</button>
&nbsp;
<button class="delete-notes-dialog-ok-button btn btn-primary btn-sm">${t("delete_notes.ok")}</button>
</div>
</div>
</div>
</div>`;
export default class DeleteNotesDialog extends BasicWidget {
private branchIds: string[] | null;
private resolve!: (options: ResolveOptions) => void;
private $content!: JQuery<HTMLElement>;
private $okButton!: JQuery<HTMLElement>;
private $cancelButton!: JQuery<HTMLElement>;
private $deleteNotesList!: JQuery<HTMLElement>;
private $brokenRelationsList!: JQuery<HTMLElement>;
private $deletedNotesCount!: JQuery<HTMLElement>;
private $noNoteToDeleteWrapper!: JQuery<HTMLElement>;
private $deleteNotesListWrapper!: JQuery<HTMLElement>;
private $brokenRelationsListWrapper!: JQuery<HTMLElement>;
private $brokenRelationsCount!: JQuery<HTMLElement>;
private $deleteAllClones!: JQuery<HTMLElement>;
private $eraseNotes!: JQuery<HTMLElement>;
private forceDeleteAllClones?: boolean;
constructor() {
super();
this.branchIds = null;
}
doRender() {
this.$widget = $(TPL);
this.$content = this.$widget.find(".recent-changes-content");
this.$okButton = this.$widget.find(".delete-notes-dialog-ok-button");
this.$cancelButton = this.$widget.find(".delete-notes-dialog-cancel-button");
this.$deleteNotesList = this.$widget.find(".delete-notes-list");
this.$brokenRelationsList = this.$widget.find(".broken-relations-list");
this.$deletedNotesCount = this.$widget.find(".deleted-notes-count");
this.$noNoteToDeleteWrapper = this.$widget.find(".no-note-to-delete-wrapper");
this.$deleteNotesListWrapper = this.$widget.find(".delete-notes-list-wrapper");
this.$brokenRelationsListWrapper = this.$widget.find(".broken-relations-wrapper");
this.$brokenRelationsCount = this.$widget.find(".broke-relations-count");
this.$deleteAllClones = this.$widget.find(".delete-all-clones");
this.$eraseNotes = this.$widget.find(".erase-notes");
this.$widget.on("shown.bs.modal", () => this.$okButton.trigger("focus"));
this.$cancelButton.on("click", () => {
utils.closeActiveDialog();
this.resolve({ proceed: false });
});
this.$okButton.on("click", () => {
utils.closeActiveDialog();
this.resolve({
proceed: true,
deleteAllClones: this.forceDeleteAllClones || this.isDeleteAllClonesChecked(),
eraseNotes: this.isEraseNotesChecked()
});
});
this.$deleteAllClones.on("click", () => this.renderDeletePreview());
}
async renderDeletePreview() {
const response = await server.post<Response>("delete-notes-preview", {
branchIdsToDelete: this.branchIds,
deleteAllClones: this.forceDeleteAllClones || this.isDeleteAllClonesChecked()
});
this.$deleteNotesList.empty();
this.$brokenRelationsList.empty();
this.$deleteNotesListWrapper.toggle(response.noteIdsToBeDeleted.length > 0);
this.$noNoteToDeleteWrapper.toggle(response.noteIdsToBeDeleted.length === 0);
for (const note of await froca.getNotes(response.noteIdsToBeDeleted)) {
this.$deleteNotesList.append($("<li>").append(await linkService.createLink(note.noteId, { showNotePath: true })));
}
this.$deletedNotesCount.text(response.noteIdsToBeDeleted.length);
this.$brokenRelationsListWrapper.toggle(response.brokenRelations.length > 0);
this.$brokenRelationsCount.text(response.brokenRelations.length);
await froca.getNotes(response.brokenRelations.map((br) => br.noteId));
for (const attr of response.brokenRelations) {
this.$brokenRelationsList.append(
$("<li>").html(
t("delete_notes.deleted_relation_text", {
note: (await linkService.createLink(attr.value)).html(),
relation: `<code>${attr.name}</code>`,
source: (await linkService.createLink(attr.noteId)).html()
})
)
);
}
}
async showDeleteNotesDialogEvent({ branchIdsToDelete, callback, forceDeleteAllClones }: ShowDeleteNotesDialogOpts) {
this.branchIds = branchIdsToDelete;
this.forceDeleteAllClones = forceDeleteAllClones;
await this.renderDeletePreview();
utils.openDialog(this.$widget);
this.$deleteAllClones.prop("checked", !!forceDeleteAllClones).prop("disabled", !!forceDeleteAllClones);
this.$eraseNotes.prop("checked", false);
this.resolve = callback;
}
isDeleteAllClonesChecked() {
return this.$deleteAllClones.is(":checked");
}
isEraseNotesChecked() {
return this.$eraseNotes.is(":checked");
}
}

View File

@@ -0,0 +1,263 @@
import treeService from "../../services/tree.js";
import utils from "../../services/utils.js";
import ws from "../../services/ws.js";
import toastService, { type ToastOptions } from "../../services/toast.js";
import froca from "../../services/froca.js";
import openService from "../../services/open.js";
import BasicWidget from "../basic_widget.js";
import { t } from "../../services/i18n.js";
import type { EventData } from "../../components/app_context.js";
import { Modal } from "bootstrap";
const TPL = /*html*/`
<div class="export-dialog modal fade mx-auto" tabindex="-1" role="dialog">
<style>
.export-dialog .export-form .form-check {
padding-top: 10px;
padding-bottom: 10px;
}
.export-dialog .export-form .format-choice {
padding-left: 40px;
display: none;
}
.export-dialog .export-form .opml-versions {
padding-left: 60px;
display: none;
}
.export-dialog .export-form .form-check-label {
padding: 2px;
}
</style>
<div class="modal-dialog modal-lg" role="document">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">${t("export.export_note_title")} <span class="export-note-title"></span></h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="${t("export.close")}"></button>
</div>
<form class="export-form">
<div class="modal-body">
<div class="form-check">
<label class="form-check-label tn-radio">
<input class="export-type-subtree form-check-input" type="radio" name="export-type" value="subtree">
${t("export.export_type_subtree")}
</label>
</div>
<div class="export-subtree-formats format-choice">
<div class="form-check">
<label class="form-check-label tn-radio">
<input class="form-check-input" type="radio" name="export-subtree-format" value="html">
${t("export.format_html_zip")}
</label>
</div>
<div class="form-check">
<label class="form-check-label tn-radio">
<input class="form-check-input" type="radio" name="export-subtree-format" value="markdown">
${t("export.format_markdown")}
</label>
</div>
<div class="form-check">
<label class="form-check-label tn-radio">
<input class="form-check-input" type="radio" name="export-subtree-format" value="opml">
${t("export.format_opml")}
</label>
</div>
<div class="opml-versions">
<div class="form-check">
<label class="form-check-label tn-radio">
<input class="form-check-input" type="radio" name="opml-version" value="1.0">
${t("export.opml_version_1")}
</label>
</div>
<div class="form-check">
<label class="form-check-label tn-radio">
<input class="form-check-input" type="radio" name="opml-version" value="2.0">
${t("export.opml_version_2")}
</label>
</div>
</div>
</div>
<div class="form-check">
<label class="form-check-label tn-radio">
<input class="form-check-input" type="radio" name="export-type" value="single">
${t("export.export_type_single")}
</label>
</div>
<div class="export-single-formats format-choice">
<div class="form-check">
<label class="form-check-label tn-radio">
<input class="form-check-input" type="radio" name="export-single-format" value="html">
${t("export.format_html")}
</label>
</div>
<div class="form-check">
<label class="form-check-label tn-radio">
<input class="form-check-input" type="radio" name="export-single-format" value="markdown">
${t("export.format_markdown")}
</label>
</div>
</div>
</div>
<div class="modal-footer">
<button class="export-button btn btn-primary">${t("export.export")}</button>
</div>
</form>
</div>
</div>
</div>`;
export default class ExportDialog extends BasicWidget {
private taskId: string;
private branchId: string | null;
private modal?: Modal;
private $form!: JQuery<HTMLElement>;
private $noteTitle!: JQuery<HTMLElement>;
private $subtreeFormats!: JQuery<HTMLElement>;
private $singleFormats!: JQuery<HTMLElement>;
private $subtreeType!: JQuery<HTMLElement>;
private $singleType!: JQuery<HTMLElement>;
private $exportButton!: JQuery<HTMLElement>;
private $opmlVersions!: JQuery<HTMLElement>;
constructor() {
super();
this.taskId = "";
this.branchId = null;
}
doRender() {
this.$widget = $(TPL);
this.modal = Modal.getOrCreateInstance(this.$widget[0]);
this.$form = this.$widget.find(".export-form");
this.$noteTitle = this.$widget.find(".export-note-title");
this.$subtreeFormats = this.$widget.find(".export-subtree-formats");
this.$singleFormats = this.$widget.find(".export-single-formats");
this.$subtreeType = this.$widget.find(".export-type-subtree");
this.$singleType = this.$widget.find(".export-type-single");
this.$exportButton = this.$widget.find(".export-button");
this.$opmlVersions = this.$widget.find(".opml-versions");
this.$form.on("submit", () => {
this.modal?.hide();
const exportType = this.$widget.find("input[name='export-type']:checked").val();
if (!exportType) {
toastService.showError(t("export.choose_export_type"));
return;
}
const exportFormat = exportType === "subtree" ? this.$widget.find("input[name=export-subtree-format]:checked").val() : this.$widget.find("input[name=export-single-format]:checked").val();
const exportVersion = exportFormat === "opml" ? this.$widget.find("input[name='opml-version']:checked").val() : "1.0";
if (this.branchId) {
this.exportBranch(this.branchId, String(exportType), String(exportFormat), String(exportVersion));
}
return false;
});
this.$widget.find("input[name=export-type]").on("change", (e) => {
if ((e.currentTarget as HTMLInputElement).value === "subtree") {
if (this.$widget.find("input[name=export-subtree-format]:checked").length === 0) {
this.$widget.find("input[name=export-subtree-format]:first").prop("checked", true);
}
this.$subtreeFormats.slideDown();
this.$singleFormats.slideUp();
} else {
if (this.$widget.find("input[name=export-single-format]:checked").length === 0) {
this.$widget.find("input[name=export-single-format]:first").prop("checked", true);
}
this.$subtreeFormats.slideUp();
this.$singleFormats.slideDown();
}
});
this.$widget.find("input[name=export-subtree-format]").on("change", (e) => {
if ((e.currentTarget as HTMLInputElement).value === "opml") {
this.$opmlVersions.slideDown();
} else {
this.$opmlVersions.slideUp();
}
});
}
async showExportDialogEvent({ notePath, defaultType }: EventData<"showExportDialog">) {
this.taskId = "";
this.$exportButton.removeAttr("disabled");
if (defaultType === "subtree") {
this.$subtreeType.prop("checked", true).trigger("change");
this.$widget.find("input[name=export-subtree-format]:checked").trigger("change");
} else if (defaultType === "single") {
this.$singleType.prop("checked", true).trigger("change");
} else {
throw new Error(`Unrecognized type '${defaultType}'`);
}
this.$widget.find(".opml-v2").prop("checked", true); // setting default
utils.openDialog(this.$widget);
const { noteId, parentNoteId } = treeService.getNoteIdAndParentIdFromUrl(notePath);
if (parentNoteId) {
this.branchId = await froca.getBranchId(parentNoteId, noteId);
}
if (noteId) {
this.$noteTitle.text(await treeService.getNoteTitle(noteId));
}
}
exportBranch(branchId: string, type: string, format: string, version: string) {
this.taskId = utils.randomString(10);
const url = openService.getUrlForDownload(`api/branches/${branchId}/export/${type}/${format}/${version}/${this.taskId}`);
openService.download(url);
}
}
ws.subscribeToMessages(async (message) => {
function makeToast(id: string, message: string): ToastOptions {
return {
id: id,
title: t("export.export_status"),
message: message,
icon: "arrow-square-up-right"
};
}
if (message.taskType !== "export") {
return;
}
if (message.type === "taskError") {
toastService.closePersistent(message.taskId);
toastService.showError(message.message);
} else if (message.type === "taskProgressCount") {
toastService.showPersistent(makeToast(message.taskId, t("export.export_in_progress", { progressCount: message.progressCount })));
} else if (message.type === "taskSucceeded") {
const toast = makeToast(message.taskId, t("export.export_finished_successfully"));
toast.closeAfter = 5000;
toastService.showPersistent(toast);
}
});

View File

@@ -0,0 +1,159 @@
import utils from "../../services/utils.js";
import BasicWidget from "../basic_widget.js";
import { t } from "../../services/i18n.js";
const TPL = /*html*/`
<div class="help-dialog modal use-tn-links" tabindex="-1" role="dialog">
<div class="modal-dialog" role="document" style="min-width: 90%;">
<div class="modal-content" style="height: auto;">
<div class="modal-header">
<h5 class="modal-title">${t("help.fullDocumentation")}</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="${t("help.close")}"></button>
</div>
<div class="modal-body" style="overflow: auto;">
<div class="help-cards row row-cols-md-3 g-3">
<div class="card">
<div class="card-body">
<h5 class="card-title">${t("help.noteNavigation")}</h5>
<p class="card-text">
<ul>
<li>${t("help.goUpDown")}</li>
<li>${t("help.collapseExpand")}</li>
<li><kbd data-command="backInNoteHistory">${t("help.notSet")}</kbd>, <kbd data-command="forwardInNoteHistory">${t("help.notSet")}</kbd> - ${t("help.goBackForwards")}</li>
<li><kbd data-command="jumpToNote">${t("help.notSet")}</kbd> - ${t("help.showJumpToNoteDialog")}</li>
<li><kbd data-command="scrollToActiveNote">${t("help.notSet")}</kbd> - ${t("help.scrollToActiveNote")}</li>
<li>${t("help.jumpToParentNote")}</li>
<li><kbd data-command="collapseTree">${t("help.notSet")}</kbd> - ${t("help.collapseWholeTree")}</li>
<li><kbd data-command="collapseSubtree">${t("help.notSet")}</kbd> - ${t("help.collapseSubTree")}</li>
</ul>
</p>
</div>
</div>
<div class="card">
<div class="card-body">
<h5 class="card-title">${t("help.tabShortcuts")}</h5>
<p class="card-text">
<ul>
<li>${t("help.newTabNoteLink")}</li>
</ul>
<h6>${t("help.onlyInDesktop")}:</h6>
<ul>
<li><kbd data-command="openNewTab">${t("help.notSet")}</kbd> ${t("help.openEmptyTab")}</li>
<li><kbd data-command="closeActiveTab">${t("help.notSet")}</kbd> ${t("help.closeActiveTab")}</li>
<li><kbd data-command="activateNextTab">${t("help.notSet")}</kbd> ${t("help.activateNextTab")}</li>
<li><kbd data-command="activatePreviousTab">${t("help.notSet")}</kbd> ${t("help.activatePreviousTab")}</li>
</ul>
</p>
</div>
</div>
<div class="card">
<div class="card-body">
<h5 class="card-title">${t("help.creatingNotes")}</h5>
<p class="card-text">
<ul>
<li><kbd data-command="createNoteAfter">${t("help.notSet")}</kbd> - ${t("help.createNoteAfter")}</li>
<li><kbd data-command="createNoteInto">${t("help.notSet")}</kbd> - ${t("help.createNoteInto")}</li>
<li><kbd data-command="editBranchPrefix">${t("help.notSet")}</kbd> - ${t("help.editBranchPrefix")}</li>
</ul>
</p>
</div>
</div>
<div class="card">
<div class="card-body">
<h5 class="card-title">${t("help.movingCloningNotes")}</h5>
<p class="card-text">
<ul>
<li><kbd data-command="moveNoteUp">${t("help.notSet")}</kbd>, <kbd data-command="moveNoteDown">${t("help.notSet")}</kbd> - ${t("help.moveNoteUpDown")}</li>
<li><kbd data-command="moveNoteUpInHierarchy">${t("help.notSet")}</kbd>, <kbd data-command="moveNoteDownInHierarchy">${t("help.notSet")}</kbd> - ${t("help.moveNoteUpHierarchy")}</li>
<li><kbd data-command="addNoteAboveToSelection">${t("help.notSet")}</kbd>, <kbd data-command="addNoteBelowToSelection">${t("help.notSet")}</kbd> - ${t("help.multiSelectNote")}</li>
<li><kbd data-command="selectAllNotesInParent">${t("help.notSet")}</kbd> - ${t("help.selectAllNotes")}</li>
<li>${t("help.selectNote")}</li>
<li><kbd data-command="copyNotesToClipboard">${t("help.notSet")}</kbd> - ${t("help.copyNotes")}</li>
<li><kbd data-command="cutNotesToClipboard">${t("help.notSet")}</kbd> - ${t("help.cutNotes")}</li>
<li><kbd data-command="pasteNotesFromClipboard">${t("help.notSet")}</kbd> - ${t("help.pasteNotes")}</li>
<li><kbd data-command="deleteNotes">${t("help.notSet")}</kbd> - ${t("help.deleteNotes")}</li>
</ul>
</p>
</div>
</div>
<div class="card">
<div class="card-body">
<h5 class="card-title">${t("help.editingNotes")}</h5>
<p class="card-text">
<ul>
<li><kbd data-command="editNoteTitle">${t("help.notSet")}</kbd> ${t("help.editNoteTitle")}</li>
<li>${t("help.createEditLink")}</li>
<li><kbd data-command="addLinkToText">${t("help.notSet")}</kbd> - ${t("help.createInternalLink")}</li>
<li><kbd data-command="followLinkUnderCursor">${t("help.notSet")}</kbd> - ${t("help.followLink")}</li>
<li><kbd data-command="insertDateTimeToText">${t("help.notSet")}</kbd> - ${t("help.insertDateTime")}</li>
<li><kbd data-command="scrollToActiveNote">${t("help.notSet")}</kbd> - ${t("help.jumpToTreePane")}</li>
</ul>
</p>
</div>
</div>
<div class="card">
<div class="card-body">
<h5 class="card-title"><a class="external" href="https://triliumnext.github.io/Docs/Wiki/text-notes.html#markdown--autoformat">${t("help.markdownAutoformat")}</a></h5>
<p class="card-text">
<ul>
<li>${t("help.headings")}</li>
<li>${t("help.bulletList")}</li>
<li>${t("help.numberedList")}</li>
<li>${t("help.blockQuote")}</li>
</ul>
</p>
</div>
</div>
<div class="card">
<div class="card-body">
<h5 class="card-title">${t("help.troubleshooting")}</h5>
<p class="card-text">
<ul>
<li><kbd data-command="reloadFrontendApp">${t("help.notSet")}</kbd> - ${t("help.reloadFrontend")}</li>
<li><kbd data-command="openDevTools">${t("help.notSet")}</kbd> - ${t("help.showDevTools")}</li>
<li><kbd data-command="showSQLConsole">${t("help.notSet")}</kbd> - ${t("help.showSQLConsole")}</li>
</ul>
</p>
</div>
</div>
<div class="card">
<div class="card-body">
<h5 class="card-title">${t("help.other")}</h5>
<p class="card-text">
<ul>
<li><kbd data-command="quickSearch">${t("help.notSet")}</kbd> - ${t("help.quickSearch")}</li>
<li><kbd data-command="findInText">${t("help.notSet")}</kbd> - ${t("help.inPageSearch")}</li>
</ul>
</p>
</div>
</div>
</div>
</div>
</div>
</div>
</div>`;
export default class HelpDialog extends BasicWidget {
doRender() {
this.$widget = $(TPL);
}
showCheatsheetEvent() {
utils.openDialog(this.$widget);
}
}

View File

@@ -0,0 +1,179 @@
import utils, { escapeQuotes } from "../../services/utils.js";
import treeService from "../../services/tree.js";
import importService, { type UploadFilesOptions } from "../../services/import.js";
import options from "../../services/options.js";
import BasicWidget from "../basic_widget.js";
import { t } from "../../services/i18n.js";
import { Modal, Tooltip } from "bootstrap";
import type { EventData } from "../../components/app_context.js";
const TPL = /*html*/`
<div class="import-dialog modal fade mx-auto" tabindex="-1" role="dialog">
<div class="modal-dialog modal-lg" role="document">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">${t("import.importIntoNote")}</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="${t("import.close")}"></button>
</div>
<form class="import-form">
<div class="modal-body">
<div class="form-group">
<label for="import-file-upload-input"><strong>${t("import.chooseImportFile")}</strong></label>
<label class="tn-file-input tn-input-field">
<input type="file" class="import-file-upload-input form-control-file" multiple />
</label>
<p>${t("import.importDescription")} <strong class="import-note-title"></strong>.
</div>
<div class="form-group">
<strong>${t("import.options")}:</strong>
<div class="checkbox">
<label class="tn-checkbox" data-bs-toggle="tooltip" title="${escapeQuotes(t("import.safeImportTooltip"))}">
<input class="safe-import-checkbox" value="1" type="checkbox" checked>
<span>${t("import.safeImport")}</span>
</label>
</div>
<div class="checkbox">
<label class="tn-checkbox" data-bs-toggle="tooltip" title="${escapeQuotes(t("import.explodeArchivesTooltip"))}">
<input class="explode-archives-checkbox" value="1" type="checkbox" checked>
<span>${t("import.explodeArchives")}</span>
</label>
</div>
<div class="checkbox">
<label class="tn-checkbox" data-bs-toggle="tooltip" title="${escapeQuotes(t("import.shrinkImagesTooltip"))}">
<input class="shrink-images-checkbox" value="1" type="checkbox" checked> <span>${t("import.shrinkImages")}</span>
</label>
</div>
<div class="checkbox">
<label class="tn-checkbox">
<input class="text-imported-as-text-checkbox" value="1" type="checkbox" checked>
${t("import.textImportedAsText")}
</label>
</div>
<div class="checkbox">
<label class="tn-checkbox">
<input class="code-imported-as-code-checkbox" value="1" type="checkbox" checked> ${t("import.codeImportedAsCode")}
</label>
</div>
<div class="checkbox">
<label class="tn-checkbox">
<input class="replace-underscores-with-spaces-checkbox" value="1" type="checkbox" checked>
${t("import.replaceUnderscoresWithSpaces")}
</label>
</div>
</div>
</div>
<div class="modal-footer">
<button class="import-button btn btn-primary">${t("import.import")}</button>
</div>
</form>
</div>
</div>
</div>`;
export default class ImportDialog extends BasicWidget {
private parentNoteId: string | null;
private $form!: JQuery<HTMLElement>;
private $noteTitle!: JQuery<HTMLElement>;
private $fileUploadInput!: JQuery<HTMLInputElement>;
private $importButton!: JQuery<HTMLElement>;
private $safeImportCheckbox!: JQuery<HTMLElement>;
private $shrinkImagesCheckbox!: JQuery<HTMLElement>;
private $textImportedAsTextCheckbox!: JQuery<HTMLElement>;
private $codeImportedAsCodeCheckbox!: JQuery<HTMLElement>;
private $explodeArchivesCheckbox!: JQuery<HTMLElement>;
private $replaceUnderscoresWithSpacesCheckbox!: JQuery<HTMLElement>;
constructor() {
super();
this.parentNoteId = null;
}
doRender() {
this.$widget = $(TPL);
Modal.getOrCreateInstance(this.$widget[0]);
this.$form = this.$widget.find(".import-form");
this.$noteTitle = this.$widget.find(".import-note-title");
this.$fileUploadInput = this.$widget.find(".import-file-upload-input");
this.$importButton = this.$widget.find(".import-button");
this.$safeImportCheckbox = this.$widget.find(".safe-import-checkbox");
this.$shrinkImagesCheckbox = this.$widget.find(".shrink-images-checkbox");
this.$textImportedAsTextCheckbox = this.$widget.find(".text-imported-as-text-checkbox");
this.$codeImportedAsCodeCheckbox = this.$widget.find(".code-imported-as-code-checkbox");
this.$explodeArchivesCheckbox = this.$widget.find(".explode-archives-checkbox");
this.$replaceUnderscoresWithSpacesCheckbox = this.$widget.find(".replace-underscores-with-spaces-checkbox");
this.$form.on("submit", () => {
// disabling so that import is not triggered again.
this.$importButton.attr("disabled", "disabled");
if (this.parentNoteId) {
this.importIntoNote(this.parentNoteId);
}
return false;
});
this.$fileUploadInput.on("change", () => {
if (this.$fileUploadInput.val()) {
this.$importButton.removeAttr("disabled");
} else {
this.$importButton.attr("disabled", "disabled");
}
});
let _ = [...this.$widget.find('[data-bs-toggle="tooltip"]')].forEach((element) => {
Tooltip.getOrCreateInstance(element, {
html: true
});
});
}
async showImportDialogEvent({ noteId }: EventData<"showImportDialog">) {
this.parentNoteId = noteId;
this.$fileUploadInput.val("").trigger("change"); // to trigger Import button disabling listener below
this.$safeImportCheckbox.prop("checked", true);
this.$shrinkImagesCheckbox.prop("checked", options.is("compressImages"));
this.$textImportedAsTextCheckbox.prop("checked", true);
this.$codeImportedAsCodeCheckbox.prop("checked", true);
this.$explodeArchivesCheckbox.prop("checked", true);
this.$replaceUnderscoresWithSpacesCheckbox.prop("checked", true);
this.$noteTitle.text(await treeService.getNoteTitle(this.parentNoteId));
utils.openDialog(this.$widget);
}
async importIntoNote(parentNoteId: string) {
const files = Array.from(this.$fileUploadInput[0].files ?? []); // shallow copy since we're resetting the upload button below
const boolToString = ($el: JQuery<HTMLElement>) => ($el.is(":checked") ? "true" : "false");
const options: UploadFilesOptions = {
safeImport: boolToString(this.$safeImportCheckbox),
shrinkImages: boolToString(this.$shrinkImagesCheckbox),
textImportedAsText: boolToString(this.$textImportedAsTextCheckbox),
codeImportedAsCode: boolToString(this.$codeImportedAsCodeCheckbox),
explodeArchives: boolToString(this.$explodeArchivesCheckbox),
replaceUnderscoresWithSpaces: boolToString(this.$replaceUnderscoresWithSpacesCheckbox)
};
this.$widget.modal("hide");
await importService.uploadFiles("notes", parentNoteId, files, options);
}
}

View File

@@ -0,0 +1,116 @@
import { t } from "../../services/i18n.js";
import treeService from "../../services/tree.js";
import noteAutocompleteService from "../../services/note_autocomplete.js";
import utils from "../../services/utils.js";
import froca from "../../services/froca.js";
import BasicWidget from "../basic_widget.js";
import { Modal } from "bootstrap";
import type { EventData } from "../../components/app_context.js";
import type EditableTextTypeWidget from "../type_widgets/editable_text.js";
const TPL = /*html*/`
<div class="include-note-dialog modal mx-auto" tabindex="-1" role="dialog">
<div class="modal-dialog modal-lg" role="document">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">${t("include_note.dialog_title")}</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="${t("include_note.close")}"></button>
</div>
<form class="include-note-form">
<div class="modal-body">
<div class="form-group">
<label for="include-note-autocomplete">${t("include_note.label_note")}</label>
<div class="input-group">
<input class="include-note-autocomplete form-control" placeholder="${t("include_note.placeholder_search")}">
</div>
</div>
${t("include_note.box_size_prompt")}
<div class="form-check">
<label class="form-check-label tn-radio">
<input class="form-check-input" type="radio" name="include-note-box-size" value="small">
${t("include_note.box_size_small")}
</label>
</div>
<div class="form-check">
<label class="form-check-label tn-radio">
<input class="form-check-input" type="radio" name="include-note-box-size" value="medium" checked>
${t("include_note.box_size_medium")}
</label>
</div>
<div class="form-check">
<label class="form-check-label tn-radio">
<input class="form-check-input" type="radio" name="include-note-box-size" value="full">
${t("include_note.box_size_full")}
</label>
</div>
</div>
<div class="modal-footer">
<button type="submit" class="btn btn-primary">${t("include_note.button_include")}</button>
</div>
</form>
</div>
</div>
</div>`;
export default class IncludeNoteDialog extends BasicWidget {
private modal!: bootstrap.Modal;
private $form!: JQuery<HTMLElement>;
private $autoComplete!: JQuery<HTMLElement>;
private textTypeWidget?: EditableTextTypeWidget;
doRender() {
this.$widget = $(TPL);
this.modal = Modal.getOrCreateInstance(this.$widget[0]);
this.$form = this.$widget.find(".include-note-form");
this.$autoComplete = this.$widget.find(".include-note-autocomplete");
this.$form.on("submit", () => {
const notePath = this.$autoComplete.getSelectedNotePath();
if (notePath) {
this.modal.hide();
this.includeNote(notePath);
} else {
logError("No noteId to include.");
}
return false;
});
}
async showIncludeNoteDialogEvent({ textTypeWidget }: EventData<"showIncludeDialog">) {
this.textTypeWidget = textTypeWidget;
await this.refresh();
utils.openDialog(this.$widget);
this.$autoComplete.trigger("focus").trigger("select"); // to be able to quickly remove entered text
}
async refresh() {
this.$autoComplete.val("");
noteAutocompleteService.initNoteAutocomplete(this.$autoComplete, {
hideGoToSelectedNoteButton: true,
allowCreatingNotes: true
});
noteAutocompleteService.showRecentNotes(this.$autoComplete);
}
async includeNote(notePath: string) {
const noteId = treeService.getNoteIdFromUrl(notePath);
if (!noteId) {
return;
}
const note = await froca.getNote(noteId);
const boxSize = $("input[name='include-note-box-size']:checked").val() as string;
if (["image", "canvas", "mermaid"].includes(note?.type ?? "")) {
// there's no benefit to use insert note functionlity for images,
// so we'll just add an IMG tag
this.textTypeWidget?.addImage(noteId);
} else {
this.textTypeWidget?.addIncludeNote(noteId, boxSize);
}
}
}

View File

@@ -0,0 +1,79 @@
import type { EventData } from "../../components/app_context.js";
import { t } from "../../services/i18n.js";
import utils from "../../services/utils.js";
import BasicWidget from "../basic_widget.js";
import { Modal } from "bootstrap";
import type { ConfirmDialogCallback } from "./confirm.js";
const TPL = /*html*/`
<div class="info-dialog modal mx-auto" tabindex="-1" role="dialog" style="z-index: 2000;">
<div class="modal-dialog" role="document">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">${t("info.modalTitle")}</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="${t("info.closeButton")}"></button>
</div>
<div class="modal-body">
<div class="info-dialog-content"></div>
</div>
<div class="modal-footer">
<button class="info-dialog-ok-button btn btn-primary btn-sm">${t("info.okButton")}</button>
</div>
</div>
</div>
</div>`;
export default class InfoDialog extends BasicWidget {
private resolve: ConfirmDialogCallback | null;
private modal!: bootstrap.Modal;
private $originallyFocused!: JQuery<HTMLElement> | null;
private $infoContent!: JQuery<HTMLElement>;
private $okButton!: JQuery<HTMLElement>;
constructor() {
super();
this.resolve = null;
this.$originallyFocused = null; // element focused before the dialog was opened, so we can return to it afterward
}
doRender() {
this.$widget = $(TPL);
this.modal = Modal.getOrCreateInstance(this.$widget[0]);
this.$infoContent = this.$widget.find(".info-dialog-content");
this.$okButton = this.$widget.find(".info-dialog-ok-button");
this.$widget.on("shown.bs.modal", () => this.$okButton.trigger("focus"));
this.$widget.on("hidden.bs.modal", () => {
if (this.resolve) {
this.resolve();
}
if (this.$originallyFocused) {
this.$originallyFocused.trigger("focus");
this.$originallyFocused = null;
}
});
this.$okButton.on("click", () => this.modal.hide());
}
showInfoDialogEvent({ message, callback }: EventData<"showInfoDialog">) {
this.$originallyFocused = $(":focus");
if (typeof message === "string") {
this.$infoContent.text(message);
} else if (Array.isArray(message)) {
this.$infoContent.html(message[0]);
} else {
this.$infoContent.html(message as HTMLElement);
}
utils.openDialog(this.$widget);
this.resolve = callback;
}
}

View File

@@ -0,0 +1,132 @@
import { t } from "../../services/i18n.js";
import noteAutocompleteService from "../../services/note_autocomplete.js";
import utils from "../../services/utils.js";
import appContext from "../../components/app_context.js";
import BasicWidget from "../basic_widget.js";
import shortcutService from "../../services/shortcuts.js";
import { Modal } from "bootstrap";
const TPL = /*html*/`<div class="jump-to-note-dialog modal mx-auto" tabindex="-1" role="dialog">
<div class="modal-dialog modal-lg" role="document">
<div class="modal-content">
<div class="modal-header">
<div class="input-group">
<input class="jump-to-note-autocomplete form-control" placeholder="${t("jump_to_note.search_placeholder")}">
</div>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="${t("jump_to_note.close")}"></button>
</div>
<div class="modal-body">
<div class="algolia-autocomplete-container jump-to-note-results"></div>
</div>
<div class="modal-footer">
<button class="show-in-full-text-button btn btn-sm">${t("jump_to_note.search_button")}</button>
</div>
</div>
</div>
</div>`;
const KEEP_LAST_SEARCH_FOR_X_SECONDS = 120;
export default class JumpToNoteDialog extends BasicWidget {
private lastOpenedTs: number;
private modal!: bootstrap.Modal;
private $autoComplete!: JQuery<HTMLElement>;
private $results!: JQuery<HTMLElement>;
private $showInFullTextButton!: JQuery<HTMLElement>;
constructor() {
super();
this.lastOpenedTs = 0;
}
doRender() {
this.$widget = $(TPL);
this.modal = Modal.getOrCreateInstance(this.$widget[0]);
this.$autoComplete = this.$widget.find(".jump-to-note-autocomplete");
this.$results = this.$widget.find(".jump-to-note-results");
this.$showInFullTextButton = this.$widget.find(".show-in-full-text-button");
this.$showInFullTextButton.on("click", (e) => this.showInFullText(e));
shortcutService.bindElShortcut(this.$widget, "ctrl+return", (e) => this.showInFullText(e));
}
async jumpToNoteEvent() {
const dialogPromise = utils.openDialog(this.$widget);
if (utils.isMobile()) {
dialogPromise.then(($dialog) => {
const el = $dialog.find(">.modal-dialog")[0];
function reposition() {
const offset = 100;
const modalHeight = (window.visualViewport?.height ?? 0) - offset;
const safeAreaInsetBottom = (window.visualViewport?.height ?? 0) - window.innerHeight;
el.style.height = `${modalHeight}px`;
el.style.bottom = `${(window.visualViewport?.height ?? 0) - modalHeight - safeAreaInsetBottom - offset}px`;
}
this.$autoComplete.on("focus", () => {
reposition();
});
window.visualViewport?.addEventListener("resize", () => {
reposition();
});
reposition();
});
}
// first open dialog, then refresh since refresh is doing focus which should be visible
this.refresh();
this.lastOpenedTs = Date.now();
}
async refresh() {
noteAutocompleteService
.initNoteAutocomplete(this.$autoComplete, {
allowCreatingNotes: true,
hideGoToSelectedNoteButton: true,
allowJumpToSearchNotes: true,
container: this.$results[0]
})
// clear any event listener added in previous invocation of this function
.off("autocomplete:noteselected")
.on("autocomplete:noteselected", function (event, suggestion, dataset) {
if (!suggestion.notePath) {
return false;
}
appContext.tabManager.getActiveContext()?.setNote(suggestion.notePath);
});
// if you open the Jump To dialog soon after using it previously, it can often mean that you
// actually want to search for the same thing (e.g., you opened the wrong note at first try)
// so we'll keep the content.
// if it's outside of this time limit, then we assume it's a completely new search and show recent notes instead.
if (Date.now() - this.lastOpenedTs > KEEP_LAST_SEARCH_FOR_X_SECONDS * 1000) {
noteAutocompleteService.showRecentNotes(this.$autoComplete);
} else {
this.$autoComplete
// hack, the actual search value is stored in <pre> element next to the search input
// this is important because the search input value is replaced with the suggestion note's title
.autocomplete("val", this.$autoComplete.next().text())
.trigger("focus")
.trigger("select");
}
}
showInFullText(e: JQuery.TriggeredEvent) {
// stop from propagating upwards (dangerous, especially with ctrl+enter executable javascript notes)
e.preventDefault();
e.stopPropagation();
const searchString = String(this.$autoComplete.val());
this.triggerCommand("searchNotes", { searchString });
this.modal.hide();
}
}

View File

@@ -0,0 +1,104 @@
import { t } from "../../services/i18n.js";
import toastService from "../../services/toast.js";
import utils from "../../services/utils.js";
import appContext from "../../components/app_context.js";
import BasicWidget from "../basic_widget.js";
import shortcutService from "../../services/shortcuts.js";
import server from "../../services/server.js";
import { Modal } from "bootstrap";
const TPL = /*html*/`
<div class="markdown-import-dialog modal fade mx-auto" tabindex="-1" role="dialog">
<div class="modal-dialog modal-lg" role="document">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">${t("markdown_import.dialog_title")}</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="${t("markdown_import.close")}"></button>
</div>
<div class="modal-body">
<p>${t("markdown_import.modal_body_text")}</p>
<textarea class="markdown-import-textarea" style="height: 340px; width: 100%"></textarea>
</div>
<div class="modal-footer">
<button class="markdown-import-button btn btn-primary">${t("markdown_import.import_button")}</button>
</div>
</div>
</div>
</div>`;
interface RenderMarkdownResponse {
htmlContent: string;
}
export default class MarkdownImportDialog extends BasicWidget {
private lastOpenedTs: number;
private modal!: bootstrap.Modal;
private $importTextarea!: JQuery<HTMLElement>;
private $importButton!: JQuery<HTMLElement>;
constructor() {
super();
this.lastOpenedTs = 0;
}
doRender() {
this.$widget = $(TPL);
this.modal = Modal.getOrCreateInstance(this.$widget[0]);
this.$importTextarea = this.$widget.find(".markdown-import-textarea");
this.$importButton = this.$widget.find(".markdown-import-button");
this.$importButton.on("click", () => this.sendForm());
this.$widget.on("shown.bs.modal", () => this.$importTextarea.trigger("focus"));
shortcutService.bindElShortcut(this.$widget, "ctrl+return", () => this.sendForm());
}
async convertMarkdownToHtml(markdownContent: string) {
const { htmlContent } = await server.post<RenderMarkdownResponse>("other/render-markdown", { markdownContent });
const textEditor = await appContext.tabManager.getActiveContext()?.getTextEditor();
if (!textEditor) {
return;
}
const viewFragment = textEditor.data.processor.toView(htmlContent);
const modelFragment = textEditor.data.toModel(viewFragment);
textEditor.model.insertContent(modelFragment, textEditor.model.document.selection);
toastService.showMessage(t("markdown_import.import_success"));
}
async pasteMarkdownIntoTextEvent() {
await this.importMarkdownInlineEvent(); // BC with keyboard shortcuts command
}
async importMarkdownInlineEvent() {
if (appContext.tabManager.getActiveContextNoteType() !== "text") {
return;
}
if (utils.isElectron()) {
const { clipboard } = utils.dynamicRequire("electron");
const text = clipboard.readText();
this.convertMarkdownToHtml(text);
} else {
utils.openDialog(this.$widget);
}
}
async sendForm() {
const text = String(this.$importTextarea.val());
this.modal.hide();
await this.convertMarkdownToHtml(text);
this.$importTextarea.val("");
}
}

View File

@@ -0,0 +1,120 @@
import noteAutocompleteService from "../../services/note_autocomplete.js";
import utils from "../../services/utils.js";
import toastService from "../../services/toast.js";
import froca from "../../services/froca.js";
import branchService from "../../services/branches.js";
import treeService from "../../services/tree.js";
import BasicWidget from "../basic_widget.js";
import { t } from "../../services/i18n.js";
import type { EventData } from "../../components/app_context.js";
const TPL = /*html*/`
<div class="move-to-dialog modal mx-auto" tabindex="-1" role="dialog">
<div class="modal-dialog modal-lg" style="max-width: 1000px" role="document">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title me-auto">${t("move_to.dialog_title")}</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="${t("move_to.close")}"></button>
</div>
<form class="move-to-form">
<div class="modal-body">
<h5>${t("move_to.notes_to_move")}</h5>
<ul class="move-to-note-list" style="max-height: 200px; overflow: auto;"></ul>
<div class="form-group">
<label style="width: 100%">
${t("move_to.target_parent_note")}
<div class="input-group">
<input class="move-to-note-autocomplete form-control" placeholder="${t("move_to.search_placeholder")}">
</div>
</label>
</div>
</div>
<div class="modal-footer">
<button type="submit" class="btn btn-primary">${t("move_to.move_button")}</button>
</div>
</form>
</div>
</div>
</div>`;
export default class MoveToDialog extends BasicWidget {
private movedBranchIds: string[] | null;
private $form!: JQuery<HTMLElement>;
private $noteAutoComplete!: JQuery<HTMLElement>;
private $noteList!: JQuery<HTMLElement>;
constructor() {
super();
this.movedBranchIds = null;
}
doRender() {
this.$widget = $(TPL);
this.$form = this.$widget.find(".move-to-form");
this.$noteAutoComplete = this.$widget.find(".move-to-note-autocomplete");
this.$noteList = this.$widget.find(".move-to-note-list");
this.$form.on("submit", () => {
const notePath = this.$noteAutoComplete.getSelectedNotePath();
if (notePath) {
this.$widget.modal("hide");
const { noteId, parentNoteId } = treeService.getNoteIdAndParentIdFromUrl(notePath);
if (parentNoteId) {
froca.getBranchId(parentNoteId, noteId).then((branchId) => {
if (branchId) {
this.moveNotesTo(branchId);
}
});
}
} else {
logError(t("move_to.error_no_path"));
}
return false;
});
}
async moveBranchIdsToEvent({ branchIds }: EventData<"moveBranchIdsTo">) {
this.movedBranchIds = branchIds;
utils.openDialog(this.$widget);
this.$noteAutoComplete.val("").trigger("focus");
this.$noteList.empty();
for (const branchId of this.movedBranchIds) {
const branch = froca.getBranch(branchId);
if (!branch) {
continue;
}
const note = await froca.getNote(branch.noteId);
if (!note) {
continue;
}
this.$noteList.append($("<li>").text(note.title));
}
noteAutocompleteService.initNoteAutocomplete(this.$noteAutoComplete);
noteAutocompleteService.showRecentNotes(this.$noteAutoComplete);
}
async moveNotesTo(parentBranchId: string) {
if (this.movedBranchIds) {
await branchService.moveToParentNote(this.movedBranchIds, parentBranchId);
}
const parentBranch = froca.getBranch(parentBranchId);
const parentNote = await parentBranch?.getNote();
toastService.showMessage(`${t("move_to.move_success_message")} ${parentNote?.title}`);
}
}

View File

@@ -0,0 +1,166 @@
import type { CommandNames } from "../../components/app_context.js";
import type { MenuCommandItem } from "../../menus/context_menu.js";
import { t } from "../../services/i18n.js";
import noteTypesService from "../../services/note_types.js";
import BasicWidget from "../basic_widget.js";
import { Dropdown, Modal } from "bootstrap";
const TPL = /*html*/`
<div class="note-type-chooser-dialog modal mx-auto" tabindex="-1" role="dialog">
<style>
.note-type-chooser-dialog {
/* note type chooser needs to be higher than other dialogs from which it is triggered, e.g. "add link"*/
z-index: 1100 !important;
}
.note-type-chooser-dialog .note-type-dropdown {
position: relative;
font-size: large;
padding: 20px;
width: 100%;
margin-top: 15px;
max-height: 80vh;
overflow: auto;
}
</style>
<div class="modal-dialog" style="max-width: 500px;" role="document">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">${t("note_type_chooser.modal_title")}</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="${t("note_type_chooser.close")}"></button>
</div>
<div class="modal-body">
${t("note_type_chooser.modal_body")}
<div class="dropdown" style="display: flex;">
<button class="note-type-dropdown-trigger" type="button" style="display: none;"
data-bs-toggle="dropdown" data-bs-display="static">
</button>
<div class="note-type-dropdown dropdown-menu"></div>
</div>
</div>
</div>
</div>
</div>`;
export interface ChooseNoteTypeResponse {
success: boolean;
noteType?: string;
templateNoteId?: string;
}
type Callback = (data: ChooseNoteTypeResponse) => void;
export default class NoteTypeChooserDialog extends BasicWidget {
private resolve: Callback | null;
private dropdown!: Dropdown;
private modal!: Modal;
private $noteTypeDropdown!: JQuery<HTMLElement>;
private $originalFocused: JQuery<HTMLElement> | null;
private $originalDialog: JQuery<HTMLElement> | null;
constructor() {
super();
this.resolve = null;
this.$originalFocused = null; // element focused before the dialog was opened, so we can return to it afterward
this.$originalDialog = null;
}
doRender() {
this.$widget = $(TPL);
this.modal = Modal.getOrCreateInstance(this.$widget[0]);
this.$noteTypeDropdown = this.$widget.find(".note-type-dropdown");
this.dropdown = Dropdown.getOrCreateInstance(this.$widget.find(".note-type-dropdown-trigger")[0]);
this.$widget.on("hidden.bs.modal", () => {
if (this.resolve) {
this.resolve({ success: false });
}
if (this.$originalFocused) {
this.$originalFocused.trigger("focus");
this.$originalFocused = null;
}
glob.activeDialog = this.$originalDialog;
});
this.$noteTypeDropdown.on("click", ".dropdown-item", (e) => this.doResolve(e));
this.$noteTypeDropdown.on("focus", ".dropdown-item", (e) => {
this.$noteTypeDropdown.find(".dropdown-item").each((i, el) => {
$(el).toggleClass("active", el === e.target);
});
});
this.$noteTypeDropdown.on("keydown", ".dropdown-item", (e) => {
if (e.key === "Enter") {
this.doResolve(e);
e.preventDefault();
return false;
}
});
this.$noteTypeDropdown.parent().on("hide.bs.dropdown", (e) => {
// prevent closing dropdown by clicking outside
// TODO: Check if this actually works.
//@ts-ignore
if (e.clickEvent) {
e.preventDefault();
}
});
}
async chooseNoteTypeEvent({ callback }: { callback: Callback }) {
this.$originalFocused = $(":focus");
const noteTypes = await noteTypesService.getNoteTypeItems();
this.$noteTypeDropdown.empty();
for (const noteType of noteTypes) {
if (noteType.title === "----") {
this.$noteTypeDropdown.append($('<h6 class="dropdown-header">').append(t("note_type_chooser.templates")));
} else {
const commandItem = noteType as MenuCommandItem<CommandNames>;
this.$noteTypeDropdown.append(
$('<a class="dropdown-item" tabindex="0">')
.attr("data-note-type", commandItem.type || "")
.attr("data-template-note-id", commandItem.templateNoteId || "")
.append($("<span>").addClass(commandItem.uiIcon || ""))
.append(` ${noteType.title}`)
);
}
}
this.dropdown.show();
this.$originalDialog = glob.activeDialog;
glob.activeDialog = this.$widget;
this.modal.show();
this.$noteTypeDropdown.find(".dropdown-item:first").focus();
this.resolve = callback;
}
doResolve(e: JQuery.KeyDownEvent | JQuery.ClickEvent) {
const $item = $(e.target).closest(".dropdown-item");
const noteType = $item.attr("data-note-type");
const templateNoteId = $item.attr("data-template-note-id");
if (this.resolve) {
this.resolve({
success: true,
noteType,
templateNoteId
});
}
this.resolve = null;
this.modal.hide();
}
}

View File

@@ -0,0 +1,42 @@
import { t } from "../../services/i18n.js";
import utils from "../../services/utils.js";
import BasicWidget from "../basic_widget.js";
import { Modal } from "bootstrap";
const TPL = /*html*/`
<div class="password-not-set-dialog modal fade mx-auto" tabindex="-1" role="dialog">
<div class="modal-dialog modal-md" role="document">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">${t("password_not_set.title")}</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="${t("password_not_set.close")}"></button>
</div>
<div class="modal-body">
${t("password_not_set.body1")}
${t("password_not_set.body2")}
</div>
</div>
</div>
</div>
`;
export default class PasswordNoteSetDialog extends BasicWidget {
private modal!: Modal;
private $openPasswordOptionsButton!: JQuery<HTMLElement>;
doRender() {
this.$widget = $(TPL);
this.modal = Modal.getOrCreateInstance(this.$widget[0]);
this.$openPasswordOptionsButton = this.$widget.find(".open-password-options-button");
this.$openPasswordOptionsButton.on("click", () => {
this.modal.hide();
this.triggerCommand("showOptions", { section: "_optionsPassword" });
});
}
showPasswordNotSetEvent() {
utils.openDialog(this.$widget);
}
}

View File

@@ -0,0 +1,115 @@
import { t } from "../../services/i18n.js";
import utils from "../../services/utils.js";
import BasicWidget from "../basic_widget.js";
import { Modal } from "bootstrap";
const TPL = /*html*/`
<div class="prompt-dialog modal mx-auto" tabindex="-1" role="dialog" style="z-index: 2000;">
<div class="modal-dialog modal-lg" role="document">
<div class="modal-content">
<form class="prompt-dialog-form">
<div class="modal-header">
<h5 class="prompt-title modal-title">${t("prompt.title")}</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="${t("prompt.close")}"></button>
</div>
<div class="modal-body"></div>
<div class="modal-footer">
<button class="prompt-dialog-ok-button btn btn-primary btn-sm">${t("prompt.ok")}</button>
</div>
</form>
</div>
</div>
</div>`;
interface ShownCallbackData {
$dialog: JQuery<HTMLElement>;
$question: JQuery<HTMLElement> | null;
$answer: JQuery<HTMLElement> | null;
$form: JQuery<HTMLElement>;
}
export interface PromptDialogOptions {
title?: string;
message?: string;
defaultValue?: string;
shown?: PromptShownDialogCallback;
callback?: (value: string | null) => void;
}
export type PromptShownDialogCallback = ((callback: ShownCallbackData) => void) | null;
export default class PromptDialog extends BasicWidget {
private resolve?: ((value: string | null) => void) | undefined | null;
private shownCb?: PromptShownDialogCallback | null;
private modal!: Modal;
private $dialogBody!: JQuery<HTMLElement>;
private $question!: JQuery<HTMLElement> | null;
private $answer!: JQuery<HTMLElement> | null;
private $form!: JQuery<HTMLElement>;
constructor() {
super();
this.resolve = null;
this.shownCb = null;
}
doRender() {
this.$widget = $(TPL);
this.modal = Modal.getOrCreateInstance(this.$widget[0]);
this.$dialogBody = this.$widget.find(".modal-body");
this.$form = this.$widget.find(".prompt-dialog-form");
this.$question = null;
this.$answer = null;
this.$widget.on("shown.bs.modal", () => {
if (this.shownCb) {
this.shownCb({
$dialog: this.$widget,
$question: this.$question,
$answer: this.$answer,
$form: this.$form
});
}
this.$answer?.trigger("focus").select();
});
this.$widget.on("hidden.bs.modal", () => {
if (this.resolve) {
this.resolve(null);
}
});
this.$form.on("submit", (e) => {
e.preventDefault();
if (this.resolve) {
this.resolve(this.$answer?.val() as string);
}
this.modal.hide();
});
}
showPromptDialogEvent({ title, message, defaultValue, shown, callback }: PromptDialogOptions) {
this.shownCb = shown;
this.resolve = callback;
this.$widget.find(".prompt-title").text(title || t("prompt.defaultTitle"));
this.$question = $("<label>")
.prop("for", "prompt-dialog-answer")
.text(message || "");
this.$answer = $("<input>")
.prop("type", "text")
.prop("id", "prompt-dialog-answer")
.addClass("form-control")
.val(defaultValue || "");
this.$dialogBody.empty().append($("<div>").addClass("form-group").append(this.$question).append(this.$answer));
utils.openDialog(this.$widget, false);
}
}

View File

@@ -0,0 +1,60 @@
import { t } from "../../services/i18n.js";
import protectedSessionService from "../../services/protected_session.js";
import utils from "../../services/utils.js";
import BasicWidget from "../basic_widget.js";
import { Modal } from "bootstrap";
const TPL = /*html*/`
<div class="protected-session-password-dialog modal mx-auto" data-backdrop="false" tabindex="-1" role="dialog">
<div class="modal-dialog modal-md" role="document">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title flex-grow-1">${t("protected_session_password.modal_title")}</h5>
<button class="help-button" type="button" data-help-page="protected-notes.html" title="${t("protected_session_password.help_title")}">?</button>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="${t("protected_session_password.close_label")}"></button>
</div>
<form class="protected-session-password-form">
<div class="modal-body">
<label for="protected-session-password" class="col-form-label">${t("protected_session_password.form_label")}</label>
<input id="protected-session-password" class="form-control protected-session-password" type="password" autocomplete="current-password">
</div>
<div class="modal-footer">
<button class="btn btn-primary">${t("protected_session_password.start_button")}</button>
</div>
</form>
</div>
</div>
</div>`;
export default class ProtectedSessionPasswordDialog extends BasicWidget {
private modal!: bootstrap.Modal;
private $passwordForm!: JQuery<HTMLElement>;
private $passwordInput!: JQuery<HTMLElement>;
doRender() {
this.$widget = $(TPL);
this.modal = Modal.getOrCreateInstance(this.$widget[0]);
this.$passwordForm = this.$widget.find(".protected-session-password-form");
this.$passwordInput = this.$widget.find(".protected-session-password");
this.$passwordForm.on("submit", () => {
const password = String(this.$passwordInput.val());
this.$passwordInput.val("");
protectedSessionService.setupProtectedSession(password);
return false;
});
}
showProtectedSessionPasswordDialogEvent() {
utils.openDialog(this.$widget);
this.$passwordInput.trigger("focus");
}
closeProtectedSessionPasswordDialogEvent() {
this.modal.hide();
}
}

View File

@@ -0,0 +1,178 @@
import { formatDateTime } from "../../utils/formatters.js";
import { t } from "../../services/i18n.js";
import appContext, { type EventData } from "../../components/app_context.js";
import BasicWidget from "../basic_widget.js";
import dialogService from "../../services/dialog.js";
import froca from "../../services/froca.js";
import hoistedNoteService from "../../services/hoisted_note.js";
import linkService from "../../services/link.js";
import server from "../../services/server.js";
import toastService from "../../services/toast.js";
import utils from "../../services/utils.js";
import ws from "../../services/ws.js";
import { Modal } from "bootstrap";
const TPL = /*html*/`
<div class="recent-changes-dialog modal fade mx-auto" tabindex="-1" role="dialog">
<div class="modal-dialog modal-lg modal-dialog-scrollable" role="document">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title flex-grow-1">${t("recent_changes.title")}</h5>
<button class="erase-deleted-notes-now-button btn btn-sm" style="padding: 0 10px">${t("recent_changes.erase_notes_button")}</button>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="${t("recent_changes.close")}"></button>
</div>
<div class="modal-body">
<div class="recent-changes-content"></div>
</div>
</div>
</div>
</div>`;
// TODO: Deduplicate with server.
interface RecentChangesRow {
noteId: string;
date: string;
}
export default class RecentChangesDialog extends BasicWidget {
private ancestorNoteId?: string;
private modal!: bootstrap.Modal;
private $content!: JQuery<HTMLElement>;
private $eraseDeletedNotesNow!: JQuery<HTMLElement>;
doRender() {
this.$widget = $(TPL);
this.modal = Modal.getOrCreateInstance(this.$widget[0]);
this.$content = this.$widget.find(".recent-changes-content");
this.$eraseDeletedNotesNow = this.$widget.find(".erase-deleted-notes-now-button");
this.$eraseDeletedNotesNow.on("click", () => {
server.post("notes/erase-deleted-notes-now").then(() => {
this.refresh();
toastService.showMessage(t("recent_changes.deleted_notes_message"));
});
});
}
async showRecentChangesEvent({ ancestorNoteId }: EventData<"showRecentChanges">) {
this.ancestorNoteId = ancestorNoteId;
await this.refresh();
utils.openDialog(this.$widget);
}
async refresh() {
if (!this.ancestorNoteId) {
this.ancestorNoteId = hoistedNoteService.getHoistedNoteId();
}
const recentChangesRows = await server.get<RecentChangesRow[]>(`recent-changes/${this.ancestorNoteId}`);
// preload all notes into cache
await froca.getNotes(
recentChangesRows.map((r) => r.noteId),
true
);
this.$content.empty();
if (recentChangesRows.length === 0) {
this.$content.append(t("recent_changes.no_changes_message"));
}
const groupedByDate = this.groupByDate(recentChangesRows);
for (const [dateDay, dayChanges] of groupedByDate) {
const $changesList = $("<ul>");
const formattedDate = formatDateTime(dateDay, "full", "none");
const dayEl = $("<div>").append($("<b>").text(formattedDate)).append($changesList);
for (const change of dayChanges) {
const formattedTime = formatDateTime(change.date, "none", "short");
let $noteLink;
if (change.current_isDeleted) {
$noteLink = $("<span>");
$noteLink.append($("<span>").addClass("note-title").text(change.current_title));
if (change.canBeUndeleted) {
const $undeleteLink = $(`<a href="javascript:">`)
.text(t("recent_changes.undelete_link"))
.on("click", async () => {
const text = t("recent_changes.confirm_undelete");
if (await dialogService.confirm(text)) {
await server.put(`notes/${change.noteId}/undelete`);
this.modal.hide();
await ws.waitForMaxKnownEntityChangeId();
const activeContext = appContext.tabManager.getActiveContext();
if (activeContext) {
activeContext.setNote(change.noteId);
}
}
});
$noteLink.append(" (").append($undeleteLink).append(")");
}
} else {
const note = await froca.getNote(change.noteId);
const notePath = note?.getBestNotePathString();
if (notePath) {
$noteLink = await linkService.createLink(notePath, {
title: change.title,
showNotePath: true
});
} else {
$noteLink = $("<span>").text(note?.title ?? "");
}
}
$changesList.append(
$("<li>")
.on("click", (e) => {
// Skip clicks on the link or deleted notes
if (e.target?.nodeName !== "A" && !change.current_isDeleted) {
// Open the current note
const activeContext = appContext.tabManager.getActiveContext();
if (activeContext) {
activeContext.setNote(change.noteId);
}
}
})
.toggleClass("deleted-note", !!change.current_isDeleted)
.append($("<span>").text(formattedTime).attr("title", change.date))
.append($noteLink.addClass("note-title"))
);
}
this.$content.append(dayEl);
}
}
groupByDate(rows: RecentChangesRow[]) {
const groupedByDate = new Map();
for (const row of rows) {
const dateDay = row.date.substr(0, 10);
if (!groupedByDate.has(dateDay)) {
groupedByDate.set(dateDay, []);
}
groupedByDate.get(dateDay).push(row);
}
return groupedByDate;
}
}

View File

@@ -0,0 +1,382 @@
import { t } from "../../services/i18n.js";
import utils from "../../services/utils.js";
import server from "../../services/server.js";
import toastService from "../../services/toast.js";
import appContext from "../../components/app_context.js";
import libraryLoader from "../../services/library_loader.js";
import openService from "../../services/open.js";
import protectedSessionHolder from "../../services/protected_session_holder.js";
import BasicWidget from "../basic_widget.js";
import dialogService from "../../services/dialog.js";
import options from "../../services/options.js";
import type FNote from "../../entities/fnote.js";
import type { NoteType } from "../../entities/fnote.js";
import { Dropdown, Modal } from "bootstrap";
const TPL = /*html*/`
<div class="revisions-dialog modal fade mx-auto" tabindex="-1" role="dialog">
<style>
.revisions-dialog .revision-content-wrapper {
flex-grow: 1;
margin-left: 20px;
display: flex;
flex-direction: column;
min-width: 0;
}
.revisions-dialog .revision-content {
overflow: auto;
word-break: break-word;
}
.revisions-dialog .revision-content img {
max-width: 100%;
object-fit: contain;
}
.revisions-dialog .revision-content pre {
max-width: 100%;
word-break: break-all;
white-space: pre-wrap;
}
</style>
<div class="modal-dialog modal-xl" role="document">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title flex-grow-1">${t("revisions.note_revisions")}</h5>
<button class="revisions-erase-all-revisions-button btn btn-sm"
title="${t("revisions.delete_all_revisions")}"
style="padding: 0 10px 0 10px;" type="button">${t("revisions.delete_all_button")}</button>
<button class="help-button" type="button" data-help-page="note-revisions.html" title="${t("revisions.help_title")}">?</button>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="${t("revisions.close")}"></button>
</div>
<div class="modal-body" style="display: flex; height: 80vh;">
<div class="dropdown">
<button class="revision-list-dropdown" type="button" style="display: none;"
data-bs-toggle="dropdown" data-bs-display="static">
</button>
<div class="revision-list dropdown-menu static" style="position: static; height: 100%; overflow: auto;"></div>
</div>
<div class="revision-content-wrapper">
<div style="flex-grow: 0; display: flex; justify-content: space-between;">
<h3 class="revision-title" style="margin: 3px; flex-grow: 100;"></h3>
<div class="revision-title-buttons"></div>
</div>
<div class="revision-content use-tn-links"></div>
</div>
</div>
<div class="modal-footer py-0">
<span class="revisions-snapshot-interval flex-grow-1 my-0 py-0"></span>
<span class="maximum-revisions-for-current-note flex-grow-1 my-0 py-0"></span>
<button class="revision-settings-button icon-action bx bx-cog my-0 py-0" title="${t("revisions.settings")}"></button>
</div>
</div>
</div>
</div>`;
interface RevisionItem {
noteId: string;
revisionId: string;
dateLastEdited: string;
contentLength: number;
type: NoteType;
title: string;
isProtected: boolean;
mime: string;
}
interface FullRevision {
content: string;
mime: string;
}
export default class RevisionsDialog extends BasicWidget {
private revisionItems: RevisionItem[];
private note: FNote | null;
private revisionId: string | null;
private modal!: Modal;
private listDropdown!: Dropdown;
private $list!: JQuery<HTMLElement>;
private $listDropdown!: JQuery<HTMLElement>;
private $content!: JQuery<HTMLElement>;
private $title!: JQuery<HTMLElement>;
private $titleButtons!: JQuery<HTMLElement>;
private $eraseAllRevisionsButton!: JQuery<HTMLElement>;
private $maximumRevisions!: JQuery<HTMLElement>;
private $snapshotInterval!: JQuery<HTMLElement>;
private $revisionSettingsButton!: JQuery<HTMLElement>;
constructor() {
super();
this.revisionItems = [];
this.note = null;
this.revisionId = null;
}
doRender() {
this.$widget = $(TPL);
this.modal = Modal.getOrCreateInstance(this.$widget[0]);
this.$list = this.$widget.find(".revision-list");
this.$listDropdown = this.$widget.find(".revision-list-dropdown");
this.listDropdown = Dropdown.getOrCreateInstance(this.$listDropdown[0], { autoClose: false });
this.$content = this.$widget.find(".revision-content");
this.$title = this.$widget.find(".revision-title");
this.$titleButtons = this.$widget.find(".revision-title-buttons");
this.$eraseAllRevisionsButton = this.$widget.find(".revisions-erase-all-revisions-button");
this.$snapshotInterval = this.$widget.find(".revisions-snapshot-interval");
this.$maximumRevisions = this.$widget.find(".maximum-revisions-for-current-note");
this.$revisionSettingsButton = this.$widget.find(".revision-settings-button");
this.listDropdown.show();
this.$listDropdown.parent().on("hide.bs.dropdown", (e) => {
this.modal.hide();
});
this.$widget.on("shown.bs.modal", () => {
this.$list.find(`[data-revision-id="${this.revisionId}"]`).trigger("focus");
});
this.$eraseAllRevisionsButton.on("click", async () => {
if (!this.note) {
return;
}
const text = t("revisions.confirm_delete_all");
if (await dialogService.confirm(text)) {
await server.remove(`notes/${this.note.noteId}/revisions`);
this.modal.hide();
toastService.showMessage(t("revisions.revisions_deleted"));
}
});
this.$list.on("focus", ".dropdown-item", (e) => {
this.$list.find(".dropdown-item").each((i, el) => {
$(el).toggleClass("active", el === e.target);
});
this.setContentPane();
});
this.$revisionSettingsButton.on("click", async () => {
appContext.tabManager.openContextWithNote("_optionsOther", { activate: true });
});
}
async showRevisionsEvent({ noteId = appContext.tabManager.getActiveContextNoteId() }) {
if (!noteId) {
return;
}
utils.openDialog(this.$widget);
await this.loadRevisions(noteId);
}
async loadRevisions(noteId: string) {
this.$title.empty();
this.$list.empty();
this.$content.empty();
this.$titleButtons.empty();
this.note = appContext.tabManager.getActiveContextNote();
this.revisionItems = await server.get<RevisionItem[]>(`notes/${noteId}/revisions`);
for (const item of this.revisionItems) {
this.$list.append(
$('<a class="dropdown-item" tabindex="0">')
.text(`${item.dateLastEdited.substr(0, 16)} (${utils.formatSize(item.contentLength)})`)
.attr("data-revision-id", item.revisionId)
.attr("title", t("revisions.revision_last_edited", { date: item.dateLastEdited }))
);
}
this.listDropdown.show();
if (this.revisionItems.length > 0) {
if (!this.revisionId) {
this.revisionId = this.revisionItems[0].revisionId;
}
} else {
this.$title.text(t("revisions.no_revisions"));
this.revisionId = null;
}
this.$eraseAllRevisionsButton.toggle(this.revisionItems.length > 0);
// Show the footer of the revisions dialog
this.$snapshotInterval.text(t("revisions.snapshot_interval", { seconds: options.getInt("revisionSnapshotTimeInterval") }));
let revisionsNumberLimit: number | string = parseInt(this.note?.getLabelValue("versioningLimit") ?? "");
if (!Number.isInteger(revisionsNumberLimit)) {
revisionsNumberLimit = options.getInt("revisionSnapshotNumberLimit") ?? 0;
}
if (revisionsNumberLimit === -1) {
revisionsNumberLimit = "∞";
}
this.$maximumRevisions.text(t("revisions.maximum_revisions", { number: revisionsNumberLimit }));
}
async setContentPane() {
const revisionId = this.$list.find(".active").attr("data-revision-id");
const revisionItem = this.revisionItems.find((r) => r.revisionId === revisionId);
if (!revisionItem) {
return;
}
this.$title.html(revisionItem.title);
this.renderContentButtons(revisionItem);
await this.renderContent(revisionItem);
}
renderContentButtons(revisionItem: RevisionItem) {
this.$titleButtons.empty();
const $restoreRevisionButton = $(`
<button class="btn btn-sm" type="button">
<span class="bx bx-history"></span>
${t("revisions.restore_button")}
</button>
`);
$restoreRevisionButton.on("click", async () => {
const text = t("revisions.confirm_restore");
if (await dialogService.confirm(text)) {
await server.post(`revisions/${revisionItem.revisionId}/restore`);
this.modal.hide();
toastService.showMessage(t("revisions.revision_restored"));
}
});
const $eraseRevisionButton = $(`
<button class="btn btn-sm" type="button">
<span class="bx bx-trash"></span>
${t("revisions.delete_button")}
</button>
`);
$eraseRevisionButton.on("click", async () => {
const text = t("revisions.confirm_delete");
if (await dialogService.confirm(text)) {
await server.remove(`revisions/${revisionItem.revisionId}`);
this.loadRevisions(revisionItem.noteId);
toastService.showMessage(t("revisions.revision_deleted"));
}
});
if (!revisionItem.isProtected || protectedSessionHolder.isProtectedSessionAvailable()) {
this.$titleButtons.append($restoreRevisionButton).append(" &nbsp; ");
}
this.$titleButtons.append($eraseRevisionButton).append(" &nbsp; ");
const $downloadButton = $(`
<button class="btn btn-sm btn-primary" type="button">
<span class="bx bx-download"></span>
${t("revisions.download_button")}
</button>
`);
$downloadButton.on("click", () => openService.downloadRevision(revisionItem.noteId, revisionItem.revisionId));
if (!revisionItem.isProtected || protectedSessionHolder.isProtectedSessionAvailable()) {
this.$titleButtons.append($downloadButton);
}
}
async renderContent(revisionItem: RevisionItem) {
this.$content.empty();
const fullRevision = await server.get<FullRevision>(`revisions/${revisionItem.revisionId}`);
if (revisionItem.type === "text") {
this.$content.html(`<div class="ck-content">${fullRevision.content}</div>`);
if (this.$content.find("span.math-tex").length > 0) {
await libraryLoader.requireLibrary(libraryLoader.KATEX);
renderMathInElement(this.$content[0], { trust: true });
}
} else if (revisionItem.type === "code") {
this.$content.html($("<pre>")
.text(fullRevision.content).prop("outerHTML"));
} else if (revisionItem.type === "image") {
if (fullRevision.mime === "image/svg+xml") {
let encodedSVG = encodeURIComponent(fullRevision.content); //Base64 of other format images may be embedded in svg
this.$content.html($("<img>")
.attr("src", `data:${fullRevision.mime};utf8,${encodedSVG}`)
.css("max-width", "100%")
.css("max-height", "100%").prop("outerHTML"));
} else {
this.$content.html(
$("<img>")
// the reason why we put this inline as base64 is that we do not want to let user copy this
// as a URL to be used in a note. Instead, if they copy and paste it into a note, it will be uploaded as a new note
.attr("src", `data:${fullRevision.mime};base64,${fullRevision.content}`)
.css("max-width", "100%")
.css("max-height", "100%")
.prop("outerHTML")
);
}
} else if (revisionItem.type === "file") {
const $table = $("<table cellpadding='10'>")
.append($("<tr>")
.append(
$("<th>").text(t("revisions.mime")),
$("<td>").text(revisionItem.mime)))
.append($("<tr>").append($("<th>").text(t("revisions.file_size")), $("<td>").text(utils.formatSize(revisionItem.contentLength))));
if (fullRevision.content) {
$table.append(
$("<tr>").append(
$('<td colspan="2">').append($('<div style="font-weight: bold;">').text(t("revisions.preview")), $('<pre class="file-preview-content"></pre>').text(fullRevision.content))
)
);
}
this.$content.html($table.prop("outerHTML"));
} else if (["canvas", "mindMap"].includes(revisionItem.type)) {
const encodedTitle = encodeURIComponent(revisionItem.title);
this.$content.html(
$("<img>")
.attr("src", `api/revisions/${revisionItem.revisionId}/image/${encodedTitle}?${Math.random()}`)
.css("max-width", "100%")
.prop("outerHTML"));
} else if (revisionItem.type === "mermaid") {
const encodedTitle = encodeURIComponent(revisionItem.title);
this.$content.html(
$("<img>")
.attr("src", `api/revisions/${revisionItem.revisionId}/image/${encodedTitle}?${Math.random()}`)
.css("max-width", "100%")
.prop("outerHTML"));
this.$content.append($("<pre>").text(fullRevision.content));
} else {
this.$content.text(t("revisions.preview_not_available"));
}
}
}

View File

@@ -0,0 +1,111 @@
import type { EventData } from "../../components/app_context.js";
import { t } from "../../services/i18n.js";
import server from "../../services/server.js";
import utils from "../../services/utils.js";
import BasicWidget from "../basic_widget.js";
const TPL = /*html*/`<div class="sort-child-notes-dialog modal mx-auto" tabindex="-1" role="dialog">
<div class="modal-dialog modal-lg" style="max-width: 500px" role="document">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">${t("sort_child_notes.sort_children_by")}</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="${t("sort_child_notes.close")}"></button>
</div>
<form class="sort-child-notes-form">
<div class="modal-body">
<h5>${t("sort_child_notes.sorting_criteria")}</h5>
<div class="form-check">
<label for="sort-by-title" class="form-check-label tn-radio">
<input id="sort-by-title" class="form-check-input" type="radio" name="sort-by" value="title" checked>
${t("sort_child_notes.title")}
</label>
</div>
<div class="form-check">
<label for="sort-by-dateCreated" class="form-check-label tn-radio">
<input id="sort-by-dateCreated" class="form-check-input" type="radio" name="sort-by" value="dateCreated">
${t("sort_child_notes.date_created")}
</label>
</div>
<div class="form-check">
<label for="sort-by-dateModified" class="form-check-label tn-radio">
<input id="sort-by-dateModified" class="form-check-input" type="radio" name="sort-by" value="dateModified">
${t("sort_child_notes.date_modified")}
</label>
</div>
<br/>
<h5>${t("sort_child_notes.sorting_direction")}</h5>
<div class="form-check">
<label for="sort-direction-asc" class="form-check-label tn-radio">
<input id="sort-direction-asc" class="form-check-input" type="radio" name="sort-direction" value="asc" checked>
${t("sort_child_notes.ascending")}
</label>
</div>
<div class="form-check">
<label for="sort-direction-desc" class="form-check-label tn-radio">
<input id="sort-direction-desc" class="form-check-input" type="radio" name="sort-direction" value="desc">
${t("sort_child_notes.descending")}
</label>
</div>
<br />
<h5>${t("sort_child_notes.folders")}</h5>
<div class="form-check">
<label for="sort-folders-first" class="form-check-label tn-checkbox">
<input id="sort-folders-first" class="form-check-input" type="checkbox" name="sort-folders-first" value="1">
${t("sort_child_notes.sort_folders_at_top")}
</label>
</div>
<br />
<h5>${t("sort_child_notes.natural_sort")}</h5>
<div class="form-check">
<label for="sort-natural" class="form-check-label tn-checkbox">
<input id="sort-natural" class="form-check-input" type="checkbox" name="sort-natural" value="1">
${t("sort_child_notes.sort_with_respect_to_different_character_sorting")}
</label>
</div>
<br />
<div class="form-check">
<label>
${t("sort_child_notes.natural_sort_language")}
<input class="form-control" name="sort-locale">
${t("sort_child_notes.the_language_code_for_natural_sort")}
</label>
</div>
</div>
<div class="modal-footer">
<button type="submit" class="btn btn-primary">${t("sort_child_notes.sort")}</button>
</div>
</form>
</div>
</div>
</div>`;
export default class SortChildNotesDialog extends BasicWidget {
private parentNoteId?: string;
private $form!: JQuery<HTMLElement>;
doRender() {
this.$widget = $(TPL);
this.$form = this.$widget.find(".sort-child-notes-form");
this.$form.on("submit", async () => {
const sortBy = this.$form.find("input[name='sort-by']:checked").val();
const sortDirection = this.$form.find("input[name='sort-direction']:checked").val();
const foldersFirst = this.$form.find("input[name='sort-folders-first']").is(":checked");
const sortNatural = this.$form.find("input[name='sort-natural']").is(":checked");
const sortLocale = this.$form.find("input[name='sort-locale']").val();
await server.put(`notes/${this.parentNoteId}/sort-children`, { sortBy, sortDirection, foldersFirst, sortNatural, sortLocale });
utils.closeActiveDialog();
});
}
async sortChildNotesEvent({ node }: EventData<"sortChildNotes">) {
this.parentNoteId = node.data.noteId;
utils.openDialog(this.$widget);
this.$form.find("input:first").focus();
}
}

View File

@@ -0,0 +1,119 @@
import { t } from "../../services/i18n.js";
import utils, { escapeQuotes } from "../../services/utils.js";
import treeService from "../../services/tree.js";
import importService from "../../services/import.js";
import options from "../../services/options.js";
import BasicWidget from "../basic_widget.js";
import { Modal, Tooltip } from "bootstrap";
import type { EventData } from "../../components/app_context.js";
const TPL = /*html*/`
<div class="upload-attachments-dialog modal fade mx-auto" tabindex="-1" role="dialog">
<div class="modal-dialog modal-lg" role="document">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">${t("upload_attachments.upload_attachments_to_note")}</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="${t("upload_attachments.close")}"></button>
</div>
<form class="upload-attachment-form">
<div class="modal-body">
<div class="form-group">
<label for="upload-attachment-file-upload-input"><strong>${t("upload_attachments.choose_files")}</strong></label>
<label class="tn-file-input tn-input-field">
<input type="file" class="upload-attachment-file-upload-input form-control-file" multiple />
</label>
<p>${t("upload_attachments.files_will_be_uploaded")} <strong class="upload-attachment-note-title"></strong>.</p>
</div>
<div class="form-group">
<strong>${t("upload_attachments.options")}:</strong>
<div class="checkbox">
<label class="tn-checkbox" data-bs-toggle="tooltip" title="${escapeQuotes(t("upload_attachments.tooltip"))}">
<input class="shrink-images-checkbox form-check-input" value="1" type="checkbox" checked> <span>${t("upload_attachments.shrink_images")}</span>
</label>
</div>
</div>
</div>
<div class="modal-footer">
<button class="upload-attachment-button btn btn-primary">${t("upload_attachments.upload")}</button>
</div>
</form>
</div>
</div>
</div>`;
export default class UploadAttachmentsDialog extends BasicWidget {
private parentNoteId: string | null;
private modal!: bootstrap.Modal;
private $form!: JQuery<HTMLElement>;
private $noteTitle!: JQuery<HTMLElement>;
private $fileUploadInput!: JQuery<HTMLInputElement>;
private $uploadButton!: JQuery<HTMLElement>;
private $shrinkImagesCheckbox!: JQuery<HTMLElement>;
constructor() {
super();
this.parentNoteId = null;
}
doRender() {
this.$widget = $(TPL);
this.modal = Modal.getOrCreateInstance(this.$widget[0]);
this.$form = this.$widget.find(".upload-attachment-form");
this.$noteTitle = this.$widget.find(".upload-attachment-note-title");
this.$fileUploadInput = this.$widget.find(".upload-attachment-file-upload-input");
this.$uploadButton = this.$widget.find(".upload-attachment-button");
this.$shrinkImagesCheckbox = this.$widget.find(".shrink-images-checkbox");
this.$form.on("submit", () => {
// disabling so that import is not triggered again.
this.$uploadButton.attr("disabled", "disabled");
if (this.parentNoteId) {
this.uploadAttachments(this.parentNoteId);
}
return false;
});
this.$fileUploadInput.on("change", () => {
if (this.$fileUploadInput.val()) {
this.$uploadButton.removeAttr("disabled");
} else {
this.$uploadButton.attr("disabled", "disabled");
}
});
Tooltip.getOrCreateInstance(this.$widget.find('[data-bs-toggle="tooltip"]')[0], {
html: true
});
}
async showUploadAttachmentsDialogEvent({ noteId }: EventData<"showUploadAttachmentsDialog">) {
this.parentNoteId = noteId;
this.$fileUploadInput.val("").trigger("change"); // to trigger upload button disabling listener below
this.$shrinkImagesCheckbox.prop("checked", options.is("compressImages"));
this.$noteTitle.text(await treeService.getNoteTitle(this.parentNoteId));
utils.openDialog(this.$widget);
}
async uploadAttachments(parentNoteId: string) {
const files = Array.from(this.$fileUploadInput[0].files ?? []); // shallow copy since we're resetting the upload button below
function boolToString($el: JQuery<HTMLElement>): "true" | "false" {
return ($el.is(":checked") ? "true" : "false");
}
const options = {
shrinkImages: boolToString(this.$shrinkImagesCheckbox)
};
this.modal.hide();
await importService.uploadFiles("attachments", parentNoteId, files, options);
}
}

View File

@@ -0,0 +1,120 @@
import attributeService from "../services/attributes.js";
import NoteContextAwareWidget from "./note_context_aware_widget.js";
import { t } from "../services/i18n.js";
import type FNote from "../entities/fnote.js";
import type { EventData } from "../components/app_context.js";
import { Dropdown } from "bootstrap";
type Editability = "auto" | "readOnly" | "autoReadOnlyDisabled";
const TPL = /*html*/`
<div class="dropdown editability-select-widget">
<style>
.editability-dropdown {
width: 300px;
}
.editability-dropdown .dropdown-item {
display: flex !importamt;
}
.editability-dropdown .dropdown-item > div {
margin-left: 10px;
}
.editability-dropdown .description {
font-size: small;
color: var(--muted-text-color);
white-space: normal;
}
</style>
<button type="button" data-bs-toggle="dropdown" aria-haspopup="true" aria-expanded="false" class="btn btn-sm select-button dropdown-toggle editability-button">
<span class="editability-active-desc">${t("editability_select.auto")}</span>
<span class="caret"></span>
</button>
<div class="editability-dropdown dropdown-menu dropdown-menu-right tn-dropdown-list">
<a class="dropdown-item" href="#" data-editability="auto">
<span class="check">&check;</span>
<div>
${t("editability_select.auto")}
<div class="description">${t("editability_select.note_is_editable")}</div>
</div>
</a>
<a class="dropdown-item" href="#" data-editability="readOnly">
<span class="check">&check;</span>
<div>
${t("editability_select.read_only")}
<div class="description">${t("editability_select.note_is_read_only")}</div>
</div>
</a>
<a class="dropdown-item" href="#" data-editability="autoReadOnlyDisabled">
<span class="check">&check;</span>
<div>
${t("editability_select.always_editable")}
<div class="description">${t("editability_select.note_is_always_editable")}</div>
</div>
</a>
</div>
</div>
`;
export default class EditabilitySelectWidget extends NoteContextAwareWidget {
private dropdown!: Dropdown;
private $editabilityActiveDesc!: JQuery<HTMLElement>;
doRender() {
this.$widget = $(TPL);
this.dropdown = Dropdown.getOrCreateInstance(this.$widget.find("[data-bs-toggle='dropdown']")[0]);
this.$editabilityActiveDesc = this.$widget.find(".editability-active-desc");
this.$widget.on("click", ".dropdown-item", async (e) => {
this.dropdown.toggle();
const editability = $(e.target).closest("[data-editability]").attr("data-editability");
if (!this.note || !this.noteId) {
return;
}
for (const ownedAttr of this.note.getOwnedLabels()) {
if (["readOnly", "autoReadOnlyDisabled"].includes(ownedAttr.name)) {
await attributeService.removeAttributeById(this.noteId, ownedAttr.attributeId);
}
}
if (editability && editability !== "auto") {
await attributeService.addLabel(this.noteId, editability);
}
});
}
async refreshWithNote(note: FNote) {
let editability: Editability = "auto";
if (this.note?.isLabelTruthy("readOnly")) {
editability = "readOnly";
} else if (this.note?.isLabelTruthy("autoReadOnlyDisabled")) {
editability = "autoReadOnlyDisabled";
}
const labels = {
auto: t("editability_select.auto"),
readOnly: t("editability_select.read_only"),
autoReadOnlyDisabled: t("editability_select.always_editable")
};
this.$widget.find(".dropdown-item").removeClass("selected");
this.$widget.find(`.dropdown-item[data-editability='${editability}']`).addClass("selected");
this.$editabilityActiveDesc.text(labels[editability]);
}
entitiesReloadedEvent({ loadResults }: EventData<"entitiesReloaded">) {
if (loadResults.getAttributeRows().find((attr) => attr.noteId === this.noteId)) {
this.refresh();
}
}
}

View File

@@ -0,0 +1,361 @@
/**
* (c) Antonio Tejada 2022
* https://github.com/antoniotejada/Trilium-FindWidget
*/
import { t } from "../services/i18n.js";
import NoteContextAwareWidget from "./note_context_aware_widget.js";
import attributeService from "../services/attributes.js";
import FindInText from "./find_in_text.js";
import FindInCode from "./find_in_code.js";
import FindInHtml from "./find_in_html.js";
import type { EventData } from "../components/app_context.js";
const findWidgetDelayMillis = 200;
const waitForEnter = findWidgetDelayMillis < 0;
export interface FindResult {
totalFound: number;
currentFound: number;
}
// tabIndex=-1 on the checkbox labels is necessary, so when clicking on the label,
// the focusout handler is called with relatedTarget equal to the label instead
// of undefined. It's -1 instead of > 0, so they don't tabstop
const TPL = /*html*/`
<div class='find-replace-widget' style="contain: none; border-top: 1px solid var(--main-border-color);">
<style>
.find-widget-box, .replace-widget-box {
padding: 2px 10px 2px 10px;
align-items: center;
}
.find-widget-box > *, .replace-widget-box > *{
margin-right: 15px;
}
.find-widget-box, .replace-widget-box {
display: flex;
}
.find-widget-found-wrapper {
justify-content: center;
min-width: 60px;
padding: 0 4px;
font-size: .85em;
text-align: center;
}
.find-widget-search-term-input-group, .replace-widget-replacetext-input {
max-width: 350px;
}
.find-widget-spacer {
flex-grow: 1;
}
</style>
<div class="find-widget-box">
<div class="input-group find-widget-search-term-input-group">
<input type="text" class="form-control find-widget-search-term-input" placeholder="${t("find.find_placeholder")}">
<button class="btn btn-outline-secondary bx bxs-chevron-up find-widget-previous-button" type="button"></button>
<div class="find-widget-found-wrapper input-group-text">
<span>
<span class="find-widget-current-found">0</span>
/
<span class="find-widget-total-found">0</span>
<span>
</div>
<button class="btn btn-outline-secondary bx bxs-chevron-down find-widget-next-button" type="button"></button>
</div>
<div class="form-check">
<label tabIndex="-1" class="form-check-label tn-checkbox">
<input type="checkbox" class="form-check-input find-widget-case-sensitive-checkbox">
${t("find.case_sensitive")}
</label>
</div>
<div class="form-check">
<label tabIndex="-1" class="form-check-label tn-checkbox">
<input type="checkbox" class="form-check-input find-widget-match-words-checkbox">
${t("find.match_words")}
</label>
</div>
<div class="find-widget-spacer"></div>
<div class="find-widget-close-button"><button class="btn icon-action bx bx-x"></button></div>
</div>
<div class="replace-widget-box" style='display: none'>
<input type="text" class="form-control replace-widget-replacetext-input" placeholder="${t("find.replace_placeholder")}">
<button class="btn btn-sm replace-widget-replaceall-button" type="button">${t("find.replace_all")}</button>
<button class="btn btn-sm replace-widget-replace-button" type="button">${t("find.replace")}</button>
</div>
</div>`;
export default class FindWidget extends NoteContextAwareWidget {
private searchTerm: string | null;
private textHandler: FindInText;
private codeHandler: FindInCode;
private htmlHandler: FindInHtml;
private handler?: FindInText | FindInCode | FindInHtml;
private timeoutId?: number | null;
private $input!: JQuery<HTMLElement>;
private $currentFound!: JQuery<HTMLElement>;
private $totalFound!: JQuery<HTMLElement>;
private $caseSensitiveCheckbox!: JQuery<HTMLElement>;
private $matchWordsCheckbox!: JQuery<HTMLElement>;
private $previousButton!: JQuery<HTMLElement>;
private $nextButton!: JQuery<HTMLElement>;
private $closeButton!: JQuery<HTMLElement>;
private $replaceWidgetBox!: JQuery<HTMLElement>;
private $replaceTextInput!: JQuery<HTMLElement>;
private $replaceAllButton!: JQuery<HTMLElement>;
private $replaceButton!: JQuery<HTMLElement>;
constructor() {
super();
this.searchTerm = null;
this.textHandler = new FindInText(this);
this.codeHandler = new FindInCode(this);
this.htmlHandler = new FindInHtml(this);
}
async noteSwitched() {
await super.noteSwitched();
await this.closeSearch();
}
doRender() {
this.$widget = $(TPL);
this.$widget.hide();
this.$input = this.$widget.find(".find-widget-search-term-input");
this.$currentFound = this.$widget.find(".find-widget-current-found");
this.$totalFound = this.$widget.find(".find-widget-total-found");
this.$caseSensitiveCheckbox = this.$widget.find(".find-widget-case-sensitive-checkbox");
this.$caseSensitiveCheckbox.change(() => this.performFind());
this.$matchWordsCheckbox = this.$widget.find(".find-widget-match-words-checkbox");
this.$matchWordsCheckbox.change(() => this.performFind());
this.$previousButton = this.$widget.find(".find-widget-previous-button");
this.$previousButton.on("click", () => this.findNext(-1));
this.$nextButton = this.$widget.find(".find-widget-next-button");
this.$nextButton.on("click", () => this.findNext(1));
this.$closeButton = this.$widget.find(".find-widget-close-button");
this.$closeButton.on("click", () => this.closeSearch());
this.$replaceWidgetBox = this.$widget.find(".replace-widget-box");
this.$replaceTextInput = this.$widget.find(".replace-widget-replacetext-input");
this.$replaceAllButton = this.$widget.find(".replace-widget-replaceall-button");
this.$replaceAllButton.on("click", () => this.replaceAll());
this.$replaceButton = this.$widget.find(".replace-widget-replace-button");
this.$replaceButton.on("click", () => this.replace());
this.$input.keydown(async (e) => {
if ((e.metaKey || e.ctrlKey) && (e.key === "F" || e.key === "f")) {
// If ctrl+f is pressed when the findbox is shown, select the
// whole input to find
this.$input.select();
} else if (e.key === "Enter" || e.key === "F3") {
await this.findNext(e?.shiftKey ? -1 : 1);
e.preventDefault();
return false;
}
});
this.$widget.keydown(async (e) => {
if (e.key === "Escape") {
await this.closeSearch();
}
});
this.$input.on("input", () => this.startSearch());
return this.$widget;
}
async findInTextEvent() {
if (!this.isActiveNoteContext()) {
return;
}
if (!["text", "code", "render"].includes(this.note?.type ?? "")) {
return;
}
this.handler = await this.getHandler();
const isReadOnly = await this.noteContext?.isReadOnly();
let selectedText = "";
if (this.note?.type === "code" && !isReadOnly && this.noteContext) {
const codeEditor = await this.noteContext.getCodeEditor();
selectedText = codeEditor.getSelection();
} else {
selectedText = window.getSelection()?.toString() || "";
}
this.$widget.show();
this.$input.focus();
if (["text", "code"].includes(this.note?.type ?? "") && !isReadOnly) {
this.$replaceWidgetBox.show();
} else {
this.$replaceWidgetBox.hide();
}
const isAlreadyVisible = this.$widget.is(":visible");
if (isAlreadyVisible) {
if (selectedText) {
this.$input.val(selectedText);
}
if (this.$input.val()) {
await this.performFind();
}
this.$input.select();
} else {
this.$totalFound.text(0);
this.$currentFound.text(0);
this.$input.val(selectedText);
if (selectedText) {
this.$input.select();
await this.performFind();
}
}
}
async getHandler() {
if (this.note?.type === "render") {
return this.htmlHandler;
}
const readOnly = await this.noteContext?.isReadOnly();
if (readOnly) {
return this.htmlHandler;
} else {
return this.note?.type === "code" ? this.codeHandler : this.textHandler;
}
}
startSearch() {
// XXX This should clear the previous search immediately in all cases
// (the search is stale when waitforenter but also while the
// delay is running for the non waitforenter case)
if (!waitForEnter) {
// Clear the previous timeout if any, it's ok if timeoutId is
// null or undefined
clearTimeout(this.timeoutId as unknown as NodeJS.Timeout); // TODO: Fix once client is separated from Node.js types.
// Defer the search a few millis so the search doesn't start
// immediately, as this can cause search word typing lag with
// one or two-char searchwords and long notes
// See https://github.com/antoniotejada/Trilium-FindWidget/issues/1
this.timeoutId = setTimeout(async () => {
this.timeoutId = null;
await this.performFind();
}, findWidgetDelayMillis) as unknown as number; // TODO: Fix once client is separated from Node.js types.
}
}
/**
* @param direction +1 for next, -1 for previous
*/
async findNext(direction: 1 | -1) {
if (this.$totalFound.text() == "?") {
await this.performFind();
return;
}
const searchTerm = this.$input.val();
if (waitForEnter && this.searchTerm !== searchTerm) {
await this.performFind();
}
const totalFound = parseInt(this.$totalFound.text());
const currentFound = parseInt(this.$currentFound.text()) - 1;
if (totalFound > 0) {
let nextFound = currentFound + direction;
// Wrap around
if (nextFound > totalFound - 1) {
nextFound = 0;
} else if (nextFound < 0) {
nextFound = totalFound - 1;
}
this.$currentFound.text(nextFound + 1);
await this.handler?.findNext(direction, currentFound, nextFound);
}
}
/** Perform the find and highlight the find results. */
async performFind() {
const searchTerm = String(this.$input.val());
const matchCase = this.$caseSensitiveCheckbox.prop("checked");
const wholeWord = this.$matchWordsCheckbox.prop("checked");
if (!this.handler) {
return;
}
const { totalFound, currentFound } = await this.handler.performFind(searchTerm, matchCase, wholeWord);
this.$totalFound.text(totalFound);
this.$currentFound.text(currentFound);
this.searchTerm = searchTerm;
}
async closeSearch() {
if (this.$widget.is(":visible")) {
this.$widget.hide();
// Restore any state, if there's a current occurrence clear markers
// and scroll to and select the last occurrence
const totalFound = parseInt(this.$totalFound.text());
const currentFound = parseInt(this.$currentFound.text()) - 1;
this.searchTerm = null;
await this.handler?.findBoxClosed(totalFound, currentFound);
}
}
async replace() {
const replaceText = String(this.$replaceTextInput.val());
if (this.handler && "replace" in this.handler) {
await this.handler.replace(replaceText);
}
}
async replaceAll() {
const replaceText = String(this.$replaceTextInput.val());
if (this.handler && "replace" in this.handler) {
await this.handler.replaceAll(replaceText);
}
}
isEnabled() {
return super.isEnabled() && ["text", "code", "render"].includes(this.note?.type ?? "");
}
async entitiesReloadedEvent({ loadResults }: EventData<"entitiesReloaded">) {
if (this.noteId && loadResults.isNoteContentReloaded(this.noteId)) {
this.$totalFound.text("?");
} else if (loadResults.getAttributeRows().find((attr) => attr.type === "label"
&& (attr.name?.toLowerCase() ?? "").includes("readonly")
&& attributeService.isAffecting(attr, this.note))) {
this.closeSearch();
}
}
}

View File

@@ -0,0 +1,247 @@
// ck-find-result and ck-find-result_selected are the styles ck-editor
// uses for highlighting matches, use the same one on CodeMirror
// for consistency
import utils from "../services/utils.js";
import type FindWidget from "./find.js";
const FIND_RESULT_SELECTED_CSS_CLASSNAME = "ck-find-result_selected";
const FIND_RESULT_CSS_CLASSNAME = "ck-find-result";
// TODO: Deduplicate.
interface Match {
className: string;
clear(): void;
find(): {
from: number;
to: number;
};
}
export default class FindInCode {
private parent: FindWidget;
private findResult?: Match[] | null;
constructor(parent: FindWidget) {
this.parent = parent;
}
async getCodeEditor() {
return this.parent.noteContext?.getCodeEditor();
}
async performFind(searchTerm: string, matchCase: boolean, wholeWord: boolean) {
let findResult: Match[] | null = null;
let totalFound = 0;
let currentFound = -1;
// See https://codemirror.net/addon/search/searchcursor.js for tips
const codeEditor = await this.getCodeEditor();
if (!codeEditor) {
return { totalFound: 0, currentFound: 0 };
}
const doc = codeEditor.doc;
const text = doc.getValue();
// Clear all markers
if (this.findResult) {
codeEditor.operation(() => {
const findResult = this.findResult as Match[];
for (let i = 0; i < findResult.length; ++i) {
const marker = findResult[i];
marker.clear();
}
});
}
if (searchTerm !== "") {
searchTerm = utils.escapeRegExp(searchTerm);
// Find and highlight matches
// Find and highlight matches
// XXX Using \\b and not using the unicode flag probably doesn't
// work with non-ASCII alphabets, findAndReplace uses a more
// complicated regexp, see
// https://github.com/ckeditor/ckeditor5/blob/b95e2faf817262ac0e1e21993d9c0bde3f1be594/packages/ckeditor5-find-and-replace/src/utils.js#L145
const wholeWordChar = wholeWord ? "\\b" : "";
const re = new RegExp(wholeWordChar + searchTerm + wholeWordChar, "g" + (matchCase ? "" : "i"));
let curLine = 0;
let curChar = 0;
let curMatch: RegExpExecArray | null = null;
findResult = [];
// All those markText take several seconds on e.g., this ~500-line
// script, batch them inside an operation, so they become
// unnoticeable. Alternatively, an overlay could be used, see
// https://codemirror.net/addon/search/match-highlighter.js ?
codeEditor.operation(() => {
for (let i = 0; i < text.length; ++i) {
// Fetch the next match if it's the first time or if past the current match start
if (curMatch == null || curMatch.index < i) {
curMatch = re.exec(text);
if (curMatch == null) {
// No more matches
break;
}
}
// Create a non-selected highlight marker for the match, the
// selected marker highlight will be done later
if (i === curMatch.index) {
let fromPos = { line: curLine, ch: curChar };
// If multiline is supported, this needs to recalculate curLine since the match may span lines
let toPos = { line: curLine, ch: curChar + curMatch[0].length };
// or css = "color: #f3"
let marker = doc.markText(fromPos, toPos, { className: FIND_RESULT_CSS_CLASSNAME });
findResult?.push(marker);
// Set the first match beyond the cursor as the current match
if (currentFound === -1) {
const cursorPos = codeEditor.getCursor();
if (fromPos.line > cursorPos.line || (fromPos.line === cursorPos.line && fromPos.ch >= cursorPos.ch)) {
currentFound = totalFound;
}
}
totalFound++;
}
// Do line and char position tracking
if (text[i] === "\n") {
curLine++;
curChar = 0;
} else {
curChar++;
}
}
});
}
this.findResult = findResult;
// Calculate curfound if not already, highlight it as selected
if (findResult && totalFound > 0) {
currentFound = Math.max(0, currentFound);
let marker = findResult[currentFound];
let pos = marker.find();
codeEditor.scrollIntoView(pos.to);
marker.clear();
findResult[currentFound] = doc.markText(pos.from, pos.to, { className: FIND_RESULT_SELECTED_CSS_CLASSNAME });
}
return {
totalFound,
currentFound: Math.min(currentFound + 1, totalFound)
};
}
async findNext(direction: number, currentFound: number, nextFound: number) {
const codeEditor = await this.getCodeEditor();
if (!codeEditor || !this.findResult) {
return;
}
const doc = codeEditor.doc;
//
// Dehighlight current, highlight & scrollIntoView next
//
let marker = this.findResult[currentFound];
let pos = marker.find();
marker.clear();
marker = doc.markText(pos.from, pos.to, { className: FIND_RESULT_CSS_CLASSNAME });
this.findResult[currentFound] = marker;
marker = this.findResult[nextFound];
pos = marker.find();
marker.clear();
marker = doc.markText(pos.from, pos.to, { className: FIND_RESULT_SELECTED_CSS_CLASSNAME });
this.findResult[nextFound] = marker;
codeEditor.scrollIntoView(pos.from);
}
async findBoxClosed(totalFound: number, currentFound: number) {
const codeEditor = await this.getCodeEditor();
if (codeEditor && totalFound > 0) {
const doc = codeEditor.doc;
const pos = this.findResult?.[currentFound].find();
// Note setting the selection sets the cursor to
// the end of the selection and scrolls it into
// view
if (pos) {
doc.setSelection(pos.from, pos.to);
}
// Clear all markers
codeEditor.operation(() => {
if (!this.findResult) {
return;
}
for (let i = 0; i < this.findResult.length; ++i) {
let marker = this.findResult[i];
marker.clear();
}
});
}
this.findResult = null;
codeEditor?.focus();
}
async replace(replaceText: string) {
// this.findResult may be undefined and null
if (!this.findResult || this.findResult.length === 0) {
return;
}
let currentFound = -1;
this.findResult.forEach((marker, index) => {
const pos = marker.find();
if (pos) {
if (marker.className === FIND_RESULT_SELECTED_CSS_CLASSNAME) {
currentFound = index;
return;
}
}
});
if (currentFound >= 0) {
let marker = this.findResult[currentFound];
let pos = marker.find();
const codeEditor = await this.getCodeEditor();
const doc = codeEditor?.doc;
if (doc) {
doc.replaceRange(replaceText, pos.from, pos.to);
}
marker.clear();
let nextFound;
if (currentFound === this.findResult.length - 1) {
nextFound = 0;
} else {
nextFound = currentFound;
}
this.findResult.splice(currentFound, 1);
if (this.findResult.length > 0) {
this.findNext(0, nextFound, nextFound);
}
}
}
async replaceAll(replaceText: string) {
if (!this.findResult || this.findResult.length === 0) {
return;
}
const codeEditor = await this.getCodeEditor();
const doc = codeEditor?.doc;
codeEditor?.operation(() => {
if (!this.findResult) {
return;
}
for (let currentFound = 0; currentFound < this.findResult.length; currentFound++) {
let marker = this.findResult[currentFound];
let pos = marker.find();
doc?.replaceRange(replaceText, pos.from, pos.to);
marker.clear();
}
});
this.findResult = [];
}
}

View File

@@ -0,0 +1,97 @@
// ck-find-result and ck-find-result_selected are the styles ck-editor
// uses for highlighting matches, use the same one on CodeMirror
// for consistency
import utils from "../services/utils.js";
import appContext from "../components/app_context.js";
import type FindWidget from "./find.js";
import type { FindResult } from "./find.js";
const FIND_RESULT_SELECTED_CSS_CLASSNAME = "ck-find-result_selected";
const FIND_RESULT_CSS_CLASSNAME = "ck-find-result";
export default class FindInHtml {
private parent: FindWidget;
private currentIndex: number;
private $results: JQuery<HTMLElement> | null;
constructor(parent: FindWidget) {
this.parent = parent;
this.currentIndex = 0;
this.$results = null;
}
async performFind(searchTerm: string, matchCase: boolean, wholeWord: boolean) {
await import("script-loader!mark.js/dist/jquery.mark.min.js");
const $content = await this.parent?.noteContext?.getContentElement();
const wholeWordChar = wholeWord ? "\\b" : "";
const regExp = new RegExp(wholeWordChar + utils.escapeRegExp(searchTerm) + wholeWordChar, matchCase ? "g" : "gi");
return new Promise<FindResult>((res) => {
$content?.unmark({
done: () => {
$content.markRegExp(regExp, {
element: "span",
className: FIND_RESULT_CSS_CLASSNAME,
separateWordSearch: false,
caseSensitive: matchCase,
done: async () => {
this.$results = $content.find(`.${FIND_RESULT_CSS_CLASSNAME}`);
this.currentIndex = 0;
await this.jumpTo();
res({
totalFound: this.$results.length,
currentFound: Math.min(1, this.$results.length)
});
}
});
}
});
});
}
async findNext(direction: -1 | 1, currentFound: number, nextFound: number) {
if (this.$results?.length) {
this.currentIndex += direction;
if (this.currentIndex < 0) {
this.currentIndex = this.$results.length - 1;
}
if (this.currentIndex > this.$results.length - 1) {
this.currentIndex = 0;
}
await this.jumpTo();
}
}
async findBoxClosed(totalFound: number, currentFound: number) {
const $content = await this.parent?.noteContext?.getContentElement();
if ($content) {
$content.unmark();
}
}
async jumpTo() {
if (this.$results?.length) {
const offsetTop = 100;
const $current = this.$results.eq(this.currentIndex);
this.$results.removeClass(FIND_RESULT_SELECTED_CSS_CLASSNAME);
if ($current.length) {
$current.addClass(FIND_RESULT_SELECTED_CSS_CLASSNAME);
const position = $current.position().top - offsetTop;
const $content = await this.parent.noteContext?.getContentElement();
if ($content) {
const $contentWidget = appContext.getComponentByEl($content[0]);
$contentWidget.triggerCommand("scrollContainerTo", { position });
}
}
}
}
}

View File

@@ -0,0 +1,146 @@
import type { FindResult } from "./find.js";
import type FindWidget from "./find.js";
// TODO: Deduplicate.
interface Match {
className: string;
clear(): void;
find(): {
from: number;
to: number;
};
}
export default class FindInText {
private parent: FindWidget;
private findResult?: CKFindResult | null;
private editingState?: EditingState;
constructor(parent: FindWidget) {
this.parent = parent;
}
async getTextEditor() {
return this.parent?.noteContext?.getTextEditor();
}
async performFind(searchTerm: string, matchCase: boolean, wholeWord: boolean): Promise<FindResult> {
// Do this even if the searchTerm is empty so the markers are cleared and
// the counters updated
const textEditor = await this.getTextEditor();
if (!textEditor) {
return { currentFound: 0, totalFound: 0 };
}
const model = textEditor.model;
let findResult = null;
let totalFound = 0;
let currentFound = -1;
// Clear
const findAndReplaceEditing = textEditor.plugins.get("FindAndReplaceEditing");
findAndReplaceEditing.state.clear(model);
findAndReplaceEditing.stop();
this.editingState = findAndReplaceEditing.state;
if (searchTerm !== "") {
// Parameters are callback/text, options.matchCase=false, options.wholeWords=false
// See https://github.com/ckeditor/ckeditor5/blob/b95e2faf817262ac0e1e21993d9c0bde3f1be594/packages/ckeditor5-find-and-replace/src/findcommand.js#L44
// XXX Need to use the callback version for regexp
// searchTerm = escapeRegExp(searchTerm);
// let re = new RegExp(searchTerm, 'gi');
// let m = text.match(re);
// totalFound = m ? m.length : 0;
const options = { matchCase: matchCase, wholeWords: wholeWord };
findResult = textEditor.execute<CKFindResult>("find", searchTerm, options);
totalFound = findResult.results.length;
// Find the result beyond the cursor
const cursorPos = model.document.selection.getLastPosition();
for (let i = 0; i < findResult.results.length; ++i) {
const marker = findResult.results.get(i).marker;
const fromPos = marker.getStart();
if (cursorPos && fromPos.compareWith(cursorPos) !== "before") {
currentFound = i;
break;
}
}
}
this.findResult = findResult;
// Calculate curfound if not already, highlight it as
// selected
if (totalFound > 0) {
currentFound = Math.max(0, currentFound);
// XXX Do this accessing the private data?
// See https://github.com/ckeditor/ckeditor5/blob/b95e2faf817262ac0e1e21993d9c0bde3f1be594/packages/ckeditor5-find-and-replace/src/findnextcommand.js
for (let i = 0; i < currentFound; ++i) {
textEditor?.execute("findNext", searchTerm);
}
}
return {
totalFound,
currentFound: Math.min(currentFound + 1, totalFound)
};
}
async findNext(direction: number, currentFound: number, nextFound: number) {
const textEditor = await this.getTextEditor();
// There are no parameters for findNext/findPrev
// See https://github.com/ckeditor/ckeditor5/blob/b95e2faf817262ac0e1e21993d9c0bde3f1be594/packages/ckeditor5-find-and-replace/src/findnextcommand.js#L57
// curFound wrap around above assumes findNext and
// findPrevious wraparound, which is what they do
if (direction > 0) {
textEditor?.execute("findNext");
} else {
textEditor?.execute("findPrevious");
}
}
async findBoxClosed(totalFound: number, currentFound: number) {
const textEditor = await this.getTextEditor();
if (!textEditor) {
return;
}
if (totalFound > 0) {
// Clear the markers and set the caret to the
// current occurrence
const model = textEditor.model;
const range = this.findResult?.results?.get(currentFound).marker.getRange();
// From
// https://github.com/ckeditor/ckeditor5/blob/b95e2faf817262ac0e1e21993d9c0bde3f1be594/packages/ckeditor5-find-and-replace/src/findandreplace.js#L92
// XXX Roll our own since already done for codeEditor and
// will probably allow more refactoring?
let findAndReplaceEditing = textEditor.plugins.get("FindAndReplaceEditing");
findAndReplaceEditing.state.clear(model);
findAndReplaceEditing.stop();
if (range) {
model.change((writer) => {
writer.setSelection(range, 0);
});
}
textEditor.editing.view.scrollToTheSelection();
}
this.findResult = null;
textEditor.focus();
}
async replace(replaceText: string) {
if (this.editingState !== undefined && this.editingState.highlightedResult !== null) {
const textEditor = await this.getTextEditor();
textEditor?.execute("replace", replaceText, this.editingState.highlightedResult);
}
}
async replaceAll(replaceText: string) {
if (this.editingState !== undefined && this.editingState.results.length > 0) {
const textEditor = await this.getTextEditor();
textEditor?.execute("replaceAll", replaceText, this.editingState.results);
}
}
}

View File

@@ -0,0 +1,93 @@
import { t } from "../../services/i18n.js";
import server from "../../services/server.js";
import ws from "../../services/ws.js";
import appContext, { type EventData } from "../../components/app_context.js";
import toastService from "../../services/toast.js";
import treeService from "../../services/tree.js";
import NoteContextAwareWidget from "../note_context_aware_widget.js";
import keyboardActionService from "../../services/keyboard_actions.js";
import type FNote from "../../entities/fnote.js";
const TPL = /*html*/`
<div class="code-buttons-widget">
<style>
.code-buttons-widget {
display: flex;
gap: 10px;
}
</style>
<button data-trigger-command="runActiveNote" class="execute-button floating-button btn" title="${t("code_buttons.execute_button_title")}">
<span class="bx bx-play"></span>
</button>
<button class="trilium-api-docs-button floating-button btn" title="${t("code_buttons.trilium_api_docs_button_title")}">
<span class="bx bx-help-circle"></span>
</button>
<button class="save-to-note-button floating-button btn" title="${t("code_buttons.save_to_note_button_title")}">
<span class="bx bx-save"></span>
</button>
</div>`;
// TODO: Deduplicate with server.
interface SaveSqlConsoleResponse {
notePath: string;
}
export default class CodeButtonsWidget extends NoteContextAwareWidget {
private $openTriliumApiDocsButton!: JQuery<HTMLElement>;
private $executeButton!: JQuery<HTMLElement>;
private $saveToNoteButton!: JQuery<HTMLElement>;
isEnabled() {
return super.isEnabled() && this.note && (this.note.mime.startsWith("application/javascript") || this.note.mime === "text/x-sqlite;schema=trilium");
}
doRender() {
this.$widget = $(TPL);
this.$openTriliumApiDocsButton = this.$widget.find(".trilium-api-docs-button");
this.$openTriliumApiDocsButton.on("click", () => {
toastService.showMessage(t("code_buttons.opening_api_docs_message"));
if (this.note?.mime.endsWith("frontend")) {
window.open("https://zadam.github.io/trilium/frontend_api/FrontendScriptApi.html", "_blank");
} else {
window.open("https://zadam.github.io/trilium/backend_api/BackendScriptApi.html", "_blank");
}
});
this.$executeButton = this.$widget.find(".execute-button");
this.$saveToNoteButton = this.$widget.find(".save-to-note-button");
this.$saveToNoteButton.on("click", async () => {
const { notePath } = await server.post<SaveSqlConsoleResponse>("special-notes/save-sql-console", { sqlConsoleNoteId: this.noteId });
await ws.waitForMaxKnownEntityChangeId();
await appContext.tabManager.getActiveContext()?.setNote(notePath);
toastService.showMessage(t("code_buttons.sql_console_saved_message", { notePath: await treeService.getNotePathTitle(notePath) }));
});
keyboardActionService.updateDisplayedShortcuts(this.$widget);
this.contentSized();
super.doRender();
}
async refreshWithNote(note: FNote) {
this.$executeButton.toggle(note.mime.startsWith("application/javascript") || note.mime === "text/x-sqlite;schema=trilium");
this.$saveToNoteButton.toggle(note.mime === "text/x-sqlite;schema=trilium" && note.isHiddenCompletely());
this.$openTriliumApiDocsButton.toggle(note.mime.startsWith("application/javascript;env="));
}
async noteTypeMimeChangedEvent({ noteId }: EventData<"noteTypeMimeChanged">) {
if (this.isNote(noteId)) {
await this.refresh();
}
}
}

View File

@@ -0,0 +1,42 @@
import { t } from "../../services/i18n.js";
import NoteContextAwareWidget from "../note_context_aware_widget.js";
import utils from "../../services/utils.js";
import imageService from "../../services/image.js";
const TPL = /*html*/`
<button type="button"
class="copy-image-reference-button"
title="${t("copy_image_reference_button.button_title")}">
<span class="bx bx-copy"></span>
<div class="hidden-image-copy"></div>
</button>`;
export default class CopyImageReferenceButton extends NoteContextAwareWidget {
private $hiddenImageCopy!: JQuery<HTMLElement>;
isEnabled() {
return super.isEnabled() && ["mermaid", "canvas", "mindMap"].includes(this.note?.type ?? "") && this.note?.isContentAvailable() && this.noteContext?.viewScope?.viewMode === "default";
}
doRender() {
super.doRender();
this.$widget = $(TPL);
this.$hiddenImageCopy = this.$widget.find(".hidden-image-copy");
this.$widget.on("click", () => {
if (!this.note) {
return;
}
this.$hiddenImageCopy.empty().append($("<img>").attr("src", utils.createImageSrcUrl(this.note)));
imageService.copyImageReferenceToClipboard(this.$hiddenImageCopy);
this.$hiddenImageCopy.empty();
});
this.contentSized();
}
}

View File

@@ -0,0 +1,79 @@
import OnClickButtonWidget from "../buttons/onclick_button.js";
import appContext from "../../components/app_context.js";
import attributeService from "../../services/attributes.js";
import protectedSessionHolder from "../../services/protected_session_holder.js";
import { t } from "../../services/i18n.js";
import LoadResults from "../../services/load_results.js";
import type { AttributeRow } from "../../services/load_results.js";
import FNote from "../../entities/fnote.js";
export default class EditButton extends OnClickButtonWidget {
isEnabled(): boolean {
return Boolean(super.isEnabled() && this.note && this.noteContext?.viewScope?.viewMode === "default");
}
constructor() {
super();
this.icon("bx-pencil")
.title(t("edit_button.edit_this_note"))
.titlePlacement("bottom")
.onClick((widget) => {
if (this.noteContext?.viewScope) {
this.noteContext.viewScope.readOnlyTemporarilyDisabled = true;
appContext.triggerEvent("readOnlyTemporarilyDisabled", { noteContext: this.noteContext });
}
});
}
async refreshWithNote(note: FNote): Promise<void> {
if (note.isProtected && !protectedSessionHolder.isProtectedSessionAvailable()) {
this.toggleInt(false);
} else {
// prevent flickering by assuming hidden before async operation
this.toggleInt(false);
const wasVisible = this.isVisible();
// can't do this in isEnabled() since isReadOnly is async
const isReadOnly = await this.noteContext?.isReadOnly();
this.toggleInt(Boolean(isReadOnly));
// make the edit button stand out on the first display, otherwise
// it's difficult to notice that the note is readonly
if (this.isVisible() && !wasVisible && this.$widget) {
this.$widget.addClass("bx-tada bx-lg");
setTimeout(() => {
this.$widget?.removeClass("bx-tada bx-lg");
}, 1700);
}
}
await super.refreshWithNote(note);
}
entitiesReloadedEvent({ loadResults }: { loadResults: LoadResults }): void {
if (loadResults.getAttributeRows().find((attr: AttributeRow) =>
attr.type === "label" &&
attr.name?.toLowerCase().includes("readonly") &&
this.note &&
attributeService.isAffecting(attr, this.note)
)) {
if (this.noteContext?.viewScope) {
this.noteContext.viewScope.readOnlyTemporarilyDisabled = false;
}
this.refresh();
}
}
readOnlyTemporarilyDisabledEvent() {
this.refresh();
}
async noteTypeMimeChangedEvent({ noteId }: { noteId: string }): Promise<void> {
if (this.isNote(noteId)) {
await this.refresh();
}
}
}

View File

@@ -0,0 +1,147 @@
import NoteContextAwareWidget from "../note_context_aware_widget.js";
import { t } from "../../services/i18n.js";
import type FNote from "../../entities/fnote.js";
import type BasicWidget from "../basic_widget.js";
/*
* Note:
*
* For floating button widgets that require content to overflow, the has-overflow CSS class should
* be applied to the root element of the widget. Additionally, this root element may need to
* properly handle rounded corners, as defined by the --border-radius CSS variable.
*/
const TPL = /*html*/`
<div class="floating-buttons no-print">
<style>
.floating-buttons {
position: relative;
}
.floating-buttons-children,
.show-floating-buttons {
position: absolute;
top: 10px;
right: 10px;
display: flex;
flex-direction: row;
z-index: 100;
}
.note-split.rtl .floating-buttons-children,
.note-split.rtl .show-floating-buttons {
right: unset;
left: 10px;
}
.note-split.rtl .close-floating-buttons {
order: -1;
}
.note-split.rtl .close-floating-buttons,
.note-split.rtl .show-floating-buttons {
transform: rotate(180deg);
}
.type-canvas .floating-buttons-children {
top: 70px;
}
.type-canvas .floating-buttons-children > * {
--border-radius: 0; /* Overridden by themes */
}
.floating-buttons-children > *:not(.hidden-int):not(.no-content-hidden) {
margin: 2px;
}
.floating-buttons-children > *:not(.has-overflow) {
overflow: hidden;
}
.floating-buttons-children > button, .floating-buttons-children .floating-button {
font-size: 150%;
padding: 5px 10px 4px 10px;
width: 40px;
cursor: pointer;
color: var(--button-text-color);
background: var(--button-background-color);
border-radius: var(--button-border-radius);
border: 1px solid transparent;
display: flex;
justify-content: space-around;
}
.floating-buttons-children > button:hover, .floating-buttons-children .floating-button:hover {
text-decoration: none;
border-color: var(--button-border-color);
}
.floating-buttons .floating-buttons-children.temporarily-hidden {
display: none;
}
</style>
<div class="floating-buttons-children"></div>
<!-- Show button that displays floating button after click on close button -->
<div class="show-floating-buttons">
<style>
.floating-buttons-children.temporarily-hidden+.show-floating-buttons {
display: block;
}
.floating-buttons-children:not(.temporarily-hidden)+.show-floating-buttons {
display: none;
}
.show-floating-buttons {
/* display: none;*/
margin-left: 5px !important;
}
.show-floating-buttons-button {
border: 1px solid transparent;
color: var(--button-text-color);
padding: 6px;
border-radius: 100px;
}
.show-floating-buttons-button:hover {
border: 1px solid var(--button-border-color);
}
</style>
<button type="button" class="show-floating-buttons-button btn bx bx-chevrons-left"
title="${t("show_floating_buttons_button.button_title")}"></button>
</div>
</div>`;
export default class FloatingButtons extends NoteContextAwareWidget {
private $children!: JQuery<HTMLElement>;
doRender() {
this.$widget = $(TPL);
this.$children = this.$widget.find(".floating-buttons-children");
for (const widget of this.children) {
if ("render" in widget) {
this.$children.append((widget as BasicWidget).render());
}
}
}
async refreshWithNote(note: FNote) {
this.toggle(true);
this.$widget.find(".show-floating-buttons-button").on("click", () => this.toggle(true));
}
toggle(show: boolean) {
this.$widget.find(".floating-buttons-children").toggleClass("temporarily-hidden", !show);
}
hideFloatingButtonsCommand() {
this.toggle(false);
}
}

View File

@@ -0,0 +1,36 @@
import { t } from "../../services/i18n.js";
import NoteContextAwareWidget from "../note_context_aware_widget.js";
const TPL = /*html*/`\
<div class="geo-map-buttons">
<style>
.geo-map-buttons {
contain: none;
display: flex;
gap: 10px;
}
.leaflet-pane {
z-index: 50;
}
</style>
<button type="button"
class="geo-map-create-child-note floating-button btn bx bx-plus-circle"
title="${t("geo-map.create-child-note-title")}" />
</div>`;
export default class GeoMapButtons extends NoteContextAwareWidget {
isEnabled() {
return super.isEnabled() && this.note?.type === "geoMap";
}
doRender() {
super.doRender();
this.$widget = $(TPL);
this.$widget.find(".geo-map-create-child-note").on("click", () => this.triggerEvent("geoMapCreateChildNote", { ntxId: this.ntxId }));
}
}

View File

@@ -0,0 +1,36 @@
import { describe, expect, it } from "vitest";
import { byBookType, byNoteType } from "./help_button.js";
import fs from "fs";
import type { HiddenSubtreeItem } from "../../../../services/hidden_subtree.js";
describe("Help button", () => {
it("All help notes are accessible", () => {
function getNoteIds(item: HiddenSubtreeItem | HiddenSubtreeItem[]): string[] {
const items: (string | string[])[] = [];
if ("id" in item && item.id) {
items.push(item.id);
}
const subitems = (Array.isArray(item) ? item : item.children);
for (const child of subitems ?? []) {
items.push(getNoteIds(child as (HiddenSubtreeItem | HiddenSubtreeItem[])));
}
return items.flat();
}
const allHelpNotes = [
...Object.values(byNoteType),
...Object.values(byBookType)
].filter((noteId) => noteId) as string[];
const meta: HiddenSubtreeItem[] = JSON.parse(fs.readFileSync("src/public/app/doc_notes/en/User Guide/!!!meta.json", "utf-8"));
const allNoteIds = new Set(getNoteIds(meta));
for (const helpNote of allHelpNotes) {
if (!allNoteIds.has(`_help_${helpNote}`)) {
expect.fail(`Help note with ID ${helpNote} does not exist in the in-app help.`);
}
}
});
});

View File

@@ -0,0 +1,75 @@
import appContext, { type EventData } from "../../components/app_context.js";
import type FNote from "../../entities/fnote.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 = /*html*/`
<button class="open-contextual-help-button" title="${t("help-button.title")}">
<span class="bx bx-help-circle"></span>
</button>
`;
export const byNoteType: Record<Exclude<NoteType, "book">, string | null> = {
canvas: null,
code: null,
contentWidget: null,
doc: null,
file: null,
geoMap: "81SGnPGMk7Xc",
image: null,
launcher: null,
mermaid: null,
mindMap: null,
noteMap: null,
relationMap: null,
render: null,
search: null,
text: null,
webView: null,
aiChat: null
};
export const byBookType: Record<ViewTypeOptions, string | null> = {
list: null,
grid: null,
calendar: "xWbu3jpNWapp"
};
export default class ContextualHelpButton extends NoteContextAwareWidget {
isEnabled() {
if (!super.isEnabled()) {
return false;
}
return !!ContextualHelpButton.#getUrlToOpen(this.note);
}
doRender() {
this.$widget = $(TPL);
}
static #getUrlToOpen(note: FNote | null | undefined) {
if (note && note.type !== "book" && byNoteType[note.type]) {
return byNoteType[note.type];
} else if (note?.hasLabel("calendarRoot")) {
return "l0tKav7yLHGF";
} else if (note && note.type === "book") {
return byBookType[note.getAttributeValue("label", "viewType") as ViewTypeOptions ?? ""]
}
}
async refreshWithNote(note: FNote | null | undefined): Promise<void> {
this.$widget.attr("data-in-app-help", ContextualHelpButton.#getUrlToOpen(this.note) ?? "");
}
entitiesReloadedEvent({ loadResults }: EventData<"entitiesReloaded">) {
if (this.note?.type === "book" && loadResults.getAttributeRows().find((attr) => attr.noteId === this.noteId && attr.name === "viewType")) {
this.refresh();
}
}
}

View File

@@ -0,0 +1,43 @@
import { t } from "../../services/i18n.js";
import NoteContextAwareWidget from "../note_context_aware_widget.js";
const TPL = /*html*/`
<div class="close-floating-buttons">
<style>
.close-floating-buttons {
display: none;
margin-left: 5px !important;
}
/* conditionally display close button if there's some other button visible */
.floating-buttons *:not(.hidden-int):not(.hidden-no-content) ~ .close-floating-buttons {
display: block;
}
.close-floating-buttons-button {
border: 1px solid transparent;
color: var(--button-text-color);
padding: 6px;
border-radius: 100px;
}
.close-floating-buttons-button:hover {
border: 1px solid var(--button-border-color);
}
</style>
<button type="button"
class="close-floating-buttons-button btn bx bx-chevrons-right"
title="${t("hide_floating_buttons_button.button_title")}"></button>
</div>
`;
export default class HideFloatingButtonsButton extends NoteContextAwareWidget {
doRender() {
super.doRender();
this.$widget = $(TPL);
this.$widget.on("click", () => this.triggerCommand("hideFloatingButtons"));
this.contentSized();
}
}

View File

@@ -0,0 +1,24 @@
import { t } from "../../services/i18n.js";
import NoteContextAwareWidget from "../note_context_aware_widget.js";
const TPL = /*html*/`
<button type="button"
class="export-svg-button"
title="${t("png_export_button.button_title")}">
<span class="bx bxs-file-png"></span>
</button>
`;
export default class PngExportButton extends NoteContextAwareWidget {
isEnabled() {
return super.isEnabled() && ["mermaid", "mindMap"].includes(this.note?.type ?? "") && this.note?.isContentAvailable() && this.noteContext?.viewScope?.viewMode === "default";
}
doRender() {
super.doRender();
this.$widget = $(TPL);
this.$widget.on("click", () => this.triggerEvent("exportPng", { ntxId: this.ntxId }));
this.contentSized();
}
}

View File

@@ -0,0 +1,21 @@
import appContext from "../../components/app_context.js";
import { t } from "../../services/i18n.js";
import OnClickButtonWidget from "../buttons/onclick_button.js";
export default class RefreshButton extends OnClickButtonWidget {
constructor() {
super();
this
.title(t("backend_log.refresh"))
.icon("bx-refresh")
.onClick(() => this.triggerEvent("refreshData", { ntxId: this.noteContext?.ntxId }))
}
isEnabled(): boolean | null | undefined {
return super.isEnabled()
&& this.note?.noteId === "_backendLog"
&& this.noteContext?.viewScope?.viewMode === "default";
}
}

View File

@@ -0,0 +1,60 @@
import { t } from "../../services/i18n.js";
import NoteContextAwareWidget from "../note_context_aware_widget.js";
const TPL = /*html*/`
<div class="relation-map-buttons">
<style>
.relation-map-buttons {
display: flex;
gap: 10px;
}
</style>
<button type="button"
class="relation-map-create-child-note floating-button btn bx bx-folder-plus"
title="${t("relation_map_buttons.create_child_note_title")}"></button>
<button type="button"
class="relation-map-reset-pan-zoom floating-button btn bx bx-crop"
title="${t("relation_map_buttons.reset_pan_zoom_title")}"></button>
<div class="btn-group">
<button type="button"
class="relation-map-zoom-in floating-button btn bx bx-zoom-in"
title="${t("relation_map_buttons.zoom_in_title")}"></button>
<button type="button"
class="relation-map-zoom-out floating-button btn bx bx-zoom-out"
title="${t("relation_map_buttons.zoom_out_title")}"></button>
</div>
</div>`;
export default class RelationMapButtons extends NoteContextAwareWidget {
private $createChildNote!: JQuery<HTMLElement>;
private $zoomInButton!: JQuery<HTMLElement>;
private $zoomOutButton!: JQuery<HTMLElement>;
private $resetPanZoomButton!: JQuery<HTMLElement>;
isEnabled() {
return super.isEnabled() && this.note?.type === "relationMap";
}
doRender() {
super.doRender();
this.$widget = $(TPL);
this.$createChildNote = this.$widget.find(".relation-map-create-child-note");
this.$zoomInButton = this.$widget.find(".relation-map-zoom-in");
this.$zoomOutButton = this.$widget.find(".relation-map-zoom-out");
this.$resetPanZoomButton = this.$widget.find(".relation-map-reset-pan-zoom");
// TODO: Deduplicate object creation here.
this.$createChildNote.on("click", () => this.triggerEvent("relationMapCreateChildNote", { ntxId: this.ntxId }));
this.$resetPanZoomButton.on("click", () => this.triggerEvent("relationMapResetPanZoom", { ntxId: this.ntxId }));
this.$zoomInButton.on("click", () => this.triggerEvent("relationMapResetZoomIn", { ntxId: this.ntxId }));
this.$zoomOutButton.on("click", () => this.triggerEvent("relationMapResetZoomOut", { ntxId: this.ntxId }));
this.contentSized();
}
}

View File

@@ -0,0 +1,24 @@
import { t } from "../../services/i18n.js";
import NoteContextAwareWidget from "../note_context_aware_widget.js";
const TPL = /*html*/`
<button type="button"
class="export-svg-button"
title="${t("svg_export_button.button_title")}">
<span class="bx bxs-file-image"></span>
</button>
`;
export default class SvgExportButton extends NoteContextAwareWidget {
isEnabled() {
return super.isEnabled() && ["mermaid", "mindMap"].includes(this.note?.type ?? "") && this.note?.isContentAvailable() && this.noteContext?.viewScope?.viewMode === "default";
}
doRender() {
super.doRender();
this.$widget = $(TPL);
this.$widget.on("click", () => this.triggerEvent("exportSvg", { ntxId: this.ntxId }));
this.contentSized();
}
}

View File

@@ -0,0 +1,62 @@
import type { EventData } from "../../components/app_context.js";
import { t } from "../../services/i18n.js";
import options from "../../services/options.js";
import NoteContextAwareWidget from "../note_context_aware_widget.js";
const TPL = /*html*/`
<button type="button"
class="switch-layout-button">
<span class="bx"></span>
</button>
`;
export default class SwitchSplitOrientationButton extends NoteContextAwareWidget {
isEnabled() {
return super.isEnabled()
&& ["mermaid"].includes(this.note?.type ?? "")
&& this.note?.isContentAvailable()
&& !this.note?.hasLabel("readOnly")
&& this.noteContext?.viewScope?.viewMode === "default";
}
doRender(): void {
super.doRender();
this.$widget = $(TPL);
this.$widget.on("click", () => {
const currentOrientation = options.get("splitEditorOrientation");
options.save("splitEditorOrientation", toggleOrientation(currentOrientation));
});
this.#adjustIcon();
this.contentSized();
}
#adjustIcon() {
const currentOrientation = options.get("splitEditorOrientation");
const upcomingOrientation = toggleOrientation(currentOrientation);
const $icon = this.$widget.find("span.bx");
$icon
.toggleClass("bxs-dock-bottom", upcomingOrientation === "vertical")
.toggleClass("bxs-dock-left", upcomingOrientation === "horizontal");
if (upcomingOrientation === "vertical") {
this.$widget.attr("title", t("switch_layout_button.title_vertical"));
} else {
this.$widget.attr("title", t("switch_layout_button.title_horizontal"));
}
}
entitiesReloadedEvent({ loadResults }: EventData<"entitiesReloaded">) {
if (loadResults.isOptionReloaded("splitEditorOrientation")) {
this.#adjustIcon();
}
}
}
function toggleOrientation(orientation: string) {
if (orientation === "horizontal") {
return "vertical";
} else {
return "horizontal";
}
}

Some files were not shown because too many files have changed in this diff Show More