Introduce the table view (#6097)

This commit is contained in:
Elian Doran 2025-07-04 23:34:34 +03:00 committed by GitHub
commit ac8b0535d2
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
43 changed files with 1183 additions and 75 deletions

View File

@ -54,6 +54,7 @@
"preact": "10.26.9",
"split.js": "1.6.5",
"svg-pan-zoom": "3.6.2",
"tabulator-tables": "6.3.1",
"vanilla-js-wheel-zoom": "9.0.4"
},
"devDependencies": {
@ -63,6 +64,7 @@
"@types/leaflet": "1.9.19",
"@types/leaflet-gpx": "1.3.7",
"@types/mark.js": "8.11.12",
"@types/tabulator-tables": "6.2.6",
"copy-webpack-plugin": "13.0.0",
"happy-dom": "18.0.1",
"script-loader": "0.7.2",

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";
@ -28,6 +28,7 @@ export interface MenuCommandItem<T> {
items?: MenuItem<T>[] | null;
shortcut?: string;
spellingSuggestion?: string;
checked?: boolean;
}
export type MenuItem<T> = MenuCommandItem<T> | MenuSeparatorItem;
@ -146,10 +147,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...",
@ -1933,5 +1934,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,13 @@ export default class SearchResultWidget extends NoteContextAwareWidget {
return;
}
const noteListRenderer = new NoteListRenderer(this.$content, note, note.getChildNoteIds(), true);
// 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

@ -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 ?? "{}");
}
}

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1,98 @@
<figure class="image">
<img style="aspect-ratio:1050/259;" src="Table_image.png" width="1050"
height="259">
</figure>
<p>The table view displays information in a grid, where the rows are individual
notes and the columns are&nbsp;<a class="reference-link" href="#root/pOsGYCXsbNQG/tC7s2alapj8V/zEY4DaJG4YT5/_help_OFXdgB2nNk1F">Promoted Attributes</a>.
In addition, values are editable.</p>
<h2>Interaction</h2>
<h3>Creating a new table</h3>
<p>Right click the&nbsp;<a class="reference-link" href="#root/pOsGYCXsbNQG/gh7bpGYxajRS/Vc8PjrjAGuOp/_help_oPVyFC7WL2Lp">Note Tree</a>&nbsp;and
select <em>Insert child note</em> and look for the <em>Table item</em>.</p>
<h3>Adding columns</h3>
<p>Each column is a <a href="#root/pOsGYCXsbNQG/tC7s2alapj8V/zEY4DaJG4YT5/_help_OFXdgB2nNk1F">promoted attribute</a> that
is defined on the Book note. Ideally, the promoted attributes need to be
inheritable in order to show up in the child notes.</p>
<p>To create a new column, simply press <em>Add new column </em>at the bottom
of the table.</p>
<p>There are also a few predefined columns:</p>
<ul>
<li>The current item number, identified by the <code>#</code> symbol. This simply
counts the note and is affected by sorting.</li>
<li><a class="reference-link" href="#root/pOsGYCXsbNQG/tC7s2alapj8V/_help_m1lbrzyKDaRB">Note ID</a>,
representing the unique ID used internally by Trilium</li>
<li>The title of the note.</li>
</ul>
<h3>Adding new rows</h3>
<p>Each row is actually a note that is a child of the book note.</p>
<p>To create a new note, press <em>Add new row</em> at the bottom of the table.
By default it will try to edit the title of the newly created note.</p>
<p>Alternatively, the note can be created from the<a class="reference-link"
href="#root/pOsGYCXsbNQG/gh7bpGYxajRS/Vc8PjrjAGuOp/_help_oPVyFC7WL2Lp">Note Tree</a>&nbsp;or
<a
href="#root/pOsGYCXsbNQG/_help_CdNpE2pqjmI6">scripting</a>.</p>
<h3>Editing data</h3>
<p>Simply click on a cell within a row to change its value. The change will
not only reflect in the table, but also as an attribute of the corresponding
note.</p>
<ul>
<li>The editing will respect the type of the promoted attribute, by presenting
a normal text box, a number selector or a date selector for example.</li>
<li>It also possible to change the title of a note.</li>
<li>Editing relations is also possible, by using the note autocomplete.</li>
</ul>
<h2>Working with the data</h2>
<h3>Sorting</h3>
<p>It is possible to sort the data by the values of a column:</p>
<ul>
<li>To do so, simply click on a column.</li>
<li>To switch between ascending or descending sort, simply click again on
the same column. The arrow next to the column will indicate the direction
of the sort.</li>
</ul>
<h3>Reordering and hiding columns</h3>
<ul>
<li>Columns can be reordered by dragging the header of the columns.</li>
<li>Columns can be hidden or shown by right clicking on a column and clicking
the item corresponding to the column.</li>
</ul>
<h3>Reordering rows</h3>
<p>Notes can be dragged around to change their order. This will also change
the order of the note in the&nbsp;<a class="reference-link" href="#root/pOsGYCXsbNQG/gh7bpGYxajRS/Vc8PjrjAGuOp/_help_oPVyFC7WL2Lp">Note Tree</a>.</p>
<p>Currently, it's possible to reorder notes even if sorting is used, but
the result might be inconsistent.</p>
<h2>Limitations</h2>
<p>The table functionality is still in its early stages, as such it faces
quite a few important limitations:</p>
<ol>
<li>As mentioned previously, the columns of the table are defined as&nbsp;
<a
class="reference-link" href="#root/pOsGYCXsbNQG/tC7s2alapj8V/zEY4DaJG4YT5/_help_OFXdgB2nNk1F">Promoted Attributes</a>.
<ol>
<li>But only the promoted attributes that are defined at the level of the
Book note are actually taken into consideration.</li>
<li>There are plans to recursively look for columns across the sub-hierarchy.</li>
</ol>
</li>
<li>Hierarchy is not yet supported, so the table will only show the items
that are direct children of the <em>Book</em> note.</li>
<li>Multiple labels and relations are not supported. If a&nbsp;<a class="reference-link"
href="#root/pOsGYCXsbNQG/tC7s2alapj8V/zEY4DaJG4YT5/_help_OFXdgB2nNk1F">Promoted Attributes</a>&nbsp;is
defined with a <em>Multi value</em> specificity, they will be ignored.</li>
</ol>
<h2>Use in search</h2>
<p>The table view can be used in a&nbsp;<a class="reference-link" href="#root/pOsGYCXsbNQG/KSZ04uQ2D1St/_help_m523cpzocqaD">Saved Search</a>&nbsp;by
adding the <code>#viewType=table</code> attribute.</p>
<p>Unlike when used in a book, saved searches are not limited to the sub-hierarchy
of a note and allows for advanced queries thanks to the power of the&nbsp;
<a
class="reference-link" href="#root/pOsGYCXsbNQG/gh7bpGYxajRS/wArbEsdSae6g/_help_eIg8jdvaoNNd">Search</a>.</p>
<p>However, there are also some limitations:</p>
<ul>
<li>It's not possible to reorder notes.</li>
<li>It's not possible to add a new row.</li>
</ul>
<p>Columns are supported, by being defined as&nbsp;<a class="reference-link"
href="#root/pOsGYCXsbNQG/tC7s2alapj8V/zEY4DaJG4YT5/_help_OFXdgB2nNk1F">Promoted Attributes</a>&nbsp;to
the&nbsp;<a class="reference-link" href="#root/pOsGYCXsbNQG/KSZ04uQ2D1St/_help_m523cpzocqaD">Saved Search</a>&nbsp;note.</p>
<p>Editing is also supported.</p>

Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

