mirror of
https://github.com/zadam/trilium.git
synced 2025-12-05 06:54:23 +01:00
Merge branch 'main' into feat/add-ocr-capabilities
This commit is contained in:
commit
02980834ad
@ -28,6 +28,8 @@ import TouchBarComponent from "./touch_bar.js";
|
||||
import type { CKTextEditor } from "@triliumnext/ckeditor5";
|
||||
import type CodeMirror from "@triliumnext/codemirror";
|
||||
import { StartupChecks } from "./startup_checks.js";
|
||||
import type { CreateNoteOpts } from "../services/note_create.js";
|
||||
import { ColumnComponent } from "tabulator-tables";
|
||||
|
||||
interface Layout {
|
||||
getRootWidget: (appContext: AppContext) => RootWidget;
|
||||
@ -276,6 +278,17 @@ export type CommandMappings = {
|
||||
|
||||
geoMapCreateChildNote: CommandData;
|
||||
|
||||
// Table view
|
||||
addNewRow: CommandData & {
|
||||
customOpts: CreateNoteOpts;
|
||||
parentNotePath?: string;
|
||||
};
|
||||
addNewTableColumn: CommandData & {
|
||||
columnToEdit?: ColumnComponent;
|
||||
referenceColumn?: ColumnComponent;
|
||||
direction?: "before" | "after";
|
||||
};
|
||||
|
||||
buildTouchBar: CommandData & {
|
||||
TouchBar: typeof TouchBar;
|
||||
buildIcon(name: string): NativeImage;
|
||||
|
||||
@ -256,6 +256,15 @@ class FNote {
|
||||
return this.children;
|
||||
}
|
||||
|
||||
async getSubtreeNoteIds() {
|
||||
let noteIds: (string | string[])[] = [];
|
||||
for (const child of await this.getChildNotes()) {
|
||||
noteIds.push(child.noteId);
|
||||
noteIds.push(await child.getSubtreeNoteIds());
|
||||
}
|
||||
return noteIds.flat();
|
||||
}
|
||||
|
||||
async getChildNotes() {
|
||||
return await this.froca.getNotes(this.children);
|
||||
}
|
||||
|
||||
@ -6,33 +6,18 @@ 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 ArgsWithoutNoteId = Omit<ViewModeArgs, "noteIds">;
|
||||
export type ViewTypeOptions = "list" | "grid" | "calendar" | "table" | "geoMap";
|
||||
|
||||
export default class NoteListRenderer {
|
||||
|
||||
private viewType: ViewTypeOptions;
|
||||
public viewMode: ViewMode<any> | null;
|
||||
private args: ArgsWithoutNoteId;
|
||||
public viewMode?: ViewMode<any>;
|
||||
|
||||
constructor(args: ViewModeArgs) {
|
||||
constructor(args: ArgsWithoutNoteId) {
|
||||
this.args = args;
|
||||
this.viewType = this.#getViewType(args.parentNote);
|
||||
|
||||
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;
|
||||
case "geoMap":
|
||||
this.viewMode = new GeoView(args);
|
||||
break;
|
||||
default:
|
||||
this.viewMode = null;
|
||||
}
|
||||
}
|
||||
|
||||
#getViewType(parentNote: FNote): ViewTypeOptions {
|
||||
@ -47,15 +32,36 @@ export default class NoteListRenderer {
|
||||
}
|
||||
|
||||
get isFullHeight() {
|
||||
return this.viewMode?.isFullHeight;
|
||||
switch (this.viewType) {
|
||||
case "list":
|
||||
case "grid":
|
||||
return false;
|
||||
default:
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
async renderList() {
|
||||
if (!this.viewMode) {
|
||||
return null;
|
||||
}
|
||||
const args = this.args;
|
||||
const viewMode = this.#buildViewMode(args);
|
||||
this.viewMode = viewMode;
|
||||
await viewMode.beforeRender();
|
||||
return await viewMode.renderList();
|
||||
}
|
||||
|
||||
return await this.viewMode.renderList();
|
||||
#buildViewMode(args: ViewModeArgs) {
|
||||
switch (this.viewType) {
|
||||
case "calendar":
|
||||
return new CalendarView(args);
|
||||
case "table":
|
||||
return new TableView(args);
|
||||
case "geoMap":
|
||||
return new GeoView(args);
|
||||
case "list":
|
||||
case "grid":
|
||||
default:
|
||||
return new ListOrGridView(this.viewType, args);
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@ -1967,7 +1967,11 @@
|
||||
"hide-column": "Hide column \"{{title}}\"",
|
||||
"show-hide-columns": "Show/hide columns",
|
||||
"row-insert-above": "Insert row above",
|
||||
"row-insert-below": "Insert row below"
|
||||
"row-insert-below": "Insert row below",
|
||||
"row-insert-child": "Insert child note",
|
||||
"add-column-to-the-left": "Add column to the left",
|
||||
"add-column-to-the-right": "Add column to the right",
|
||||
"edit-column": "Edit column"
|
||||
},
|
||||
"book_properties_config": {
|
||||
"hide-weekends": "Hide weekends",
|
||||
|
||||
@ -295,6 +295,7 @@ interface AttributeDetailOpts {
|
||||
x: number;
|
||||
y: number;
|
||||
focus?: "name";
|
||||
parent?: HTMLElement;
|
||||
}
|
||||
|
||||
interface SearchRelatedResponse {
|
||||
@ -560,19 +561,22 @@ export default class AttributeDetailWidget extends NoteContextAwareWidget {
|
||||
|
||||
this.toggleInt(true);
|
||||
|
||||
const offset = this.parent?.$widget.offset() || { top: 0, left: 0 };
|
||||
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 (!detPosition || !outerHeight || !height) {
|
||||
console.warn("Can't position popup, is it attached?");
|
||||
return;
|
||||
}
|
||||
|
||||
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");
|
||||
}
|
||||
|
||||
@ -426,7 +426,7 @@ export default class AttributeEditorWidget extends NoteContextAwareWidget implem
|
||||
curNode = curNode.previousSibling;
|
||||
|
||||
if ((curNode as ModelElement).name === "reference") {
|
||||
clickIndex += (curNode.getAttribute("notePath") as string).length + 1;
|
||||
clickIndex += (curNode.getAttribute("href") as string).length + 1;
|
||||
} else if ("data" in curNode) {
|
||||
clickIndex += (curNode.data as string).length;
|
||||
}
|
||||
|
||||
@ -3,8 +3,6 @@ import NoteListRenderer from "../services/note_list_renderer.js";
|
||||
import type FNote from "../entities/fnote.js";
|
||||
import type { CommandListener, CommandListenerData, CommandMappings, CommandNames, EventData, EventNames } 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">
|
||||
@ -39,7 +37,6 @@ export default class NoteListWidget extends NoteContextAwareWidget {
|
||||
private noteIdRefreshed?: string;
|
||||
private shownNoteId?: string | null;
|
||||
private viewMode?: ViewMode<any> | null;
|
||||
private attributeDetailWidget: AttributeDetailWidget;
|
||||
private displayOnlyCollections: boolean;
|
||||
|
||||
/**
|
||||
@ -47,9 +44,7 @@ export default class NoteListWidget extends NoteContextAwareWidget {
|
||||
*/
|
||||
constructor(displayOnlyCollections: boolean) {
|
||||
super();
|
||||
this.attributeDetailWidget = new AttributeDetailWidget()
|
||||
.contentSized()
|
||||
.setParent(this);
|
||||
|
||||
this.displayOnlyCollections = displayOnlyCollections;
|
||||
}
|
||||
|
||||
@ -72,7 +67,6 @@ 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) => {
|
||||
@ -91,23 +85,6 @@ 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);
|
||||
@ -123,8 +100,7 @@ export default class NoteListWidget extends NoteContextAwareWidget {
|
||||
const noteListRenderer = new NoteListRenderer({
|
||||
$parent: this.$content,
|
||||
parentNote: note,
|
||||
parentNotePath: this.notePath,
|
||||
noteIds: note.getChildNoteIds()
|
||||
parentNotePath: this.notePath
|
||||
});
|
||||
this.$widget.toggleClass("full-height", noteListRenderer.isFullHeight);
|
||||
await noteListRenderer.renderList();
|
||||
@ -169,12 +145,6 @@ export default class NoteListWidget extends NoteContextAwareWidget {
|
||||
this.refresh();
|
||||
this.checkRenderStatus();
|
||||
}
|
||||
|
||||
// Inform the view mode of changes and refresh if needed.
|
||||
if (this.viewMode && this.viewMode.onEntitiesReloaded(e)) {
|
||||
this.refresh();
|
||||
this.checkRenderStatus();
|
||||
}
|
||||
}
|
||||
|
||||
buildTouchBarCommand(data: CommandListenerData<"buildTouchBar">) {
|
||||
|
||||
@ -68,7 +68,6 @@ export default class SearchResultWidget extends NoteContextAwareWidget {
|
||||
const noteListRenderer = new NoteListRenderer({
|
||||
$parent: this.$content,
|
||||
parentNote: note,
|
||||
noteIds: note.getChildNoteIds(),
|
||||
showNotePath: true
|
||||
});
|
||||
await noteListRenderer.renderList();
|
||||
|
||||
@ -59,7 +59,7 @@ async function handleContentUpdate(affectedNoteIds: string[]) {
|
||||
const templateNoteIds = new Set(templateCache.keys());
|
||||
const affectedTemplateNoteIds = templateNoteIds.intersection(updatedNoteIds);
|
||||
|
||||
await froca.getNotes(affectedNoteIds);
|
||||
await froca.getNotes(affectedNoteIds, true);
|
||||
|
||||
let fullReloadNeeded = false;
|
||||
for (const affectedTemplateNoteId of affectedTemplateNoteIds) {
|
||||
|
||||
@ -265,7 +265,12 @@ export default class EditableTextTypeWidget extends AbstractTextTypeWidget {
|
||||
}
|
||||
|
||||
focus() {
|
||||
this.$editor.trigger("focus");
|
||||
const editor = this.watchdog.editor;
|
||||
if (editor) {
|
||||
editor.editing.view.focus();
|
||||
} else {
|
||||
this.$editor.trigger("focus");
|
||||
}
|
||||
}
|
||||
|
||||
scrollToEnd() {
|
||||
|
||||
@ -71,6 +71,15 @@ export default abstract class TypeWidget extends NoteContextAwareWidget {
|
||||
}
|
||||
}
|
||||
|
||||
activeNoteChangedEvent() {
|
||||
if (!this.isActiveNoteContext()) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Restore focus to the editor when switching tabs.
|
||||
this.focus();
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*
|
||||
|
||||
@ -113,7 +113,6 @@ export default class CalendarView extends ViewMode<{}> {
|
||||
|
||||
private $root: JQuery<HTMLElement>;
|
||||
private $calendarContainer: JQuery<HTMLElement>;
|
||||
private noteIds: string[];
|
||||
private calendar?: Calendar;
|
||||
private isCalendarRoot: boolean;
|
||||
private lastView?: string;
|
||||
@ -124,15 +123,10 @@ export default class CalendarView extends ViewMode<{}> {
|
||||
|
||||
this.$root = $(TPL);
|
||||
this.$calendarContainer = this.$root.find(".calendar-container");
|
||||
this.noteIds = args.noteIds;
|
||||
this.isCalendarRoot = false;
|
||||
args.$parent.append(this.$root);
|
||||
}
|
||||
|
||||
get isFullHeight(): boolean {
|
||||
return true;
|
||||
}
|
||||
|
||||
async renderList(): Promise<JQuery<HTMLElement> | undefined> {
|
||||
this.isCalendarRoot = this.parentNote.hasLabel("calendarRoot") || this.parentNote.hasLabel("workspaceCalendarRoot");
|
||||
const isEditable = !this.isCalendarRoot;
|
||||
@ -396,7 +390,7 @@ export default class CalendarView extends ViewMode<{}> {
|
||||
}
|
||||
}
|
||||
|
||||
onEntitiesReloaded({ loadResults }: EventData<"entitiesReloaded">) {
|
||||
async onEntitiesReloaded({ loadResults }: EventData<"entitiesReloaded">) {
|
||||
// Refresh note IDs if they got changed.
|
||||
if (loadResults.getBranchRows().some((branch) => branch.parentNoteId === this.parentNote.noteId)) {
|
||||
this.noteIds = this.parentNote.getChildNoteIds();
|
||||
@ -438,7 +432,7 @@ export default class CalendarView extends ViewMode<{}> {
|
||||
events.push(await CalendarView.buildEvent(dateNote, { startDate }));
|
||||
|
||||
if (dateNote.hasChildren()) {
|
||||
const childNoteIds = dateNote.getChildNoteIds();
|
||||
const childNoteIds = await dateNote.getSubtreeNoteIds();
|
||||
for (const childNoteId of childNoteIds) {
|
||||
childNoteToDateMapping[childNoteId] = startDate;
|
||||
}
|
||||
@ -464,13 +458,6 @@ export default class CalendarView extends ViewMode<{}> {
|
||||
for (const note of notes) {
|
||||
const startDate = CalendarView.#getCustomisableLabel(note, "startDate", "calendar:startDate");
|
||||
|
||||
if (note.hasChildren()) {
|
||||
const childrenEventData = await this.buildEvents(note.getChildNoteIds());
|
||||
if (childrenEventData.length > 0) {
|
||||
events.push(childrenEventData);
|
||||
}
|
||||
}
|
||||
|
||||
if (!startDate) {
|
||||
continue;
|
||||
}
|
||||
|
||||
@ -243,10 +243,6 @@ export default class GeoView extends ViewMode<MapData> {
|
||||
}
|
||||
}
|
||||
|
||||
get isFullHeight(): boolean {
|
||||
return true;
|
||||
}
|
||||
|
||||
#changeState(newState: State) {
|
||||
this._state = newState;
|
||||
this.$container.toggleClass("placing-note", newState === State.NewNote);
|
||||
@ -255,7 +251,7 @@ export default class GeoView extends ViewMode<MapData> {
|
||||
}
|
||||
}
|
||||
|
||||
onEntitiesReloaded({ loadResults }: EventData<"entitiesReloaded">): boolean | void {
|
||||
async onEntitiesReloaded({ loadResults }: EventData<"entitiesReloaded">) {
|
||||
// If any of the children branches are altered.
|
||||
if (loadResults.getBranchRows().find((branch) => branch.parentNoteId === this.parentNote.noteId)) {
|
||||
this.#reloadMarkers();
|
||||
|
||||
@ -161,7 +161,7 @@ const TPL = /*html*/`
|
||||
class ListOrGridView extends ViewMode<{}> {
|
||||
private $noteList: JQuery<HTMLElement>;
|
||||
|
||||
private noteIds: string[];
|
||||
private filteredNoteIds!: string[];
|
||||
private page?: number;
|
||||
private pageSize?: number;
|
||||
private showNotePath?: boolean;
|
||||
@ -174,13 +174,6 @@ class ListOrGridView extends ViewMode<{}> {
|
||||
super(args, viewType);
|
||||
this.$noteList = $(TPL);
|
||||
|
||||
const includedNoteIds = this.getIncludedNoteIds();
|
||||
|
||||
this.noteIds = args.noteIds.filter((noteId) => !includedNoteIds.has(noteId) && noteId !== "_hidden");
|
||||
|
||||
if (this.noteIds.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
args.$parent.append(this.$noteList);
|
||||
|
||||
@ -204,8 +197,14 @@ class ListOrGridView extends ViewMode<{}> {
|
||||
return new Set(includedLinks.map((rel) => rel.value));
|
||||
}
|
||||
|
||||
async beforeRender() {
|
||||
super.beforeRender();
|
||||
const includedNoteIds = this.getIncludedNoteIds();
|
||||
this.filteredNoteIds = this.noteIds.filter((noteId) => !includedNoteIds.has(noteId) && noteId !== "_hidden");
|
||||
}
|
||||
|
||||
async renderList() {
|
||||
if (this.noteIds.length === 0 || !this.page || !this.pageSize) {
|
||||
if (this.filteredNoteIds.length === 0 || !this.page || !this.pageSize) {
|
||||
this.$noteList.hide();
|
||||
return;
|
||||
}
|
||||
@ -226,7 +225,7 @@ class ListOrGridView extends ViewMode<{}> {
|
||||
const startIdx = (this.page - 1) * this.pageSize;
|
||||
const endIdx = startIdx + this.pageSize;
|
||||
|
||||
const pageNoteIds = this.noteIds.slice(startIdx, Math.min(endIdx, this.noteIds.length));
|
||||
const pageNoteIds = this.filteredNoteIds.slice(startIdx, Math.min(endIdx, this.filteredNoteIds.length));
|
||||
const pageNotes = await froca.getNotes(pageNoteIds);
|
||||
|
||||
for (const note of pageNotes) {
|
||||
@ -246,7 +245,7 @@ class ListOrGridView extends ViewMode<{}> {
|
||||
return;
|
||||
}
|
||||
|
||||
const pageCount = Math.ceil(this.noteIds.length / this.pageSize);
|
||||
const pageCount = Math.ceil(this.filteredNoteIds.length / this.pageSize);
|
||||
|
||||
$pager.toggle(pageCount > 1);
|
||||
|
||||
@ -257,7 +256,7 @@ class ListOrGridView extends ViewMode<{}> {
|
||||
lastPrinted = true;
|
||||
|
||||
const startIndex = (i - 1) * this.pageSize + 1;
|
||||
const endIndex = Math.min(this.noteIds.length, i * this.pageSize);
|
||||
const endIndex = Math.min(this.filteredNoteIds.length, i * this.pageSize);
|
||||
|
||||
$pager.append(
|
||||
i === this.page
|
||||
@ -279,7 +278,7 @@ class ListOrGridView extends ViewMode<{}> {
|
||||
}
|
||||
|
||||
// no need to distinguish "note" vs "notes" since in case of one result, there's no paging at all
|
||||
$pager.append(`<span class="note-list-pager-total-count">(${this.noteIds.length} notes)</span>`);
|
||||
$pager.append(`<span class="note-list-pager-total-count">(${this.filteredNoteIds.length} notes)</span>`);
|
||||
}
|
||||
|
||||
async renderNote(note: FNote, expand: boolean = false) {
|
||||
|
||||
104
apps/client/src/widgets/view_widgets/table_view/col_editing.ts
Normal file
104
apps/client/src/widgets/view_widgets/table_view/col_editing.ts
Normal file
@ -0,0 +1,104 @@
|
||||
import { Tabulator } from "tabulator-tables";
|
||||
import AttributeDetailWidget from "../../attribute_widgets/attribute_detail";
|
||||
import { Attribute } from "../../../services/attribute_parser";
|
||||
import Component from "../../../components/component";
|
||||
import { CommandListenerData, EventData } from "../../../components/app_context";
|
||||
import attributes from "../../../services/attributes";
|
||||
import FNote from "../../../entities/fnote";
|
||||
|
||||
export default class TableColumnEditing extends Component {
|
||||
|
||||
private attributeDetailWidget: AttributeDetailWidget;
|
||||
private newAttributePosition?: number;
|
||||
private api: Tabulator;
|
||||
private newAttribute?: Attribute;
|
||||
private parentNote: FNote;
|
||||
|
||||
constructor($parent: JQuery<HTMLElement>, parentNote: FNote, api: Tabulator) {
|
||||
super();
|
||||
const parentComponent = glob.getComponentByEl($parent[0]);
|
||||
this.attributeDetailWidget = new AttributeDetailWidget()
|
||||
.contentSized()
|
||||
.setParent(parentComponent);
|
||||
$parent.append(this.attributeDetailWidget.render());
|
||||
this.api = api;
|
||||
this.parentNote = parentNote;
|
||||
}
|
||||
|
||||
addNewTableColumnCommand({ referenceColumn, columnToEdit, direction }: EventData<"addNewTableColumn">) {
|
||||
let attr: Attribute | undefined;
|
||||
|
||||
if (columnToEdit) {
|
||||
attr = this.getAttributeFromField(columnToEdit.getField());
|
||||
console.log("Built ", attr);
|
||||
}
|
||||
|
||||
if (!attr) {
|
||||
attr = {
|
||||
type: "label",
|
||||
name: "label:myLabel",
|
||||
value: "promoted,single,text"
|
||||
};
|
||||
}
|
||||
|
||||
if (referenceColumn && this.api) {
|
||||
this.newAttributePosition = this.api.getColumns().indexOf(referenceColumn);
|
||||
|
||||
if (direction === "after") {
|
||||
this.newAttributePosition++;
|
||||
}
|
||||
} else {
|
||||
this.newAttributePosition = undefined;
|
||||
}
|
||||
|
||||
this.attributeDetailWidget!.showAttributeDetail({
|
||||
attribute: attr,
|
||||
allAttributes: [ attr ],
|
||||
isOwned: true,
|
||||
x: 0,
|
||||
y: 150,
|
||||
focus: "name"
|
||||
});
|
||||
}
|
||||
|
||||
async updateAttributeListCommand({ attributes }: CommandListenerData<"updateAttributeList">) {
|
||||
this.newAttribute = attributes[0];
|
||||
}
|
||||
|
||||
async saveAttributesCommand() {
|
||||
if (!this.newAttribute) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { name, value } = this.newAttribute;
|
||||
attributes.setLabel(this.parentNote.noteId, name, value);
|
||||
}
|
||||
|
||||
getNewAttributePosition() {
|
||||
return this.newAttributePosition;
|
||||
}
|
||||
|
||||
resetNewAttributePosition() {
|
||||
this.newAttributePosition = 0;
|
||||
}
|
||||
|
||||
getFAttributeFromField(field: string) {
|
||||
const [ type, name ] = field.split(".", 2);
|
||||
const attrName = `${type.replace("s", "")}:${name}`;
|
||||
return this.parentNote.getLabel(attrName);
|
||||
}
|
||||
|
||||
getAttributeFromField(field: string) {
|
||||
const fAttribute = this.getFAttributeFromField(field);
|
||||
if (fAttribute) {
|
||||
return {
|
||||
name: fAttribute.name,
|
||||
value: fAttribute.value,
|
||||
type: fAttribute.type
|
||||
};
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@ -0,0 +1,81 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { restoreExistingData } from "./columns";
|
||||
import type { ColumnDefinition } from "tabulator-tables";
|
||||
|
||||
describe("restoreExistingData", () => {
|
||||
it("maintains important columns properties", () => {
|
||||
const newDefs: ColumnDefinition[] = [
|
||||
{ field: "title", title: "Title", editor: "input" },
|
||||
{ field: "noteId", title: "Note ID", formatter: "color", visible: false }
|
||||
];
|
||||
const oldDefs: ColumnDefinition[] = [
|
||||
{ field: "title", title: "Title", width: 300, visible: true },
|
||||
{ field: "noteId", title: "Note ID", width: 200, visible: true }
|
||||
];
|
||||
const restored = restoreExistingData(newDefs, oldDefs);
|
||||
expect(restored[0].editor).toBe("input");
|
||||
expect(restored[1].formatter).toBe("color");
|
||||
});
|
||||
|
||||
it("should restore existing column data", () => {
|
||||
const newDefs: ColumnDefinition[] = [
|
||||
{ field: "title", title: "Title", editor: "input" },
|
||||
{ field: "noteId", title: "Note ID", visible: false }
|
||||
];
|
||||
const oldDefs: ColumnDefinition[] = [
|
||||
{ field: "title", title: "Title", width: 300, visible: true },
|
||||
{ field: "noteId", title: "Note ID", width: 200, visible: true }
|
||||
];
|
||||
const restored = restoreExistingData(newDefs, oldDefs);
|
||||
expect(restored[0].width).toBe(300);
|
||||
expect(restored[1].width).toBe(200);
|
||||
});
|
||||
|
||||
it("restores order of columns", () => {
|
||||
const newDefs: ColumnDefinition[] = [
|
||||
{ field: "title", title: "Title", editor: "input" },
|
||||
{ field: "noteId", title: "Note ID", visible: false }
|
||||
];
|
||||
const oldDefs: ColumnDefinition[] = [
|
||||
{ field: "noteId", title: "Note ID", width: 200, visible: true },
|
||||
{ field: "title", title: "Title", width: 300, visible: true }
|
||||
];
|
||||
const restored = restoreExistingData(newDefs, oldDefs);
|
||||
expect(restored[0].field).toBe("noteId");
|
||||
expect(restored[1].field).toBe("title");
|
||||
});
|
||||
|
||||
it("inserts new columns at given position", () => {
|
||||
const newDefs: ColumnDefinition[] = [
|
||||
{ field: "title", title: "Title", editor: "input" },
|
||||
{ field: "noteId", title: "Note ID", visible: false },
|
||||
{ field: "newColumn", title: "New Column", editor: "input" }
|
||||
];
|
||||
const oldDefs: ColumnDefinition[] = [
|
||||
{ field: "title", title: "Title", width: 300, visible: true },
|
||||
{ field: "noteId", title: "Note ID", width: 200, visible: true }
|
||||
];
|
||||
const restored = restoreExistingData(newDefs, oldDefs, 0);
|
||||
expect(restored.length).toBe(3);
|
||||
expect(restored[0].field).toBe("newColumn");
|
||||
expect(restored[1].field).toBe("title");
|
||||
expect(restored[2].field).toBe("noteId");
|
||||
});
|
||||
|
||||
it("inserts new columns at the end if no position is specified", () => {
|
||||
const newDefs: ColumnDefinition[] = [
|
||||
{ field: "title", title: "Title", editor: "input" },
|
||||
{ field: "noteId", title: "Note ID", visible: false },
|
||||
{ field: "newColumn", title: "New Column", editor: "input" }
|
||||
];
|
||||
const oldDefs: ColumnDefinition[] = [
|
||||
{ field: "title", title: "Title", width: 300, visible: true },
|
||||
{ field: "noteId", title: "Note ID", width: 200, visible: true }
|
||||
];
|
||||
const restored = restoreExistingData(newDefs, oldDefs);
|
||||
expect(restored.length).toBe(3);
|
||||
expect(restored[0].field).toBe("title");
|
||||
expect(restored[1].field).toBe("noteId");
|
||||
expect(restored[2].field).toBe("newColumn");
|
||||
});
|
||||
});
|
||||
@ -1,12 +1,11 @@
|
||||
import { RelationEditor } from "./relation_editor.js";
|
||||
import { NoteFormatter, NoteTitleFormatter, RowNumberFormatter } from "./formatters.js";
|
||||
import { applyHeaderMenu } from "./header-menu.js";
|
||||
import { MonospaceFormatter, NoteFormatter, NoteTitleFormatter, RowNumberFormatter } from "./formatters.js";
|
||||
import type { ColumnDefinition } from "tabulator-tables";
|
||||
import { LabelType } from "../../../services/promoted_attribute_definition_parser.js";
|
||||
|
||||
type ColumnType = LabelType | "relation";
|
||||
|
||||
export interface PromotedAttributeInformation {
|
||||
export interface AttributeDefinitionInformation {
|
||||
name: string;
|
||||
title?: string;
|
||||
type?: ColumnType;
|
||||
@ -42,20 +41,21 @@ const labelTypeMappings: Record<ColumnType, Partial<ColumnDefinition>> = {
|
||||
}
|
||||
};
|
||||
|
||||
export function buildColumnDefinitions(info: PromotedAttributeInformation[], existingColumnData?: ColumnDefinition[]) {
|
||||
const columnDefs: ColumnDefinition[] = [
|
||||
export function buildColumnDefinitions(info: AttributeDefinitionInformation[], movableRows: boolean, existingColumnData?: ColumnDefinition[], position?: number) {
|
||||
let columnDefs: ColumnDefinition[] = [
|
||||
{
|
||||
title: "#",
|
||||
headerSort: false,
|
||||
hozAlign: "center",
|
||||
resizable: false,
|
||||
frozen: true,
|
||||
rowHandle: true,
|
||||
formatter: RowNumberFormatter
|
||||
rowHandle: movableRows,
|
||||
formatter: RowNumberFormatter(movableRows)
|
||||
},
|
||||
{
|
||||
field: "noteId",
|
||||
title: "Note ID",
|
||||
formatter: MonospaceFormatter,
|
||||
visible: false
|
||||
},
|
||||
{
|
||||
@ -86,27 +86,41 @@ export function buildColumnDefinitions(info: PromotedAttributeInformation[], exi
|
||||
seenFields.add(field);
|
||||
}
|
||||
|
||||
applyHeaderMenu(columnDefs);
|
||||
if (existingColumnData) {
|
||||
restoreExistingData(columnDefs, existingColumnData);
|
||||
columnDefs = restoreExistingData(columnDefs, existingColumnData, position);
|
||||
}
|
||||
|
||||
return columnDefs;
|
||||
}
|
||||
|
||||
function restoreExistingData(newDefs: ColumnDefinition[], oldDefs: ColumnDefinition[]) {
|
||||
const byField = new Map<string, ColumnDefinition>;
|
||||
for (const def of oldDefs) {
|
||||
byField.set(def.field ?? "", def);
|
||||
}
|
||||
export function restoreExistingData(newDefs: ColumnDefinition[], oldDefs: ColumnDefinition[], position?: number) {
|
||||
// 1. Keep existing columns, but restore their properties like width, visibility and order.
|
||||
const newItemsByField = new Map<string, ColumnDefinition>(
|
||||
newDefs.map(def => [def.field!, def])
|
||||
);
|
||||
const existingColumns = oldDefs
|
||||
.map(item => {
|
||||
return {
|
||||
...newItemsByField.get(item.field!),
|
||||
width: item.width,
|
||||
visible: item.visible,
|
||||
};
|
||||
}) as ColumnDefinition[];
|
||||
|
||||
for (const newDef of newDefs) {
|
||||
const oldDef = byField.get(newDef.field ?? "");
|
||||
if (!oldDef) {
|
||||
continue;
|
||||
}
|
||||
// 2. Determine new columns.
|
||||
const existingFields = new Set(existingColumns.map(item => item.field));
|
||||
const newColumns = newDefs
|
||||
.filter(item => !existingFields.has(item.field!));
|
||||
|
||||
newDef.width = oldDef.width;
|
||||
newDef.visible = oldDef.visible;
|
||||
}
|
||||
// Clamp position to a valid range
|
||||
const insertPos = position !== undefined
|
||||
? Math.min(Math.max(position, 0), existingColumns.length)
|
||||
: existingColumns.length;
|
||||
|
||||
// 3. Insert new columns at the specified position
|
||||
return [
|
||||
...existingColumns.slice(0, insertPos),
|
||||
...newColumns,
|
||||
...existingColumns.slice(insertPos)
|
||||
];
|
||||
}
|
||||
|
||||
@ -5,10 +5,19 @@ import branches from "../../../services/branches.js";
|
||||
import { t } from "../../../services/i18n.js";
|
||||
import link_context_menu from "../../../menus/link_context_menu.js";
|
||||
import type FNote from "../../../entities/fnote.js";
|
||||
import froca from "../../../services/froca.js";
|
||||
import type Component from "../../../components/component.js";
|
||||
|
||||
export function setupContextMenu(tabulator: Tabulator, parentNote: FNote) {
|
||||
tabulator.on("rowContext", (e, row) => showRowContextMenu(e, row, parentNote));
|
||||
tabulator.on("rowContext", (e, row) => showRowContextMenu(e, row, parentNote, tabulator));
|
||||
tabulator.on("headerContext", (e, col) => showColumnContextMenu(e, col, tabulator));
|
||||
|
||||
// Pressing the expand button prevents bubbling and the context menu remains menu when it shouldn't.
|
||||
if (tabulator.options.dataTree) {
|
||||
const dismissContextMenu = () => contextMenu.hide();
|
||||
tabulator.on("dataTreeRowExpanded", dismissContextMenu);
|
||||
tabulator.on("dataTreeRowCollapsed", dismissContextMenu);
|
||||
}
|
||||
}
|
||||
|
||||
function showColumnContextMenu(_e: UIEvent, column: ColumnComponent, tabulator: Tabulator) {
|
||||
@ -69,6 +78,29 @@ function showColumnContextMenu(_e: UIEvent, column: ColumnComponent, tabulator:
|
||||
uiIcon: "bx bx-empty",
|
||||
items: buildColumnItems()
|
||||
},
|
||||
{ title: "----" },
|
||||
{
|
||||
title: t("table_view.add-column-to-the-left"),
|
||||
uiIcon: "bx bx-horizontal-left",
|
||||
handler: () => getParentComponent(e)?.triggerCommand("addNewTableColumn", {
|
||||
referenceColumn: column
|
||||
})
|
||||
},
|
||||
{
|
||||
title: t("table_view.edit-column"),
|
||||
uiIcon: "bx bx-edit",
|
||||
handler: () => getParentComponent(e)?.triggerCommand("addNewTableColumn", {
|
||||
columnToEdit: column
|
||||
})
|
||||
},
|
||||
{
|
||||
title: t("table_view.add-column-to-the-right"),
|
||||
uiIcon: "bx bx-horizontal-right",
|
||||
handler: () => getParentComponent(e)?.triggerCommand("addNewTableColumn", {
|
||||
referenceColumn: column,
|
||||
direction: "after"
|
||||
})
|
||||
}
|
||||
],
|
||||
selectMenuItemHandler() {},
|
||||
x: e.pageX,
|
||||
@ -79,11 +111,11 @@ function showColumnContextMenu(_e: UIEvent, column: ColumnComponent, tabulator:
|
||||
function buildColumnItems() {
|
||||
const items: MenuItem<unknown>[] = [];
|
||||
for (const column of tabulator.getColumns()) {
|
||||
const { title, visible, field } = column.getDefinition();
|
||||
const { title, field } = column.getDefinition();
|
||||
|
||||
items.push({
|
||||
title,
|
||||
checked: visible,
|
||||
checked: column.isVisible(),
|
||||
uiIcon: "bx bx-empty",
|
||||
enabled: !!field,
|
||||
handler: () => column.toggle()
|
||||
@ -94,9 +126,19 @@ function showColumnContextMenu(_e: UIEvent, column: ColumnComponent, tabulator:
|
||||
}
|
||||
}
|
||||
|
||||
export function showRowContextMenu(_e: UIEvent, row: RowComponent, parentNote: FNote) {
|
||||
export function showRowContextMenu(_e: UIEvent, row: RowComponent, parentNote: FNote, tabulator: Tabulator) {
|
||||
const e = _e as MouseEvent;
|
||||
const rowData = row.getData() as TableData;
|
||||
|
||||
let parentNoteId: string = parentNote.noteId;
|
||||
|
||||
if (tabulator.options.dataTree) {
|
||||
const parentRow = row.getTreeParent();
|
||||
if (parentRow) {
|
||||
parentNoteId = parentRow.getData().noteId as string;
|
||||
}
|
||||
}
|
||||
|
||||
contextMenu.show({
|
||||
items: [
|
||||
...link_context_menu.getItems(),
|
||||
@ -104,16 +146,25 @@ export function showRowContextMenu(_e: UIEvent, row: RowComponent, parentNote: F
|
||||
{
|
||||
title: t("table_view.row-insert-above"),
|
||||
uiIcon: "bx bx-list-plus",
|
||||
handler: () => {
|
||||
const target = e.target;
|
||||
if (!target) {
|
||||
return;
|
||||
handler: () => getParentComponent(e)?.triggerCommand("addNewRow", {
|
||||
parentNotePath: parentNoteId,
|
||||
customOpts: {
|
||||
target: "before",
|
||||
targetBranchId: rowData.branchId,
|
||||
}
|
||||
const component = $(target).closest(".component").prop("component");
|
||||
component.triggerCommand("addNewRow", {
|
||||
})
|
||||
},
|
||||
{
|
||||
title: t("table_view.row-insert-child"),
|
||||
uiIcon: "bx bx-empty",
|
||||
handler: async () => {
|
||||
const branchId = row.getData().branchId;
|
||||
const note = await froca.getBranch(branchId)?.getNote();
|
||||
getParentComponent(e)?.triggerCommand("addNewRow", {
|
||||
parentNotePath: note?.noteId,
|
||||
customOpts: {
|
||||
target: "before",
|
||||
targetBranchId: rowData.branchId,
|
||||
target: "after",
|
||||
targetBranchId: branchId,
|
||||
}
|
||||
});
|
||||
}
|
||||
@ -121,19 +172,13 @@ export function showRowContextMenu(_e: UIEvent, row: RowComponent, parentNote: F
|
||||
{
|
||||
title: t("table_view.row-insert-below"),
|
||||
uiIcon: "bx bx-empty",
|
||||
handler: () => {
|
||||
const target = e.target;
|
||||
if (!target) {
|
||||
return;
|
||||
handler: () => getParentComponent(e)?.triggerCommand("addNewRow", {
|
||||
parentNotePath: parentNoteId,
|
||||
customOpts: {
|
||||
target: "after",
|
||||
targetBranchId: rowData.branchId,
|
||||
}
|
||||
const component = $(target).closest(".component").prop("component");
|
||||
component.triggerCommand("addNewRow", {
|
||||
customOpts: {
|
||||
target: "after",
|
||||
targetBranchId: rowData.branchId,
|
||||
}
|
||||
});
|
||||
}
|
||||
})
|
||||
},
|
||||
{ title: "----" },
|
||||
{
|
||||
@ -148,3 +193,13 @@ export function showRowContextMenu(_e: UIEvent, row: RowComponent, parentNote: F
|
||||
});
|
||||
e.preventDefault();
|
||||
}
|
||||
|
||||
function getParentComponent(e: MouseEvent) {
|
||||
if (!e.target) {
|
||||
return;
|
||||
}
|
||||
|
||||
return $(e.target)
|
||||
.closest(".component")
|
||||
.prop("component") as Component;
|
||||
}
|
||||
|
||||
@ -15,7 +15,7 @@ export default function buildFooter(parentNote: FNote) {
|
||||
<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">
|
||||
<button class="btn btn-sm" style="padding: 0px 10px 0px 10px;" data-trigger-command="addNewTableColumn">
|
||||
<span class="bx bx-columns"></span> ${t("table_view.new-column")}
|
||||
</button>
|
||||
`.trimStart();
|
||||
|
||||
@ -36,8 +36,19 @@ export function NoteTitleFormatter(cell: CellComponent) {
|
||||
return $noteRef[0].outerHTML;
|
||||
}
|
||||
|
||||
export function RowNumberFormatter(cell: CellComponent) {
|
||||
return `<span class="bx bx-dots-vertical-rounded"></span> ` + cell.getRow().getPosition(true);
|
||||
export function RowNumberFormatter(draggableRows: boolean) {
|
||||
return (cell: CellComponent) => {
|
||||
let html = "";
|
||||
if (draggableRows) {
|
||||
html += `<span class="bx bx-dots-vertical-rounded"></span> `;
|
||||
}
|
||||
html += cell.getRow().getPosition(true);
|
||||
return html;
|
||||
};
|
||||
}
|
||||
|
||||
export function MonospaceFormatter(cell: CellComponent) {
|
||||
return `<code>${cell.getValue()}</code>`;
|
||||
}
|
||||
|
||||
function buildNoteLink(noteId: string) {
|
||||
|
||||
@ -1,53 +0,0 @@
|
||||
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;
|
||||
};
|
||||
@ -1,19 +1,17 @@
|
||||
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 attributes from "../../../services/attributes.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, { CreateNoteOpts } from "../../../services/note_create.js";
|
||||
import {Tabulator, SortModule, FormatModule, InteractionModule, EditModule, ResizeColumnsModule, FrozenColumnsModule, PersistenceModule, MoveColumnsModule, MenuModule, MoveRowsModule, ColumnDefinition} from 'tabulator-tables';
|
||||
import type { EventData } from "../../../components/app_context.js";
|
||||
import {Tabulator, SortModule, FormatModule, InteractionModule, EditModule, ResizeColumnsModule, FrozenColumnsModule, PersistenceModule, MoveColumnsModule, MoveRowsModule, ColumnDefinition, DataTreeModule, Options, RowComponent, ColumnComponent} from 'tabulator-tables';
|
||||
import "tabulator-tables/dist/css/tabulator.css";
|
||||
import "../../../../src/stylesheets/table.css";
|
||||
import { canReorderRows, configureReorderingRows } from "./dragging.js";
|
||||
import buildFooter from "./footer.js";
|
||||
import getPromotedAttributeInformation, { buildRowDefinitions } from "./rows.js";
|
||||
import { buildColumnDefinitions } from "./columns.js";
|
||||
import getAttributeDefinitionInformation, { buildRowDefinitions } from "./rows.js";
|
||||
import { AttributeDefinitionInformation, buildColumnDefinitions } from "./columns.js";
|
||||
import { setupContextMenu } from "./context_menu.js";
|
||||
import TableColumnEditing from "./col_editing.js";
|
||||
import TableRowEditing from "./row_editing.js";
|
||||
|
||||
const TPL = /*html*/`
|
||||
<div class="table-view">
|
||||
@ -65,6 +63,26 @@ const TPL = /*html*/`
|
||||
justify-content: left;
|
||||
gap: 0.5em;
|
||||
}
|
||||
|
||||
.tabulator button.tree-expand,
|
||||
.tabulator button.tree-collapse {
|
||||
display: inline-block;
|
||||
appearance: none;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
width: 1.5em;
|
||||
position: relative;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.tabulator button.tree-expand span,
|
||||
.tabulator button.tree-collapse span {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
font-size: 1.5em;
|
||||
transform: translateY(-50%);
|
||||
}
|
||||
</style>
|
||||
|
||||
<div class="table-view-container"></div>
|
||||
@ -81,29 +99,22 @@ 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;
|
||||
private colEditing?: TableColumnEditing;
|
||||
private rowEditing?: TableRowEditing;
|
||||
|
||||
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]);
|
||||
@ -111,29 +122,27 @@ export default class TableView extends ViewMode<StateInfo> {
|
||||
}
|
||||
|
||||
private async renderTable(el: HTMLElement) {
|
||||
const modules = [SortModule, FormatModule, InteractionModule, EditModule, ResizeColumnsModule, FrozenColumnsModule, PersistenceModule, MoveColumnsModule, MoveRowsModule, MenuModule];
|
||||
const info = getAttributeDefinitionInformation(this.parentNote);
|
||||
const modules = [ SortModule, FormatModule, InteractionModule, EditModule, ResizeColumnsModule, FrozenColumnsModule, PersistenceModule, MoveColumnsModule, MoveRowsModule, DataTreeModule ];
|
||||
for (const module of modules) {
|
||||
Tabulator.registerModule(module);
|
||||
}
|
||||
|
||||
this.initialize(el);
|
||||
this.initialize(el, info);
|
||||
}
|
||||
|
||||
private async initialize(el: HTMLElement) {
|
||||
const notes = await froca.getNotes(this.args.noteIds);
|
||||
const info = getPromotedAttributeInformation(this.parentNote);
|
||||
|
||||
private async initialize(el: HTMLElement, info: AttributeDefinitionInformation[]) {
|
||||
const viewStorage = await this.viewStorage.restore();
|
||||
this.persistentData = viewStorage?.tableData || {};
|
||||
|
||||
const columnDefs = buildColumnDefinitions(info);
|
||||
const movableRows = canReorderRows(this.parentNote);
|
||||
|
||||
this.api = new Tabulator(el, {
|
||||
const { definitions: rowData, hasSubtree: hasChildren } = await buildRowDefinitions(this.parentNote, info);
|
||||
const movableRows = canReorderRows(this.parentNote) && !hasChildren;
|
||||
const columnDefs = buildColumnDefinitions(info, movableRows);
|
||||
let opts: Options = {
|
||||
layout: "fitDataFill",
|
||||
index: "noteId",
|
||||
index: "branchId",
|
||||
columns: columnDefs,
|
||||
data: await buildRowDefinitions(this.parentNote, notes, info),
|
||||
data: rowData,
|
||||
persistence: true,
|
||||
movableColumns: true,
|
||||
movableRows,
|
||||
@ -143,10 +152,28 @@ export default class TableView extends ViewMode<StateInfo> {
|
||||
this.spacedUpdate.scheduleUpdate();
|
||||
},
|
||||
persistenceReaderFunc: (_id, type: string) => this.persistentData?.[type],
|
||||
});
|
||||
configureReorderingRows(this.api);
|
||||
};
|
||||
|
||||
if (hasChildren) {
|
||||
opts = {
|
||||
...opts,
|
||||
dataTree: hasChildren,
|
||||
dataTreeStartExpanded: true,
|
||||
dataTreeElementColumn: "title",
|
||||
dataTreeExpandElement: `<button class="tree-expand"><span class="bx bx-chevron-right"></span></button>`,
|
||||
dataTreeCollapseElement: `<button class="tree-collapse"><span class="bx bx-chevron-down"></span></button>`
|
||||
}
|
||||
}
|
||||
|
||||
this.api = new Tabulator(el, opts);
|
||||
|
||||
this.colEditing = new TableColumnEditing(this.args.$parent, this.args.parentNote, this.api);
|
||||
this.rowEditing = new TableRowEditing(this.api, this.args.parentNotePath!);
|
||||
|
||||
if (movableRows) {
|
||||
configureReorderingRows(this.api);
|
||||
}
|
||||
setupContextMenu(this.api, this.parentNote);
|
||||
this.setupEditing();
|
||||
}
|
||||
|
||||
private onSave() {
|
||||
@ -155,86 +182,29 @@ export default class TableView extends ViewMode<StateInfo> {
|
||||
});
|
||||
}
|
||||
|
||||
private setupEditing() {
|
||||
this.api!.on("cellEdited", async (cell) => {
|
||||
const noteId = cell.getRow().getData().noteId;
|
||||
const field = cell.getField();
|
||||
let 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") {
|
||||
if (typeof newValue === "boolean") {
|
||||
newValue = newValue ? "true" : "false";
|
||||
}
|
||||
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({ customOpts }: { customOpts: CreateNoteOpts }) {
|
||||
const parentNotePath = this.args.parentNotePath;
|
||||
if (parentNotePath) {
|
||||
const opts: CreateNoteOpts = {
|
||||
activate: false,
|
||||
...customOpts
|
||||
}
|
||||
console.log("Create with ", opts);
|
||||
note_create.createNote(parentNotePath, opts).then(({ note }) => {
|
||||
if (!note) {
|
||||
return;
|
||||
}
|
||||
this.noteIdToEdit = note.noteId;
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
onEntitiesReloaded({ loadResults }: EventData<"entitiesReloaded">): boolean | void {
|
||||
async onEntitiesReloaded({ loadResults }: EventData<"entitiesReloaded">) {
|
||||
if (!this.api) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Force a refresh if sorted is changed since we need to disable reordering.
|
||||
if (loadResults.getAttributeRows().find(a => a.name === "sorted" && attributes.isAffecting(a, this.parentNote))) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// 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))) {
|
||||
console.log("Col update");
|
||||
this.#manageColumnUpdate();
|
||||
}
|
||||
|
||||
if (loadResults.getBranchRows().some(branch => branch.parentNoteId === this.parentNote.noteId)
|
||||
|| loadResults.getNoteIds().some(noteId => this.args.noteIds.includes(noteId)
|
||||
|| loadResults.getAttributeRows().some(attr => this.args.noteIds.includes(attr.noteId!)))) {
|
||||
this.#manageRowsUpdate();
|
||||
if (loadResults.getBranchRows().some(branch => branch.parentNoteId === this.parentNote.noteId || this.noteIds.includes(branch.parentNoteId ?? ""))
|
||||
|| loadResults.getNoteIds().some(noteId => this.noteIds.includes(noteId)
|
||||
|| loadResults.getAttributeRows().some(attr => this.noteIds.includes(attr.noteId!)))) {
|
||||
return await this.#manageRowsUpdate();
|
||||
}
|
||||
|
||||
return false;
|
||||
@ -245,27 +215,44 @@ export default class TableView extends ViewMode<StateInfo> {
|
||||
return;
|
||||
}
|
||||
|
||||
const info = getPromotedAttributeInformation(this.parentNote);
|
||||
const columnDefs = buildColumnDefinitions(info, this.persistentData?.columns);
|
||||
const info = getAttributeDefinitionInformation(this.parentNote);
|
||||
const columnDefs = buildColumnDefinitions(info, !!this.api.options.movableRows, this.persistentData?.columns, this.colEditing?.getNewAttributePosition());
|
||||
this.api.setColumns(columnDefs);
|
||||
this.colEditing?.resetNewAttributePosition();
|
||||
}
|
||||
|
||||
addNewRowCommand(e) {
|
||||
this.rowEditing?.addNewRowCommand(e);
|
||||
}
|
||||
|
||||
addNewTableColumnCommand(e) {
|
||||
this.colEditing?.addNewTableColumnCommand(e);
|
||||
}
|
||||
|
||||
updateAttributeListCommand(e) {
|
||||
this.colEditing?.updateAttributeListCommand(e);
|
||||
}
|
||||
|
||||
saveAttributesCommand() {
|
||||
this.colEditing?.saveAttributesCommand();
|
||||
}
|
||||
|
||||
|
||||
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));
|
||||
const info = getAttributeDefinitionInformation(this.parentNote);
|
||||
const { definitions, hasSubtree } = await buildRowDefinitions(this.parentNote, 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;
|
||||
// Force a refresh if the data tree needs enabling/disabling.
|
||||
if (this.api.options.dataTree !== hasSubtree) {
|
||||
return true;
|
||||
}
|
||||
|
||||
await this.api.replaceData(definitions);
|
||||
return false;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@ -0,0 +1,97 @@
|
||||
import { RowComponent, Tabulator } from "tabulator-tables";
|
||||
import Component from "../../../components/component.js";
|
||||
import { setAttribute, setLabel } from "../../../services/attributes.js";
|
||||
import server from "../../../services/server.js";
|
||||
import froca from "../../../services/froca.js";
|
||||
import note_create, { CreateNoteOpts } from "../../../services/note_create.js";
|
||||
import { CommandListenerData } from "../../../components/app_context.js";
|
||||
|
||||
export default class TableRowEditing extends Component {
|
||||
|
||||
private parentNotePath: string;
|
||||
private api: Tabulator;
|
||||
|
||||
constructor(api: Tabulator, parentNotePath: string) {
|
||||
super();
|
||||
this.api = api;
|
||||
this.parentNotePath = parentNotePath;
|
||||
api.on("cellEdited", async (cell) => {
|
||||
const noteId = cell.getRow().getData().noteId;
|
||||
const field = cell.getField();
|
||||
let 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") {
|
||||
if (typeof newValue === "boolean") {
|
||||
newValue = newValue ? "true" : "false";
|
||||
}
|
||||
setLabel(noteId, name, newValue);
|
||||
} else if (type === "relations") {
|
||||
const note = await froca.getNote(noteId);
|
||||
if (note) {
|
||||
setAttribute(note, "relation", name, newValue);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
addNewRowCommand({ customOpts, parentNotePath: customNotePath }: CommandListenerData<"addNewRow">) {
|
||||
const parentNotePath = customNotePath ?? this.parentNotePath;
|
||||
if (parentNotePath) {
|
||||
const opts: CreateNoteOpts = {
|
||||
activate: false,
|
||||
...customOpts
|
||||
}
|
||||
note_create.createNote(parentNotePath, opts).then(({ branch }) => {
|
||||
if (branch) {
|
||||
setTimeout(() => {
|
||||
this.focusOnBranch(branch?.branchId);
|
||||
});
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
focusOnBranch(branchId: string) {
|
||||
if (!this.api) {
|
||||
return;
|
||||
}
|
||||
|
||||
const row = findRowDataById(this.api.getRows(), branchId);
|
||||
if (!row) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Expand the parent tree if any.
|
||||
if (this.api.options.dataTree) {
|
||||
const parent = row.getTreeParent();
|
||||
if (parent) {
|
||||
parent.treeExpand();
|
||||
}
|
||||
}
|
||||
|
||||
row.getCell("title").edit();
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
function findRowDataById(rows: RowComponent[], branchId: string): RowComponent | null {
|
||||
for (let row of rows) {
|
||||
const item = row.getIndex() as string;
|
||||
|
||||
if (item === branchId) {
|
||||
return row;
|
||||
}
|
||||
|
||||
let found = findRowDataById(row.getTreeChildren(), branchId);
|
||||
if (found) return found;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
@ -1,6 +1,6 @@
|
||||
import FNote from "../../../entities/fnote.js";
|
||||
import type { LabelType } from "../../../services/promoted_attribute_definition_parser.js";
|
||||
import type { PromotedAttributeInformation } from "./columns.js";
|
||||
import type { AttributeDefinitionInformation } from "./columns.js";
|
||||
|
||||
export type TableData = {
|
||||
iconClass: string;
|
||||
@ -9,10 +9,12 @@ export type TableData = {
|
||||
labels: Record<string, boolean | string | null>;
|
||||
relations: Record<string, boolean | string | null>;
|
||||
branchId: string;
|
||||
_children?: TableData[];
|
||||
};
|
||||
|
||||
export async function buildRowDefinitions(parentNote: FNote, notes: FNote[], infos: PromotedAttributeInformation[]) {
|
||||
export async function buildRowDefinitions(parentNote: FNote, infos: AttributeDefinitionInformation[]) {
|
||||
const definitions: TableData[] = [];
|
||||
let hasSubtree = false;
|
||||
for (const branch of parentNote.getChildBranches()) {
|
||||
const note = await branch.getNote();
|
||||
if (!note) {
|
||||
@ -28,30 +30,43 @@ export async function buildRowDefinitions(parentNote: FNote, notes: FNote[], inf
|
||||
labels[name] = note.getLabelValue(name);
|
||||
}
|
||||
}
|
||||
definitions.push({
|
||||
|
||||
const def: TableData = {
|
||||
iconClass: note.getIcon(),
|
||||
noteId: note.noteId,
|
||||
title: note.title,
|
||||
labels,
|
||||
relations,
|
||||
branchId: branch.branchId
|
||||
});
|
||||
branchId: branch.branchId,
|
||||
}
|
||||
|
||||
if (note.hasChildren()) {
|
||||
def._children = (await buildRowDefinitions(note, infos)).definitions;
|
||||
hasSubtree = true;
|
||||
}
|
||||
|
||||
definitions.push(def);
|
||||
}
|
||||
|
||||
return definitions;
|
||||
return {
|
||||
definitions,
|
||||
hasSubtree
|
||||
};
|
||||
}
|
||||
|
||||
export default function getPromotedAttributeInformation(parentNote: FNote) {
|
||||
const info: PromotedAttributeInformation[] = [];
|
||||
for (const promotedAttribute of parentNote.getPromotedDefinitionAttributes()) {
|
||||
const def = promotedAttribute.getDefinition();
|
||||
export default function getAttributeDefinitionInformation(parentNote: FNote) {
|
||||
const info: AttributeDefinitionInformation[] = [];
|
||||
const attrDefs = parentNote.getAttributes()
|
||||
.filter(attr => attr.isDefinition());
|
||||
for (const attrDef of attrDefs) {
|
||||
const def = attrDef.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") {
|
||||
const [ labelType, name ] = attrDef.name.split(":", 2);
|
||||
if (attrDef.type !== "label") {
|
||||
console.warn("Relations are not supported for now");
|
||||
continue;
|
||||
}
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
import type { EventData } from "../../components/app_context.js";
|
||||
import appContext 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";
|
||||
@ -8,7 +9,6 @@ export interface ViewModeArgs {
|
||||
$parent: JQuery<HTMLElement>;
|
||||
parentNote: FNote;
|
||||
parentNotePath?: string | null;
|
||||
noteIds: string[];
|
||||
showNotePath?: boolean;
|
||||
}
|
||||
|
||||
@ -17,6 +17,8 @@ export default abstract class ViewMode<T extends object> extends Component {
|
||||
private _viewStorage: ViewModeStorage<T> | null;
|
||||
protected parentNote: FNote;
|
||||
protected viewType: ViewTypeOptions;
|
||||
protected noteIds: string[];
|
||||
protected args: ViewModeArgs;
|
||||
|
||||
constructor(args: ViewModeArgs, viewType: ViewTypeOptions) {
|
||||
super();
|
||||
@ -25,6 +27,12 @@ export default abstract class ViewMode<T extends object> extends Component {
|
||||
// note list must be added to the DOM immediately, otherwise some functionality scripting (canvas) won't work
|
||||
args.$parent.empty();
|
||||
this.viewType = viewType;
|
||||
this.args = args;
|
||||
this.noteIds = [];
|
||||
}
|
||||
|
||||
async beforeRender() {
|
||||
await this.#refreshNoteIds();
|
||||
}
|
||||
|
||||
abstract renderList(): Promise<JQuery<HTMLElement> | undefined>;
|
||||
@ -35,13 +43,18 @@ export default abstract class ViewMode<T extends object> extends Component {
|
||||
* @param e the event data.
|
||||
* @return {@code true} if the view should be re-rendered, a falsy value otherwise.
|
||||
*/
|
||||
onEntitiesReloaded(e: EventData<"entitiesReloaded">): boolean | void {
|
||||
async onEntitiesReloaded(e: EventData<"entitiesReloaded">): Promise<boolean | void> {
|
||||
// Do nothing by default.
|
||||
}
|
||||
|
||||
get isFullHeight() {
|
||||
// Override to change its value.
|
||||
return false;
|
||||
async entitiesReloadedEvent(e: EventData<"entitiesReloaded">) {
|
||||
if (e.loadResults.getBranchRows().some(branch => branch.parentNoteId === this.parentNote.noteId || this.noteIds.includes(branch.parentNoteId ?? ""))) {
|
||||
this.#refreshNoteIds();
|
||||
}
|
||||
|
||||
if (await this.onEntitiesReloaded(e)) {
|
||||
appContext.triggerEvent("refreshNoteList", { noteId: this.parentNote.noteId });
|
||||
}
|
||||
}
|
||||
|
||||
get isReadOnly() {
|
||||
@ -57,4 +70,14 @@ export default abstract class ViewMode<T extends object> extends Component {
|
||||
return this._viewStorage;
|
||||
}
|
||||
|
||||
async #refreshNoteIds() {
|
||||
let noteIds: string[];
|
||||
if (this.viewType === "list" || this.viewType === "grid") {
|
||||
noteIds = this.args.parentNote.getChildNoteIds();
|
||||
} else {
|
||||
noteIds = await this.args.parentNote.getSubtreeNoteIds();
|
||||
}
|
||||
this.noteIds = noteIds;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@ -3,12 +3,18 @@ import type { Router } from "express";
|
||||
import fs from "fs";
|
||||
import path from "path";
|
||||
import { RESOURCE_DIR } from "../services/resource_dir";
|
||||
import rateLimit from "express-rate-limit";
|
||||
|
||||
const specPath = path.join(RESOURCE_DIR, "etapi.openapi.yaml");
|
||||
let spec: string | null = null;
|
||||
|
||||
const limiter = rateLimit({
|
||||
windowMs: 15 * 60 * 1000, // 15 minutes
|
||||
max: 100, // limit each IP to 100 requests per windowMs
|
||||
});
|
||||
|
||||
function register(router: Router) {
|
||||
router.get("/etapi/etapi.openapi.yaml", (_, res) => {
|
||||
router.get("/etapi/etapi.openapi.yaml", limiter, (_, res) => {
|
||||
if (!spec) {
|
||||
spec = fs.readFileSync(specPath, "utf8");
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user