Merge branch 'main' of https://github.com/TriliumNext/Trilium into feat/context-menu/menu-items-badge-support

This commit is contained in:
Adorian Doran
2025-07-05 22:06:39 +03:00
47 changed files with 2358 additions and 541 deletions

View File

@@ -93,11 +93,7 @@ export class TypedComponent<ChildT extends TypedComponent<ChildT>> {
if (fun) {
return this.callMethod(fun, data);
} else {
if (!this.parent) {
throw new Error(`Component "${this.componentId}" does not have a parent attached to propagate a command.`);
}
} else if (this.parent) {
return this.parent.triggerCommand(name, data);
}
}

View File

@@ -315,14 +315,38 @@ class NoteContext extends Component implements EventListener<"entitiesReloaded">
}
hasNoteList() {
return (
this.note &&
["default", "contextual-help"].includes(this.viewScope?.viewMode ?? "") &&
(this.note.hasChildren() || this.note.getLabelValue("viewType") === "calendar") &&
["book", "text", "code"].includes(this.note.type) &&
this.note.mime !== "text/x-sqlite;schema=trilium" &&
!this.note.isLabelTruthy("hideChildrenOverview")
);
const note = this.note;
if (!note) {
return false;
}
if (!["default", "contextual-help"].includes(this.viewScope?.viewMode ?? "")) {
return false;
}
// Some book types must always display a note list, even if no children.
if (["calendar", "table"].includes(note.getLabelValue("viewType") ?? "")) {
return true;
}
if (!note.hasChildren()) {
return false;
}
if (!["book", "text", "code"].includes(note.type)) {
return false;
}
if (note.mime === "text/x-sqlite;schema=trilium") {
return false;
}
if (note.isLabelTruthy("hideChildrenOverview")) {
return false;
}
return true;
}
async getTextEditor(callback?: GetTextEditorCallback) {

View File

@@ -2,7 +2,7 @@ import keyboardActionService from "../services/keyboard_actions.js";
import note_tooltip from "../services/note_tooltip.js";
import utils from "../services/utils.js";
interface ContextMenuOptions<T> {
export interface ContextMenuOptions<T> {
x: number;
y: number;
orientation?: "left";
@@ -34,6 +34,7 @@ export interface MenuCommandItem<T> {
items?: MenuItem<T>[] | null;
shortcut?: string;
spellingSuggestion?: string;
checked?: boolean;
}
export type MenuItem<T> = MenuCommandItem<T> | MenuSeparatorItem;
@@ -152,10 +153,13 @@ class ContextMenu {
} else {
const $icon = $("<span>");
if ("uiIcon" in item && item.uiIcon) {
$icon.addClass(item.uiIcon);
} else {
$icon.append("&nbsp;");
if ("uiIcon" in item || "checked" in item) {
const icon = (item.checked ? "bx bx-check" : item.uiIcon);
if (icon) {
$icon.addClass(icon);
} else {
$icon.append("&nbsp;");
}
}
const $link = $("<span>")

View File

@@ -3,15 +3,16 @@ import froca from "./froca.js";
import type FNote from "../entities/fnote.js";
import type { AttributeRow } from "./load_results.js";
async function addLabel(noteId: string, name: string, value: string = "") {
async function addLabel(noteId: string, name: string, value: string = "", isInheritable = false) {
await server.put(`notes/${noteId}/attribute`, {
type: "label",
name: name,
value: value
value: value,
isInheritable
});
}
async function setLabel(noteId: string, name: string, value: string = "") {
export async function setLabel(noteId: string, name: string, value: string = "") {
await server.put(`notes/${noteId}/set-attribute`, {
type: "label",
name: name,
@@ -49,7 +50,7 @@ function removeOwnedLabelByName(note: FNote, labelName: string) {
* @param name the name of the attribute to set.
* @param value the value of the attribute to set.
*/
async function setAttribute(note: FNote, type: "label" | "relation", name: string, value: string | null | undefined) {
export async function setAttribute(note: FNote, type: "label" | "relation", name: string, value: string | null | undefined) {
if (value) {
// Create or update the attribute.
await server.put(`notes/${note.noteId}/set-attribute`, { type, name, value });

View File

@@ -118,8 +118,17 @@ async function renderText(note: FNote | FAttachment, $renderedContent: JQuery<HT
async function renderCode(note: FNote | FAttachment, $renderedContent: JQuery<HTMLElement>) {
const blob = await note.getBlob();
let content = blob?.content || "";
if (note.mime === "application/json") {
try {
content = JSON.stringify(JSON.parse(content), null, 4);
} catch (e) {
// Ignore JSON parsing errors.
}
}
const $codeBlock = $("<code>");
$codeBlock.text(blob?.content || "");
$codeBlock.text(content);
$renderedContent.append($("<pre>").append($codeBlock));
await applySingleBlockSyntaxHighlight($codeBlock, normalizeMimeTypeForCKEditor(note.mime));
}
@@ -301,7 +310,7 @@ function getRenderingType(entity: FNote | FAttachment) {
if (type === "file" && mime === "application/pdf") {
type = "pdf";
} else if (type === "file" && mime && CODE_MIME_TYPES.has(mime)) {
} else if ((type === "file" || type === "viewConfig") && mime && CODE_MIME_TYPES.has(mime)) {
type = "code";
} else if (type === "file" && mime && mime.startsWith("audio/")) {
type = "audio";

View File

@@ -384,7 +384,7 @@ function linkContextMenu(e: PointerEvent) {
linkContextMenuService.openContextMenu(notePath, e, viewScope, null);
}
async function loadReferenceLinkTitle($el: JQuery<HTMLElement>, href: string | null | undefined = null) {
export async function loadReferenceLinkTitle($el: JQuery<HTMLElement>, href: string | null | undefined = null) {
const $link = $el[0].tagName === "A" ? $el : $el.find("a");
href = href || $link.attr("href");

View File

@@ -1,38 +1,40 @@
import type FNote from "../entities/fnote.js";
import CalendarView from "../widgets/view_widgets/calendar_view.js";
import ListOrGridView from "../widgets/view_widgets/list_or_grid_view.js";
import TableView from "../widgets/view_widgets/table_view/index.js";
import type { ViewModeArgs } from "../widgets/view_widgets/view_mode.js";
import type ViewMode from "../widgets/view_widgets/view_mode.js";
export type ViewTypeOptions = "list" | "grid" | "calendar";
export type ViewTypeOptions = "list" | "grid" | "calendar" | "table";
export default class NoteListRenderer {
private viewType: ViewTypeOptions;
public viewMode: ViewMode | null;
public viewMode: ViewMode<any> | null;
constructor($parent: JQuery<HTMLElement>, parentNote: FNote, noteIds: string[], showNotePath: boolean = false) {
this.viewType = this.#getViewType(parentNote);
const args: ViewModeArgs = {
$parent,
parentNote,
noteIds,
showNotePath
};
constructor(args: ViewModeArgs) {
this.viewType = this.#getViewType(args.parentNote);
if (this.viewType === "list" || this.viewType === "grid") {
this.viewMode = new ListOrGridView(this.viewType, args);
} else if (this.viewType === "calendar") {
this.viewMode = new CalendarView(args);
} else {
this.viewMode = null;
switch (this.viewType) {
case "list":
case "grid":
this.viewMode = new ListOrGridView(this.viewType, args);
break;
case "calendar":
this.viewMode = new CalendarView(args);
break;
case "table":
this.viewMode = new TableView(args);
break;
default:
this.viewMode = null;
}
}
#getViewType(parentNote: FNote): ViewTypeOptions {
const viewType = parentNote.getLabelValue("viewType");
if (!["list", "grid", "calendar"].includes(viewType || "")) {
if (!["list", "grid", "calendar", "table"].includes(viewType || "")) {
// when not explicitly set, decide based on the note type
return parentNote.type === "search" ? "list" : "grid";
} else {

View File

@@ -14,6 +14,7 @@ let dismissTimer: ReturnType<typeof setTimeout>;
function setupGlobalTooltip() {
$(document).on("mouseenter", "a", mouseEnterHandler);
$(document).on("mouseenter", "[data-href]", mouseEnterHandler);
// close any note tooltip after click, this fixes the problem that sometimes tooltips remained on the screen
$(document).on("click", (e) => {

View File

@@ -1,4 +1,4 @@
type LabelType = "text" | "number" | "boolean" | "date" | "datetime" | "time" | "url";
export type LabelType = "text" | "number" | "boolean" | "date" | "datetime" | "time" | "url";
type Multiplicity = "single" | "multi";
export interface DefinitionObject {

View File

@@ -760,7 +760,8 @@
"expand": "Expand",
"book_properties": "Book Properties",
"invalid_view_type": "Invalid view type '{{type}}'",
"calendar": "Calendar"
"calendar": "Calendar",
"table": "Table"
},
"edited_notes": {
"no_edited_notes_found": "No edited notes on this day yet...",
@@ -1934,5 +1935,9 @@
"title": "Features",
"emoji_completion_enabled": "Enable Emoji auto-completion",
"note_completion_enabled": "Enable note auto-completion"
},
"table_view": {
"new-row": "New row",
"new-column": "New column"
}
}

View File

@@ -34,7 +34,8 @@ export const byNoteType: Record<Exclude<NoteType, "book">, string | null> = {
export const byBookType: Record<ViewTypeOptions, string | null> = {
list: null,
grid: null,
calendar: "xWbu3jpNWapp"
calendar: "xWbu3jpNWapp",
table: "2FvYrpmOXm29"
};
export default class ContextualHelpButton extends NoteContextAwareWidget {

View File

@@ -1,8 +1,10 @@
import NoteContextAwareWidget from "./note_context_aware_widget.js";
import NoteListRenderer from "../services/note_list_renderer.js";
import type FNote from "../entities/fnote.js";
import type { CommandListener, CommandListenerData, EventData } from "../components/app_context.js";
import type { CommandListener, CommandListenerData, CommandMappings, CommandNames, EventData } from "../components/app_context.js";
import type ViewMode from "./view_widgets/view_mode.js";
import AttributeDetailWidget from "./attribute_widgets/attribute_detail.js";
import { Attribute } from "../services/attribute_parser.js";
const TPL = /*html*/`
<div class="note-list-widget">
@@ -36,7 +38,15 @@ export default class NoteListWidget extends NoteContextAwareWidget {
private isIntersecting?: boolean;
private noteIdRefreshed?: string;
private shownNoteId?: string | null;
private viewMode?: ViewMode | null;
private viewMode?: ViewMode<any> | null;
private attributeDetailWidget: AttributeDetailWidget;
constructor() {
super();
this.attributeDetailWidget = new AttributeDetailWidget()
.contentSized()
.setParent(this);
}
isEnabled() {
return super.isEnabled() && this.noteContext?.hasNoteList();
@@ -46,6 +56,7 @@ export default class NoteListWidget extends NoteContextAwareWidget {
this.$widget = $(TPL);
this.contentSized();
this.$content = this.$widget.find(".note-list-widget-content");
this.$widget.append(this.attributeDetailWidget.render());
const observer = new IntersectionObserver(
(entries) => {
@@ -64,6 +75,23 @@ export default class NoteListWidget extends NoteContextAwareWidget {
setTimeout(() => observer.observe(this.$widget[0]), 10);
}
addNoteListItemEvent() {
const attr: Attribute = {
type: "label",
name: "label:myLabel",
value: "promoted,single,text"
};
this.attributeDetailWidget!.showAttributeDetail({
attribute: attr,
allAttributes: [ attr ],
isOwned: true,
x: 100,
y: 200,
focus: "name"
});
}
checkRenderStatus() {
// console.log("this.isIntersecting", this.isIntersecting);
// console.log(`${this.noteIdRefreshed} === ${this.noteId}`, this.noteIdRefreshed === this.noteId);
@@ -76,7 +104,12 @@ export default class NoteListWidget extends NoteContextAwareWidget {
}
async renderNoteList(note: FNote) {
const noteListRenderer = new NoteListRenderer(this.$content, note, note.getChildNoteIds());
const noteListRenderer = new NoteListRenderer({
$parent: this.$content,
parentNote: note,
parentNotePath: this.notePath,
noteIds: note.getChildNoteIds()
});
this.$widget.toggleClass("full-height", noteListRenderer.isFullHeight);
await noteListRenderer.renderList();
this.viewMode = noteListRenderer.viewMode;
@@ -134,4 +167,13 @@ export default class NoteListWidget extends NoteContextAwareWidget {
}
}
triggerCommand<K extends CommandNames>(name: K, data?: CommandMappings[K]): Promise<unknown> | undefined | null {
// Pass the commands to the view mode, which is not actually attached to the hierarchy.
if (this.viewMode?.triggerCommand(name, data)) {
return;
}
return super.triggerCommand(name, data);
}
}

View File

@@ -24,6 +24,7 @@ const TPL = /*html*/`
<option value="grid">${t("book_properties.grid")}</option>
<option value="list">${t("book_properties.list")}</option>
<option value="calendar">${t("book_properties.calendar")}</option>
<option value="table">${t("book_properties.table")}</option>
</select>
</div>
@@ -67,7 +68,6 @@ export default class BookPropertiesWidget extends NoteContextAwareWidget {
getTitle() {
return {
show: this.isEnabled(),
activate: true,
title: t("book_properties.book_properties"),
icon: "bx bx-book"
};
@@ -126,7 +126,7 @@ export default class BookPropertiesWidget extends NoteContextAwareWidget {
return;
}
if (!["list", "grid", "calendar"].includes(type)) {
if (!["list", "grid", "calendar", "table"].includes(type)) {
throw new Error(t("book_properties.invalid_view_type", { type }));
}

View File

@@ -117,7 +117,7 @@ export default class PromotedAttributesWidget extends NoteContextAwareWidget {
// the order of attributes is important as well
ownedAttributes.sort((a, b) => a.position - b.position);
if (promotedDefAttrs.length === 0) {
if (promotedDefAttrs.length === 0 || note.getLabelValue("viewType") === "table") {
this.toggleInt(false);
return;
}

View File

@@ -65,7 +65,12 @@ export default class SearchResultWidget extends NoteContextAwareWidget {
return;
}
const noteListRenderer = new NoteListRenderer(this.$content, note, note.getChildNoteIds(), true);
const noteListRenderer = new NoteListRenderer({
$parent: this.$content,
parentNote: note,
noteIds: note.getChildNoteIds(),
showNotePath: true
});
await noteListRenderer.renderList();
}

View File

@@ -36,7 +36,21 @@ export default class BookTypeWidget extends TypeWidget {
}
async doRefresh(note: FNote) {
this.$helpNoChildren.toggle(!this.note?.hasChildren() && this.note?.getAttributeValue("label", "viewType") !== "calendar");
this.$helpNoChildren.toggle(this.shouldDisplayNoChildrenWarning());
}
shouldDisplayNoChildrenWarning() {
if (this.note?.hasChildren()) {
return false;
}
switch (this.note?.getAttributeValue("label", "viewType")) {
case "calendar":
case "table":
return false;
default:
return true;
}
}
entitiesReloadedEvent({ loadResults }: EventData<"entitiesReloaded">) {

View File

@@ -16,6 +16,10 @@ const TPL = /*html*/`<div class="note-detail-doc note-detail-printable">
border-radius: 5px;
}
.note-detail-doc-content code {
font-variant: none;
}
.note-detail-doc-content pre:not(.hljs) {
background-color: var(--accented-background-color);
border: 1px solid var(--main-border-color);

View File

@@ -109,24 +109,22 @@ const CALENDAR_VIEWS = [
"listMonth"
]
export default class CalendarView extends ViewMode {
export default class CalendarView extends ViewMode<{}> {
private $root: JQuery<HTMLElement>;
private $calendarContainer: JQuery<HTMLElement>;
private noteIds: string[];
private parentNote: FNote;
private calendar?: Calendar;
private isCalendarRoot: boolean;
private lastView?: string;
private debouncedSaveView?: DebouncedFunction<() => void>;
constructor(args: ViewModeArgs) {
super(args);
super(args, "calendar");
this.$root = $(TPL);
this.$calendarContainer = this.$root.find(".calendar-container");
this.noteIds = args.noteIds;
this.parentNote = args.parentNote;
this.isCalendarRoot = false;
args.$parent.append(this.$root);
}

View File

@@ -6,6 +6,7 @@ import treeService from "../../services/tree.js";
import utils from "../../services/utils.js";
import type FNote from "../../entities/fnote.js";
import ViewMode, { type ViewModeArgs } from "./view_mode.js";
import type { ViewTypeOptions } from "../../services/note_list_renderer.js";
const TPL = /*html*/`
<div class="note-list">
@@ -157,26 +158,22 @@ const TPL = /*html*/`
</div>
</div>`;
class ListOrGridView extends ViewMode {
class ListOrGridView extends ViewMode<{}> {
private $noteList: JQuery<HTMLElement>;
private parentNote: FNote;
private noteIds: string[];
private page?: number;
private pageSize?: number;
private viewType?: string | null;
private showNotePath?: boolean;
private highlightRegex?: RegExp | null;
/*
* We're using noteIds so that it's not necessary to load all notes at once when paging
*/
constructor(viewType: string, args: ViewModeArgs) {
super(args);
constructor(viewType: ViewTypeOptions, args: ViewModeArgs) {
super(args, viewType);
this.$noteList = $(TPL);
this.viewType = viewType;
this.parentNote = args.parentNote;
const includedNoteIds = this.getIncludedNoteIds();
this.noteIds = args.noteIds.filter((noteId) => !includedNoteIds.has(noteId) && noteId !== "_hidden");

View File

@@ -0,0 +1,110 @@
import { RelationEditor } from "./relation_editor.js";
import { NoteFormatter, NoteTitleFormatter } from "./formatters.js";
import { applyHeaderMenu } from "./header-menu.js";
import type { ColumnDefinition } from "tabulator-tables";
import { LabelType } from "../../../services/promoted_attribute_definition_parser.js";
type ColumnType = LabelType | "relation";
export interface PromotedAttributeInformation {
name: string;
title?: string;
type?: ColumnType;
}
const labelTypeMappings: Record<ColumnType, Partial<ColumnDefinition>> = {
text: {
editor: "input"
},
boolean: {
formatter: "tickCross",
editor: "tickCross"
},
date: {
editor: "date",
},
datetime: {
editor: "datetime"
},
number: {
editor: "number"
},
time: {
editor: "input"
},
url: {
formatter: "link",
editor: "input"
},
relation: {
editor: RelationEditor,
formatter: NoteFormatter
}
};
export function buildColumnDefinitions(info: PromotedAttributeInformation[], existingColumnData?: ColumnDefinition[]) {
const columnDefs: ColumnDefinition[] = [
{
title: "#",
formatter: "rownum",
headerSort: false,
hozAlign: "center",
resizable: false,
frozen: true
},
{
field: "noteId",
title: "Note ID",
visible: false
},
{
field: "title",
title: "Title",
editor: "input",
formatter: NoteTitleFormatter,
width: 400
}
];
const seenFields = new Set<string>();
for (const { name, title, type } of info) {
const prefix = (type === "relation" ? "relations" : "labels");
const field = `${prefix}.${name}`;
if (seenFields.has(field)) {
continue;
}
columnDefs.push({
field,
title: title ?? name,
editor: "input",
...labelTypeMappings[type ?? "text"],
});
seenFields.add(field);
}
applyHeaderMenu(columnDefs);
if (existingColumnData) {
restoreExistingData(columnDefs, existingColumnData);
}
return columnDefs;
}
function restoreExistingData(newDefs: ColumnDefinition[], oldDefs: ColumnDefinition[]) {
const byField = new Map<string, ColumnDefinition>;
for (const def of oldDefs) {
byField.set(def.field ?? "", def);
}
for (const newDef of newDefs) {
const oldDef = byField.get(newDef.field ?? "");
if (!oldDef) {
continue;
}
newDef.width = oldDef.width;
newDef.visible = oldDef.visible;
}
}

View File

@@ -0,0 +1,25 @@
import type { Tabulator } from "tabulator-tables";
import type FNote from "../../../entities/fnote.js";
import branches from "../../../services/branches.js";
export function canReorderRows(parentNote: FNote) {
return !parentNote.hasLabel("sorted")
&& parentNote.type !== "search";
}
export function configureReorderingRows(tabulator: Tabulator) {
tabulator.on("rowMoved", (row) => {
const branchIdsToMove = [ row.getData().branchId ];
const prevRow = row.getPrevRow();
if (prevRow) {
branches.moveAfterBranch(branchIdsToMove, prevRow.getData().branchId);
return;
}
const nextRow = row.getNextRow();
if (nextRow) {
branches.moveBeforeBranch(branchIdsToMove, nextRow.getData().branchId);
}
});
}

View File

@@ -0,0 +1,22 @@
import FNote from "../../../entities/fnote.js";
import { t } from "../../../services/i18n.js";
function shouldDisplayFooter(parentNote: FNote) {
return (parentNote.type !== "search");
}
export default function buildFooter(parentNote: FNote) {
if (!shouldDisplayFooter(parentNote)) {
return undefined;
}
return /*html*/`\
<button class="btn btn-sm" style="padding: 0px 10px 0px 10px;" data-trigger-command="addNewRow">
<span class="bx bx-plus"></span> ${t("table_view.new-row")}
</button>
<button class="btn btn-sm" style="padding: 0px 10px 0px 10px;" data-trigger-command="addNoteListItem">
<span class="bx bx-columns"></span> ${t("table_view.new-column")}
</button>
`.trimStart();
}

View File

@@ -0,0 +1,45 @@
import { CellComponent } from "tabulator-tables";
import { loadReferenceLinkTitle } from "../../../services/link.js";
/**
* Custom formatter to represent a note, with the icon and note title being rendered.
*
* The value of the cell must be the note ID.
*/
export function NoteFormatter(cell: CellComponent, _formatterParams, onRendered) {
let noteId = cell.getValue();
if (!noteId) {
return "";
}
onRendered(async () => {
const { $noteRef, href } = buildNoteLink(noteId);
await loadReferenceLinkTitle($noteRef, href);
cell.getElement().appendChild($noteRef[0]);
});
return "";
}
/**
* Custom formatter for the note title that is quite similar to {@link NoteFormatter}, but where the title and icons are read from separate fields.
*/
export function NoteTitleFormatter(cell: CellComponent) {
const { noteId, iconClass } = cell.getRow().getData();
if (!noteId) {
return "";
}
const { $noteRef } = buildNoteLink(noteId);
$noteRef.text(cell.getValue());
$noteRef.prepend($("<span>").addClass(iconClass));
return $noteRef[0].outerHTML;
}
function buildNoteLink(noteId: string) {
const $noteRef = $("<span>");
const href = `#root/${noteId}`;
$noteRef.addClass("reference-link");
$noteRef.attr("data-href", href);
return { $noteRef, href };
}

View File

@@ -0,0 +1,53 @@
import type { ColumnComponent, ColumnDefinition, MenuObject, Tabulator } from "tabulator-tables";
export function applyHeaderMenu(columns: ColumnDefinition[]) {
for (let column of columns) {
if (column.headerSort !== false) {
column.headerMenu = headerMenu;
}
}
}
function headerMenu(this: Tabulator) {
const menu: MenuObject<ColumnComponent>[] = [];
const columns = this.getColumns();
for (let column of columns) {
//create checkbox element using font awesome icons
let icon = document.createElement("i");
icon.classList.add("bx");
icon.classList.add(column.isVisible() ? "bx-check" : "bx-empty");
//build label
let label = document.createElement("span");
let title = document.createElement("span");
title.textContent = " " + column.getDefinition().title;
label.appendChild(icon);
label.appendChild(title);
//create menu item
menu.push({
label: label,
action: function (e) {
//prevent menu closing
e.stopPropagation();
//toggle current column visibility
column.toggle();
//change menu item icon
if (column.isVisible()) {
icon.classList.remove("bx-empty");
icon.classList.add("bx-check");
} else {
icon.classList.remove("bx-check");
icon.classList.add("bx-empty");
}
}
});
}
return menu;
};

View File

@@ -0,0 +1,265 @@
import froca from "../../../services/froca.js";
import ViewMode, { type ViewModeArgs } from "../view_mode.js";
import attributes, { setAttribute, setLabel } from "../../../services/attributes.js";
import server from "../../../services/server.js";
import SpacedUpdate from "../../../services/spaced_update.js";
import type { CommandListenerData, EventData } from "../../../components/app_context.js";
import type { Attribute } from "../../../services/attribute_parser.js";
import note_create from "../../../services/note_create.js";
import {Tabulator, SortModule, FormatModule, InteractionModule, EditModule, ResizeColumnsModule, FrozenColumnsModule, PersistenceModule, MoveColumnsModule, MenuModule, MoveRowsModule, ColumnDefinition} from 'tabulator-tables';
import "tabulator-tables/dist/css/tabulator_bootstrap5.min.css";
import { canReorderRows, configureReorderingRows } from "./dragging.js";
import buildFooter from "./footer.js";
import getPromotedAttributeInformation, { buildRowDefinitions } from "./rows.js";
import { buildColumnDefinitions } from "./columns.js";
const TPL = /*html*/`
<div class="table-view">
<style>
.table-view {
overflow: hidden;
position: relative;
height: 100%;
user-select: none;
padding: 0 5px 0 10px;
}
.table-view-container {
height: 100%;
}
.search-result-widget-content .table-view {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
}
.tabulator-cell .autocomplete {
position: absolute;
top: 50%;
transform: translateY(-50%);
background: transparent;
outline: none !important;
}
.tabulator .tabulator-header {
border-top: unset;
border-bottom-width: 1px;
}
.tabulator .tabulator-header .tabulator-frozen.tabulator-frozen-left,
.tabulator-row .tabulator-cell.tabulator-frozen.tabulator-frozen-left {
border-right-width: 1px;
}
.tabulator .tabulator-footer {
background-color: unset;
padding: 5px 0;
}
.tabulator .tabulator-footer .tabulator-footer-contents {
justify-content: left;
gap: 0.5em;
}
</style>
<div class="table-view-container"></div>
</div>
`;
export interface StateInfo {
tableData?: {
columns?: ColumnDefinition[];
};
}
export default class TableView extends ViewMode<StateInfo> {
private $root: JQuery<HTMLElement>;
private $container: JQuery<HTMLElement>;
private args: ViewModeArgs;
private spacedUpdate: SpacedUpdate;
private api?: Tabulator;
private newAttribute?: Attribute;
private persistentData: StateInfo["tableData"];
/** If set to a note ID, whenever the rows will be updated, the title of the note will be automatically focused for editing. */
private noteIdToEdit?: string;
constructor(args: ViewModeArgs) {
super(args, "table");
this.$root = $(TPL);
this.$container = this.$root.find(".table-view-container");
this.args = args;
this.spacedUpdate = new SpacedUpdate(() => this.onSave(), 5_000);
this.persistentData = {};
args.$parent.append(this.$root);
}
get isFullHeight(): boolean {
return true;
}
async renderList() {
this.$container.empty();
this.renderTable(this.$container[0]);
return this.$root;
}
private async renderTable(el: HTMLElement) {
const modules = [SortModule, FormatModule, InteractionModule, EditModule, ResizeColumnsModule, FrozenColumnsModule, PersistenceModule, MoveColumnsModule, MoveRowsModule, MenuModule];
for (const module of modules) {
Tabulator.registerModule(module);
}
this.initialize(el);
}
private async initialize(el: HTMLElement) {
const notes = await froca.getNotes(this.args.noteIds);
const info = getPromotedAttributeInformation(this.parentNote);
const viewStorage = await this.viewStorage.restore();
this.persistentData = viewStorage?.tableData || {};
const columnDefs = buildColumnDefinitions(info);
const movableRows = canReorderRows(this.parentNote);
this.api = new Tabulator(el, {
layout: "fitDataFill",
index: "noteId",
columns: columnDefs,
data: await buildRowDefinitions(this.parentNote, notes, info),
persistence: true,
movableColumns: true,
movableRows,
footerElement: buildFooter(this.parentNote),
persistenceWriterFunc: (_id, type: string, data: object) => {
(this.persistentData as Record<string, {}>)[type] = data;
this.spacedUpdate.scheduleUpdate();
},
persistenceReaderFunc: (_id, type: string) => this.persistentData?.[type],
});
configureReorderingRows(this.api);
this.setupEditing();
}
private onSave() {
this.viewStorage.store({
tableData: this.persistentData,
});
}
private setupEditing() {
this.api!.on("cellEdited", async (cell) => {
const noteId = cell.getRow().getData().noteId;
const field = cell.getField();
const newValue = cell.getValue();
if (field === "title") {
server.put(`notes/${noteId}/title`, { title: newValue });
return;
}
if (field.includes(".")) {
const [ type, name ] = field.split(".", 2);
if (type === "labels") {
setLabel(noteId, name, newValue);
} else if (type === "relations") {
const note = await froca.getNote(noteId);
if (note) {
setAttribute(note, "relation", name, newValue);
}
}
}
});
}
async reloadAttributesCommand() {
console.log("Reload attributes");
}
async updateAttributeListCommand({ attributes }: CommandListenerData<"updateAttributeList">) {
this.newAttribute = attributes[0];
}
async saveAttributesCommand() {
if (!this.newAttribute) {
return;
}
const { name, value } = this.newAttribute;
attributes.addLabel(this.parentNote.noteId, name, value, true);
console.log("Save attributes", this.newAttribute);
}
addNewRowCommand() {
const parentNotePath = this.args.parentNotePath;
if (parentNotePath) {
note_create.createNote(parentNotePath, {
activate: false
}).then(({ note }) => {
if (!note) {
return;
}
this.noteIdToEdit = note.noteId;
})
}
}
onEntitiesReloaded({ loadResults }: EventData<"entitiesReloaded">): boolean | void {
if (!this.api) {
return;
}
// Refresh if promoted attributes get changed.
if (loadResults.getAttributeRows().find(attr =>
attr.type === "label" &&
(attr.name?.startsWith("label:") || attr.name?.startsWith("relation:")) &&
attributes.isAffecting(attr, this.parentNote))) {
this.#manageColumnUpdate();
}
if (loadResults.getBranchRows().some(branch => branch.parentNoteId === this.parentNote.noteId)) {
this.#manageRowsUpdate();
}
if (loadResults.getAttributeRows().some(attr => this.args.noteIds.includes(attr.noteId!))) {
this.#manageRowsUpdate();
}
return false;
}
#manageColumnUpdate() {
if (!this.api) {
return;
}
const info = getPromotedAttributeInformation(this.parentNote);
const columnDefs = buildColumnDefinitions(info, this.persistentData?.columns);
this.api.setColumns(columnDefs);
}
async #manageRowsUpdate() {
if (!this.api) {
return;
}
const notes = await froca.getNotes(this.args.noteIds);
const info = getPromotedAttributeInformation(this.parentNote);
this.api.replaceData(await buildRowDefinitions(this.parentNote, notes, info));
if (this.noteIdToEdit) {
const row = this.api?.getRows().find(r => r.getData().noteId === this.noteIdToEdit);
if (row) {
row.getCell("title").edit();
}
this.noteIdToEdit = undefined;
}
}
}

View File

@@ -0,0 +1,51 @@
import { CellComponent } from "tabulator-tables";
import note_autocomplete from "../../../services/note_autocomplete";
import froca from "../../../services/froca";
export function RelationEditor(cell: CellComponent, onRendered, success, cancel, editorParams){
//cell - the cell component for the editable cell
//onRendered - function to call when the editor has been rendered
//success - function to call to pass thesuccessfully updated value to Tabulator
//cancel - function to call to abort the edit and return to a normal cell
//editorParams - params object passed into the editorParams column definition property
//create and style editor
const editor = document.createElement("input");
const $editor = $(editor);
editor.classList.add("form-control");
//create and style input
editor.style.padding = "3px";
editor.style.width = "100%";
editor.style.boxSizing = "border-box";
//Set value of editor to the current value of the cell
const noteId = cell.getValue();
if (noteId) {
const note = froca.getNoteFromCache(noteId);
editor.value = note.title;
}
//set focus on the select box when the editor is selected
onRendered(function(){
note_autocomplete.initNoteAutocomplete($editor, {
allowCreatingNotes: true
}).on("autocomplete:noteselected", (event, suggestion, dataset) => {
const notePath = suggestion.notePath;
if (!notePath) {
return;
}
const noteId = notePath.split("/").at(-1);
success(noteId);
});
editor.focus();
});
const container = document.createElement("div");
container.classList.add("input-group");
container.classList.add("autocomplete");
container.appendChild(editor);
return container;
};

View File

@@ -0,0 +1,74 @@
import FNote from "../../../entities/fnote.js";
import type { LabelType } from "../../../services/promoted_attribute_definition_parser.js";
import type { PromotedAttributeInformation } from "./columns.js";
export type TableData = {
iconClass: string;
noteId: string;
title: string;
labels: Record<string, boolean | string | null>;
relations: Record<string, boolean | string | null>;
branchId: string;
};
export async function buildRowDefinitions(parentNote: FNote, notes: FNote[], infos: PromotedAttributeInformation[]) {
const definitions: TableData[] = [];
for (const branch of parentNote.getChildBranches()) {
const note = await branch.getNote();
if (!note) {
continue; // Skip if the note is not found
}
const labels: typeof definitions[0]["labels"] = {};
const relations: typeof definitions[0]["relations"] = {};
for (const { name, type } of infos) {
if (type === "relation") {
relations[name] = note.getRelationValue(name);
} else if (type === "boolean") {
labels[name] = note.hasLabel(name);
} else {
labels[name] = note.getLabelValue(name);
}
}
definitions.push({
iconClass: note.getIcon(),
noteId: note.noteId,
title: note.title,
labels,
relations,
branchId: branch.branchId
});
}
return definitions;
}
export default function getPromotedAttributeInformation(parentNote: FNote) {
const info: PromotedAttributeInformation[] = [];
for (const promotedAttribute of parentNote.getPromotedDefinitionAttributes()) {
const def = promotedAttribute.getDefinition();
if (def.multiplicity !== "single") {
console.warn("Multiple values are not supported for now");
continue;
}
const [ labelType, name ] = promotedAttribute.name.split(":", 2);
if (promotedAttribute.type !== "label") {
console.warn("Relations are not supported for now");
continue;
}
let type: LabelType | "relation" = def.labelType || "text";
if (labelType === "relation") {
type = "relation";
}
info.push({
name,
title: def.promotedAlias,
type
});
}
console.log("Promoted attribute information", info);
return info;
}

View File

@@ -1,18 +1,30 @@
import type { EventData } from "../../components/app_context.js";
import Component from "../../components/component.js";
import type FNote from "../../entities/fnote.js";
import type { ViewTypeOptions } from "../../services/note_list_renderer.js";
import ViewModeStorage from "./view_mode_storage.js";
export interface ViewModeArgs {
$parent: JQuery<HTMLElement>;
parentNote: FNote;
parentNotePath?: string | null;
noteIds: string[];
showNotePath?: boolean;
}
export default abstract class ViewMode {
export default abstract class ViewMode<T extends object> extends Component {
constructor(args: ViewModeArgs) {
private _viewStorage: ViewModeStorage<T> | null;
protected parentNote: FNote;
protected viewType: ViewTypeOptions;
constructor(args: ViewModeArgs, viewType: ViewTypeOptions) {
super();
this.parentNote = args.parentNote;
this._viewStorage = null;
// note list must be added to the DOM immediately, otherwise some functionality scripting (canvas) won't work
args.$parent.empty();
this.viewType = viewType;
}
abstract renderList(): Promise<JQuery<HTMLElement> | undefined>;
@@ -32,4 +44,13 @@ export default abstract class ViewMode {
return false;
}
get viewStorage() {
if (this._viewStorage) {
return this._viewStorage;
}
this._viewStorage = new ViewModeStorage(this.parentNote, this.viewType);
return this._viewStorage;
}
}

View File

@@ -0,0 +1,43 @@
import type FNote from "../../entities/fnote";
import type { ViewTypeOptions } from "../../services/note_list_renderer";
import server from "../../services/server";
const ATTACHMENT_ROLE = "viewConfig";
export default class ViewModeStorage<T extends object> {
private note: FNote;
private attachmentName: string;
constructor(note: FNote, viewType: ViewTypeOptions) {
this.note = note;
this.attachmentName = viewType + ".json";
}
async store(data: T) {
const payload = {
role: ATTACHMENT_ROLE,
title: this.attachmentName,
mime: "application/json",
content: JSON.stringify(data),
position: 0
};
await server.post(`notes/${this.note.noteId}/attachments?matchBy=title`, payload);
}
async restore() {
const existingAttachments = await this.note.getAttachmentsByRole(ATTACHMENT_ROLE);
if (existingAttachments.length === 0) {
return undefined;
}
const attachment = existingAttachments
.find(a => a.title === this.attachmentName);
if (!attachment) {
return undefined;
}
const attachmentData = await server.get<{ content: string } | null>(`attachments/${attachment.attachmentId}/blob`);
return JSON.parse(attachmentData?.content ?? "{}");
}
}