View File

@ -6,4 +6,7 @@
of the same name:&nbsp;<a href="#root/_help_BCkXAVs63Ttv">Note Map (Link map, Tree map)</a>.</p>
<p>Once created, the note map will display the relations between notes. Only
the notes that are part of the parent of the note map will be displayed
(including their children).</p>
(including their children).</p>
<p>The labels <code>mapIncludeRelation</code> and <code>mapExcludeRelation</code>,
if set, filter the note map to include only the specified relations or
to exclude the specified relations, respectively.</p>

View File

@ -48,7 +48,8 @@ function updateNoteAttribute(req: Request) {
attribute = new BAttribute({
noteId: noteId,
name: body.name,
type: body.type
type: body.type,
isInheritable: body.isInheritable
});
}

View File

@ -26,6 +26,23 @@ export default function buildHiddenSubtreeTemplates() {
value: "promoted,alias=Description,single,text"
}
]
},
{
id: "_template_table",
type: "book",
title: "Table",
icon: "bx bx-table",
attributes: [
{
name: "template",
type: "label"
},
{
name: "viewType",
type: "label",
value: "table"
}
]
}
]
};

View File

@ -1,6 +1,6 @@
{
"formatVersion": 2,
"appVersion": "0.95.0",
"appVersion": "0.96.0",
"files": [
{
"isClone": false,

View File

@ -1,6 +1,6 @@
{
"formatVersion": 2,
"appVersion": "0.95.0",
"appVersion": "0.96.0",
"files": [
{
"isClone": false,

View File

@ -1,12 +1,11 @@
# v0.96.0
> [!NOTE]
> The Docker image has been relocated to `triliumnext/trilium`. Please update your configuration accordingly.
> [!IMPORTANT]
> If you enjoyed this release, consider showing a token of appreciation by:
>
> * Pressing the “Star” button on [GitHub](https://github.com/TriliumNext/Notes) (top-right).
> * Pressing the “Star” button on [GitHub](https://github.com/TriliumNext/Trilium) (top-right).
> * Considering a one-time or recurrent donation to the [lead developer](https://github.com/eliandoran) via [GitHub Sponsors](https://github.com/sponsors/eliandoran) or [PayPal](https://paypal.me/eliandoran).
## 💡 Key highlights

View File

@ -1,6 +1,6 @@
{
"formatVersion": 2,
"appVersion": "0.95.0",
"appVersion": "0.96.0",
"files": [
{
"isClone": false,
@ -3420,6 +3420,86 @@
"dataFileName": "11_Calendar View_image.png"
}
]
},
{
"isClone": false,
"noteId": "2FvYrpmOXm29",
"notePath": [
"pOsGYCXsbNQG",
"gh7bpGYxajRS",
"BFs8mudNFgCS",
"0ESUbbAxVnoK",
"2FvYrpmOXm29"
],
"title": "Table",
"notePosition": 20,
"prefix": null,
"isExpanded": false,
"type": "text",
"mime": "text/html",
"attributes": [
{
"type": "label",
"name": "iconClass",
"value": "bx bx-table",
"isInheritable": false,
"position": 10
},
{
"type": "relation",
"name": "internalLink",
"value": "OFXdgB2nNk1F",
"isInheritable": false,
"position": 20
},
{
"type": "relation",
"name": "internalLink",
"value": "oPVyFC7WL2Lp",
"isInheritable": false,
"position": 30
},
{
"type": "relation",
"name": "internalLink",
"value": "m1lbrzyKDaRB",
"isInheritable": false,
"position": 40
},
{
"type": "relation",
"name": "internalLink",
"value": "CdNpE2pqjmI6",
"isInheritable": false,
"position": 50
},
{
"type": "relation",
"name": "internalLink",
"value": "m523cpzocqaD",
"isInheritable": false,
"position": 60
},
{
"type": "relation",
"name": "internalLink",
"value": "eIg8jdvaoNNd",
"isInheritable": false,
"position": 70
}
],
"format": "markdown",
"dataFileName": "Table.md",
"attachments": [
{
"attachmentId": "vJYUG9fLQ2Pd",
"title": "image.png",
"role": "image",
"mime": "image/png",
"position": 10,
"dataFileName": "Table_image.png"
}
]
}
]
}

View File

@ -0,0 +1,83 @@
# Table
<figure class="image"><img style="aspect-ratio:1050/259;" src="Table_image.png" width="1050" height="259"></figure>
The table view displays information in a grid, where the rows are individual notes and the columns are <a class="reference-link" href="../../../Advanced%20Usage/Attributes/Promoted%20Attributes.md">Promoted Attributes</a>. In addition, values are editable.
## Interaction
### Creating a new table
Right click the <a class="reference-link" href="../../UI%20Elements/Note%20Tree.md">Note Tree</a> and select _Insert child note_ and look for the _Table item_.
### Adding columns
Each column is a [promoted attribute](../../../Advanced%20Usage/Attributes/Promoted%20Attributes.md) that is defined on the Book note. Ideally, the promoted attributes need to be inheritable in order to show up in the child notes.
To create a new column, simply press _Add new column_ at the bottom of the table.
There are also a few predefined columns:
* The current item number, identified by the `#` symbol. This simply counts the note and is affected by sorting.
* <a class="reference-link" href="../../../Advanced%20Usage/Note%20ID.md">Note ID</a>, representing the unique ID used internally by Trilium
* The title of the note.
### Adding new rows
Each row is actually a note that is a child of the book note.
To create a new note, press _Add new row_ at the bottom of the table. By default it will try to edit the title of the newly created note.
Alternatively, the note can be created from the<a class="reference-link" href="../../UI%20Elements/Note%20Tree.md">Note Tree</a> or [scripting](../../../Scripting.md).
### Editing data
Simply click on a cell within a row to change its value. The change will not only reflect in the table, but also as an attribute of the corresponding note.
* The editing will respect the type of the promoted attribute, by presenting a normal text box, a number selector or a date selector for example.
* It also possible to change the title of a note.
* Editing relations is also possible, by using the note autocomplete.
## Working with the data
### Sorting
It is possible to sort the data by the values of a column:
* To do so, simply click on a column.
* To switch between ascending or descending sort, simply click again on the same column. The arrow next to the column will indicate the direction of the sort.
### Reordering and hiding columns
* Columns can be reordered by dragging the header of the columns.
* Columns can be hidden or shown by right clicking on a column and clicking the item corresponding to the column.
### Reordering rows
Notes can be dragged around to change their order. This will also change the order of the note in the <a class="reference-link" href="../../UI%20Elements/Note%20Tree.md">Note Tree</a>.
Currently, it's possible to reorder notes even if sorting is used, but the result might be inconsistent.
## Limitations
The table functionality is still in its early stages, as such it faces quite a few important limitations:
1. As mentioned previously, the columns of the table are defined as <a class="reference-link" href="../../../Advanced%20Usage/Attributes/Promoted%20Attributes.md">Promoted Attributes</a>.
1. But only the promoted attributes that are defined at the level of the Book note are actually taken into consideration.
2. There are plans to recursively look for columns across the sub-hierarchy.
2. Hierarchy is not yet supported, so the table will only show the items that are direct children of the _Book_ note.
3. Multiple labels and relations are not supported. If a <a class="reference-link" href="../../../Advanced%20Usage/Attributes/Promoted%20Attributes.md">Promoted Attributes</a> is defined with a _Multi value_ specificity, they will be ignored.
## Use in search
The table view can be used in a <a class="reference-link" href="../../../Note%20Types/Saved%20Search.md">Saved Search</a> by adding the `#viewType=table` attribute.
Unlike when used in a book, saved searches are not limited to the sub-hierarchy of a note and allows for advanced queries thanks to the power of the <a class="reference-link" href="../../Navigation/Search.md">Search</a>.
However, there are also some limitations:
* It's not possible to reorder notes.
* It's not possible to add a new row.
Columns are supported, by being defined as <a class="reference-link" href="../../../Advanced%20Usage/Attributes/Promoted%20Attributes.md">Promoted Attributes</a> to the <a class="reference-link" href="../../../Note%20Types/Saved%20Search.md">Saved Search</a> note.
Editing is also supported.

Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

View File

@ -5,4 +5,4 @@ A Note map is a note type which displays a standalone version of the feature of
Once created, the note map will display the relations between notes. Only the notes that are part of the parent of the note map will be displayed (including their children).
The labels `mapIncludeRelation` and `mapExcludeRelation`, if set, filter the note map to include only the specified relations or to exclude the specified relations, respectively.
The labels `mapIncludeRelation` and `mapExcludeRelation`, if set, filter the note map to include only the specified relations or to exclude the specified relations, respectively.

16
pnpm-lock.yaml generated
View File

@ -286,6 +286,9 @@ importers:
svg-pan-zoom:
specifier: 3.6.2
version: 3.6.2
tabulator-tables:
specifier: 6.3.1
version: 6.3.1
vanilla-js-wheel-zoom:
specifier: 9.0.4
version: 9.0.4
@ -308,6 +311,9 @@ importers:
'@types/mark.js':
specifier: 8.11.12
version: 8.11.12
'@types/tabulator-tables':
specifier: 6.2.6
version: 6.2.6
copy-webpack-plugin:
specifier: 13.0.0
version: 13.0.0(webpack@5.99.9(@swc/core@1.11.29(@swc/helpers@0.5.17))(esbuild@0.25.5))
@ -5606,6 +5612,9 @@ packages:
'@types/swagger-ui@5.21.1':
resolution: {integrity: sha512-DUmUH59eeOtvAqcWwBduH2ws0cc5i95KHsXCS4FsOfbUq/clW8TN+HqRBj7q5p9MSsSNK43RziIGItNbrAGLxg==}
'@types/tabulator-tables@6.2.6':
resolution: {integrity: sha512-A+2VrqDluI6hNw5dQl1Z7b8pjQfAE62+3Kj0cFfenWzj0T0ewMicPrpPINHL7ASqz9u9FTDn1Mz1Ige2tF4Wlw==}
'@types/tmp@0.2.6':
resolution: {integrity: sha512-chhaNf2oKHlRkDGt+tiKE2Z5aJ6qalm7Z9rlLdBwmOiAAf09YQvvoLXjWK4HWPF1xU/fqvMgfNfpVoBscA/tKA==}
@ -12987,6 +12996,9 @@ packages:
resolution: {integrity: sha512-9kY+CygyYM6j02t5YFHbNz2FN5QmYGv9zAjVp4lCDjlCw7amdckXlEt/bjMhUIfj4ThGRE4gCUH5+yGnNuPo5A==}
engines: {node: '>=10.0.0'}
tabulator-tables@6.3.1:
resolution: {integrity: sha512-qFW7kfadtcaISQIibKAIy0f3eeIXUVi8242Vly1iJfMD79kfEGzfczNuPBN/80hDxHzQJXYbmJ8VipI40hQtfA==}
tailwindcss@4.1.11:
resolution: {integrity: sha512-2E9TBm6MDD/xKYe+dvJZAmg3yxIEDNRc0jwlNyDg/4Fil2QcSLjFKGVff0lAf1jjeaArlG/M75Ey/EYr/OJtBA==}
@ -20461,6 +20473,8 @@ snapshots:
'@types/swagger-ui@5.21.1': {}
'@types/tabulator-tables@6.2.6': {}
'@types/tmp@0.2.6': {}
'@types/tough-cookie@4.0.5': {}
@ -29295,6 +29309,8 @@ snapshots:
string-width: 4.2.3
strip-ansi: 6.0.1
tabulator-tables@6.3.1: {}
tailwindcss@4.1.11: {}
tapable@2.2.1: {}