mirror of
https://github.com/zadam/trilium.git
synced 2025-10-21 07:38:53 +02:00
Port collections to React (#6837)
This commit is contained in:
commit
0ac2df8102
@ -1,6 +1,6 @@
|
||||
root = true
|
||||
|
||||
[*.{js,ts,.tsx}]
|
||||
[*.{js,ts,tsx}]
|
||||
charset = utf-8
|
||||
end_of_line = lf
|
||||
indent_size = 4
|
||||
|
@ -116,7 +116,7 @@ export type CommandMappings = {
|
||||
openedFileUpdated: CommandData & {
|
||||
entityType: string;
|
||||
entityId: string;
|
||||
lastModifiedMs: number;
|
||||
lastModifiedMs?: number;
|
||||
filePath: string;
|
||||
};
|
||||
focusAndSelectTitle: CommandData & {
|
||||
@ -650,7 +650,7 @@ export class AppContext extends Component {
|
||||
}
|
||||
|
||||
getComponentByEl(el: HTMLElement) {
|
||||
return $(el).closest(".component").prop("component");
|
||||
return $(el).closest("[data-component-id]").prop("component");
|
||||
}
|
||||
|
||||
addBeforeUnloadListener(obj: BeforeUploadListener | (() => boolean)) {
|
||||
|
@ -23,11 +23,11 @@ export default class TouchBarComponent extends Component {
|
||||
this.$widget = $("<div>");
|
||||
|
||||
$(window).on("focusin", async (e) => {
|
||||
const $target = $(e.target);
|
||||
const focusedEl = e.target as unknown as HTMLElement;
|
||||
const $target = $(focusedEl);
|
||||
|
||||
this.$activeModal = $target.closest(".modal-dialog");
|
||||
const parentComponentEl = $target.closest(".component");
|
||||
this.lastFocusedComponent = appContext.getComponentByEl(parentComponentEl[0]);
|
||||
this.lastFocusedComponent = appContext.getComponentByEl(focusedEl);
|
||||
this.#refreshTouchBar();
|
||||
});
|
||||
}
|
||||
|
@ -256,18 +256,20 @@ export default class FNote {
|
||||
return this.children;
|
||||
}
|
||||
|
||||
async getSubtreeNoteIds() {
|
||||
async getSubtreeNoteIds(includeArchived = false) {
|
||||
let noteIds: (string | string[])[] = [];
|
||||
for (const child of await this.getChildNotes()) {
|
||||
if (child.isArchived && !includeArchived) continue;
|
||||
|
||||
noteIds.push(child.noteId);
|
||||
noteIds.push(await child.getSubtreeNoteIds());
|
||||
noteIds.push(await child.getSubtreeNoteIds(includeArchived));
|
||||
}
|
||||
return noteIds.flat();
|
||||
}
|
||||
|
||||
async getSubtreeNotes() {
|
||||
const noteIds = await this.getSubtreeNoteIds();
|
||||
return this.froca.getNotes(noteIds);
|
||||
return (await this.froca.getNotes(noteIds));
|
||||
}
|
||||
|
||||
async getChildNotes() {
|
||||
@ -905,8 +907,8 @@ export default class FNote {
|
||||
return this.getBlob();
|
||||
}
|
||||
|
||||
async getBlob() {
|
||||
return await this.froca.getBlob("notes", this.noteId);
|
||||
getBlob() {
|
||||
return this.froca.getBlob("notes", this.noteId);
|
||||
}
|
||||
|
||||
toString() {
|
||||
|
@ -5,7 +5,6 @@ import NoteTreeWidget from "../widgets/note_tree.js";
|
||||
import NoteTitleWidget from "../widgets/note_title.jsx";
|
||||
import NoteDetailWidget from "../widgets/note_detail.js";
|
||||
import PromotedAttributesWidget from "../widgets/promoted_attributes.js";
|
||||
import NoteListWidget from "../widgets/note_list.js";
|
||||
import NoteIconWidget from "../widgets/note_icon.jsx";
|
||||
import ScrollingContainer from "../widgets/containers/scrolling_container.js";
|
||||
import RootContainer from "../widgets/containers/root_container.js";
|
||||
@ -42,6 +41,7 @@ import LeftPaneToggle from "../widgets/buttons/left_pane_toggle.js";
|
||||
import ApiLog from "../widgets/api_log.jsx";
|
||||
import CloseZenModeButton from "../widgets/close_zen_button.jsx";
|
||||
import SharedInfo from "../widgets/shared_info.jsx";
|
||||
import NoteList from "../widgets/collections/NoteList.jsx";
|
||||
|
||||
export default class DesktopLayout {
|
||||
|
||||
@ -138,7 +138,7 @@ export default class DesktopLayout {
|
||||
.child(new PromotedAttributesWidget())
|
||||
.child(<SqlTableSchemas />)
|
||||
.child(new NoteDetailWidget())
|
||||
.child(new NoteListWidget(false))
|
||||
.child(<NoteList />)
|
||||
.child(<SearchResult />)
|
||||
.child(<SqlResults />)
|
||||
.child(<ScrollPadding />)
|
||||
|
@ -27,10 +27,10 @@ import FlexContainer from "../widgets/containers/flex_container.js";
|
||||
import NoteIconWidget from "../widgets/note_icon";
|
||||
import PromotedAttributesWidget from "../widgets/promoted_attributes.js";
|
||||
import NoteDetailWidget from "../widgets/note_detail.js";
|
||||
import NoteListWidget from "../widgets/note_list.js";
|
||||
import CallToActionDialog from "../widgets/dialogs/call_to_action.jsx";
|
||||
import NoteTitleWidget from "../widgets/note_title.jsx";
|
||||
import { PopupEditorFormattingToolbar } from "../widgets/ribbon/FormattingToolbar.js";
|
||||
import NoteList from "../widgets/collections/NoteList.jsx";
|
||||
|
||||
export function applyModals(rootContainer: RootContainer) {
|
||||
rootContainer
|
||||
@ -66,6 +66,6 @@ export function applyModals(rootContainer: RootContainer) {
|
||||
.child(<PopupEditorFormattingToolbar />)
|
||||
.child(new PromotedAttributesWidget())
|
||||
.child(new NoteDetailWidget())
|
||||
.child(new NoteListWidget(true)))
|
||||
.child(<NoteList displayOnlyCollections />))
|
||||
.child(<CallToActionDialog />);
|
||||
}
|
||||
|
@ -5,7 +5,6 @@ import QuickSearchWidget from "../widgets/quick_search.js";
|
||||
import NoteTreeWidget from "../widgets/note_tree.js";
|
||||
import ScreenContainer from "../widgets/mobile_widgets/screen_container.js";
|
||||
import ScrollingContainer from "../widgets/containers/scrolling_container.js";
|
||||
import NoteListWidget from "../widgets/note_list.js";
|
||||
import GlobalMenuWidget from "../widgets/buttons/global_menu.js";
|
||||
import LauncherContainer from "../widgets/containers/launcher_container.js";
|
||||
import RootContainer from "../widgets/containers/root_container.js";
|
||||
@ -24,6 +23,7 @@ import ToggleSidebarButton from "../widgets/mobile_widgets/toggle_sidebar_button
|
||||
import CloseZenModeButton from "../widgets/close_zen_button.js";
|
||||
import NoteWrapperWidget from "../widgets/note_wrapper.js";
|
||||
import MobileDetailMenu from "../widgets/mobile_widgets/mobile_detail_menu.js";
|
||||
import NoteList from "../widgets/collections/NoteList.jsx";
|
||||
|
||||
const MOBILE_CSS = `
|
||||
<style>
|
||||
@ -154,7 +154,7 @@ export default class MobileLayout {
|
||||
.filling()
|
||||
.contentSized()
|
||||
.child(new NoteDetailWidget())
|
||||
.child(new NoteListWidget(false))
|
||||
.child(<NoteList />)
|
||||
.child(<FilePropertiesWrapper />)
|
||||
)
|
||||
.child(<MobileEditorToolbar />)
|
||||
|
@ -13,6 +13,8 @@ import type NoteTreeWidget from "../widgets/note_tree.js";
|
||||
import type FAttachment from "../entities/fattachment.js";
|
||||
import type { SelectMenuItemEventListener } from "../components/events.js";
|
||||
import utils from "../services/utils.js";
|
||||
import attributes from "../services/attributes.js";
|
||||
import { executeBulkActions } from "../services/bulk_action.js";
|
||||
|
||||
// TODO: Deduplicate once client/server is well split.
|
||||
interface ConvertToAttachmentResponse {
|
||||
@ -61,6 +63,11 @@ export default class TreeContextMenu implements SelectMenuItemEventListener<Tree
|
||||
// the only exception is when the only selected note is the one that was right-clicked, then
|
||||
// it's clear what the user meant to do.
|
||||
const selNodes = this.treeWidget.getSelectedNodes();
|
||||
const selectedNotes = await froca.getNotes(selNodes.map(node => node.data.noteId));
|
||||
if (note && !selectedNotes.includes(note)) selectedNotes.push(note);
|
||||
const isArchived = selectedNotes.every(note => note.isArchived);
|
||||
const canToggleArchived = !selectedNotes.some(note => note.isArchived !== isArchived);
|
||||
|
||||
const noSelectedNotes = selNodes.length === 0 || (selNodes.length === 1 && selNodes[0] === this.node);
|
||||
|
||||
const notSearch = note?.type !== "search";
|
||||
@ -189,6 +196,34 @@ export default class TreeContextMenu implements SelectMenuItemEventListener<Tree
|
||||
enabled: parentNotSearch && isNotRoot && !isHoisted && notOptionsOrHelp
|
||||
},
|
||||
|
||||
{
|
||||
title: !isArchived ? t("tree-context-menu.archive") : t("tree-context-menu.unarchive"),
|
||||
uiIcon: !isArchived ? "bx bx-archive" : "bx bx-archive-out",
|
||||
enabled: canToggleArchived,
|
||||
handler: () => {
|
||||
if (!selectedNotes.length) return;
|
||||
|
||||
if (selectedNotes.length == 1) {
|
||||
const note = selectedNotes[0];
|
||||
if (!isArchived) {
|
||||
attributes.addLabel(note.noteId, "archived");
|
||||
} else {
|
||||
attributes.removeOwnedLabelByName(note, "archived");
|
||||
}
|
||||
} else {
|
||||
const noteIds = selectedNotes.map(note => note.noteId);
|
||||
if (!isArchived) {
|
||||
executeBulkActions(noteIds, [{
|
||||
name: "addLabel", labelName: "archived"
|
||||
}]);
|
||||
} else {
|
||||
executeBulkActions(noteIds, [{
|
||||
name: "deleteLabel", labelName: "archived"
|
||||
}]);
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
title: `${t("tree-context-menu.delete")} <kbd data-command="deleteNotes"></kbd>`,
|
||||
command: "deleteNotes",
|
||||
|
@ -210,7 +210,7 @@ function makeToast(id: string, message: string): ToastOptions {
|
||||
}
|
||||
|
||||
ws.subscribeToMessages(async (message) => {
|
||||
if (message.taskType !== "deleteNotes") {
|
||||
if (!("taskType" in message) || message.taskType !== "deleteNotes") {
|
||||
return;
|
||||
}
|
||||
|
||||
@ -228,7 +228,7 @@ ws.subscribeToMessages(async (message) => {
|
||||
});
|
||||
|
||||
ws.subscribeToMessages(async (message) => {
|
||||
if (message.taskType !== "undeleteNotes") {
|
||||
if (!("taskType" in message) || message.taskType !== "undeleteNotes") {
|
||||
return;
|
||||
}
|
||||
|
||||
|
@ -60,7 +60,7 @@ async function confirmDeleteNoteBoxWithNote(title: string) {
|
||||
return new Promise<ConfirmDialogResult | undefined>((res) => appContext.triggerCommand("showConfirmDeleteNoteBoxWithNoteDialog", { title, callback: res }));
|
||||
}
|
||||
|
||||
async function prompt(props: PromptDialogOptions) {
|
||||
export async function prompt(props: PromptDialogOptions) {
|
||||
return new Promise<string | null>((res) => appContext.triggerCommand("showPromptDialog", { ...props, callback: res }));
|
||||
}
|
||||
|
||||
|
@ -1,16 +1,8 @@
|
||||
import ws from "./ws.js";
|
||||
import appContext from "../components/app_context.js";
|
||||
import { OpenedFileUpdateStatus } from "@triliumnext/commons";
|
||||
|
||||
// TODO: Deduplicate
|
||||
interface Message {
|
||||
type: string;
|
||||
entityType: string;
|
||||
entityId: string;
|
||||
lastModifiedMs: number;
|
||||
filePath: string;
|
||||
}
|
||||
|
||||
const fileModificationStatus: Record<string, Record<string, Message>> = {
|
||||
const fileModificationStatus: Record<string, Record<string, OpenedFileUpdateStatus>> = {
|
||||
notes: {},
|
||||
attachments: {}
|
||||
};
|
||||
@ -39,7 +31,7 @@ function ignoreModification(entityType: string, entityId: string) {
|
||||
delete fileModificationStatus[entityType][entityId];
|
||||
}
|
||||
|
||||
ws.subscribeToMessages(async (message: Message) => {
|
||||
ws.subscribeToMessages(async message => {
|
||||
if (message.type !== "openedFileUpdated") {
|
||||
return;
|
||||
}
|
||||
|
@ -4,6 +4,7 @@ import ws from "./ws.js";
|
||||
import utils from "./utils.js";
|
||||
import appContext from "../components/app_context.js";
|
||||
import { t } from "./i18n.js";
|
||||
import { WebSocketMessage } from "@triliumnext/commons";
|
||||
|
||||
type BooleanLike = boolean | "true" | "false";
|
||||
|
||||
@ -66,7 +67,7 @@ function makeToast(id: string, message: string): ToastOptions {
|
||||
}
|
||||
|
||||
ws.subscribeToMessages(async (message) => {
|
||||
if (message.taskType !== "importNotes") {
|
||||
if (!("taskType" in message) || message.taskType !== "importNotes") {
|
||||
return;
|
||||
}
|
||||
|
||||
@ -87,8 +88,8 @@ ws.subscribeToMessages(async (message) => {
|
||||
}
|
||||
});
|
||||
|
||||
ws.subscribeToMessages(async (message) => {
|
||||
if (message.taskType !== "importAttachments") {
|
||||
ws.subscribeToMessages(async (message: WebSocketMessage) => {
|
||||
if (!("taskType" in message) || message.taskType !== "importAttachments") {
|
||||
return;
|
||||
}
|
||||
|
||||
|
@ -1,6 +1,6 @@
|
||||
import { NoteType } from "@triliumnext/commons";
|
||||
import { ViewTypeOptions } from "./note_list_renderer";
|
||||
import FNote from "../entities/fnote";
|
||||
import { ViewTypeOptions } from "../widgets/collections/interface";
|
||||
|
||||
export const byNoteType: Record<Exclude<NoteType, "book">, string | null> = {
|
||||
canvas: null,
|
||||
|
@ -1,71 +0,0 @@
|
||||
import type FNote from "../entities/fnote.js";
|
||||
import BoardView from "../widgets/view_widgets/board_view/index.js";
|
||||
import CalendarView from "../widgets/view_widgets/calendar_view.js";
|
||||
import GeoView from "../widgets/view_widgets/geo_view/index.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";
|
||||
|
||||
const allViewTypes = ["list", "grid", "calendar", "table", "geoMap", "board"] as const;
|
||||
export type ArgsWithoutNoteId = Omit<ViewModeArgs, "noteIds">;
|
||||
export type ViewTypeOptions = typeof allViewTypes[number];
|
||||
|
||||
export default class NoteListRenderer {
|
||||
|
||||
private viewType: ViewTypeOptions;
|
||||
private args: ArgsWithoutNoteId;
|
||||
public viewMode?: ViewMode<any>;
|
||||
|
||||
constructor(args: ArgsWithoutNoteId) {
|
||||
this.args = args;
|
||||
this.viewType = this.#getViewType(args.parentNote);
|
||||
}
|
||||
|
||||
#getViewType(parentNote: FNote): ViewTypeOptions {
|
||||
const viewType = parentNote.getLabelValue("viewType");
|
||||
|
||||
if (!(allViewTypes as readonly string[]).includes(viewType || "")) {
|
||||
// when not explicitly set, decide based on the note type
|
||||
return parentNote.type === "search" ? "list" : "grid";
|
||||
} else {
|
||||
return viewType as ViewTypeOptions;
|
||||
}
|
||||
}
|
||||
|
||||
get isFullHeight() {
|
||||
switch (this.viewType) {
|
||||
case "list":
|
||||
case "grid":
|
||||
return false;
|
||||
default:
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
async renderList() {
|
||||
const args = this.args;
|
||||
const viewMode = this.#buildViewMode(args);
|
||||
this.viewMode = viewMode;
|
||||
await viewMode.beforeRender();
|
||||
return await 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 "board":
|
||||
return new BoardView(args);
|
||||
case "list":
|
||||
case "grid":
|
||||
default:
|
||||
return new ListOrGridView(this.viewType, args);
|
||||
}
|
||||
}
|
||||
|
||||
}
|
@ -107,11 +107,11 @@ function makeToast(message: Message, title: string, text: string): ToastOptions
|
||||
}
|
||||
|
||||
ws.subscribeToMessages(async (message) => {
|
||||
if (message.taskType !== "protectNotes") {
|
||||
if (!("taskType" in message) || message.taskType !== "protectNotes") {
|
||||
return;
|
||||
}
|
||||
|
||||
const isProtecting = message.data.protect;
|
||||
const isProtecting = message.data?.protect;
|
||||
const title = isProtecting ? t("protected_session.protecting-title") : t("protected_session.unprotecting-title");
|
||||
|
||||
if (message.type === "taskError") {
|
||||
|
@ -47,27 +47,6 @@ function parseDate(str: string) {
|
||||
}
|
||||
}
|
||||
|
||||
// Source: https://stackoverflow.com/a/30465299/4898894
|
||||
function getMonthsInDateRange(startDate: string, endDate: string) {
|
||||
const start = startDate.split("-");
|
||||
const end = endDate.split("-");
|
||||
const startYear = parseInt(start[0]);
|
||||
const endYear = parseInt(end[0]);
|
||||
const dates: string[] = [];
|
||||
|
||||
for (let i = startYear; i <= endYear; i++) {
|
||||
const endMonth = i != endYear ? 11 : parseInt(end[1]) - 1;
|
||||
const startMon = i === startYear ? parseInt(start[1]) - 1 : 0;
|
||||
|
||||
for (let j = startMon; j <= endMonth; j = j > 12 ? j % 12 || 11 : j + 1) {
|
||||
const month = j + 1;
|
||||
const displayMonth = month < 10 ? "0" + month : month;
|
||||
dates.push([i, displayMonth].join("-"));
|
||||
}
|
||||
}
|
||||
return dates;
|
||||
}
|
||||
|
||||
function padNum(num: number) {
|
||||
return `${num <= 9 ? "0" : ""}${num}`;
|
||||
}
|
||||
@ -496,7 +475,7 @@ function sleep(time_ms: number) {
|
||||
});
|
||||
}
|
||||
|
||||
function escapeRegExp(str: string) {
|
||||
export function escapeRegExp(str: string) {
|
||||
return str.replace(/([.*+?^=!:${}()|\[\]\/\\])/g, "\\$1");
|
||||
}
|
||||
|
||||
@ -883,7 +862,6 @@ export default {
|
||||
restartDesktopApp,
|
||||
reloadTray,
|
||||
parseDate,
|
||||
getMonthsInDateRange,
|
||||
formatDateISO,
|
||||
formatDateTime,
|
||||
formatTimeInterval,
|
||||
|
@ -6,9 +6,10 @@ import frocaUpdater from "./froca_updater.js";
|
||||
import appContext from "../components/app_context.js";
|
||||
import { t } from "./i18n.js";
|
||||
import type { EntityChange } from "../server_types.js";
|
||||
import { WebSocketMessage } from "@triliumnext/commons";
|
||||
|
||||
type MessageHandler = (message: any) => void;
|
||||
const messageHandlers: MessageHandler[] = [];
|
||||
type MessageHandler = (message: WebSocketMessage) => void;
|
||||
let messageHandlers: MessageHandler[] = [];
|
||||
|
||||
let ws: WebSocket;
|
||||
let lastAcceptedEntityChangeId = window.glob.maxEntityChangeIdAtLoad;
|
||||
@ -47,10 +48,14 @@ function logInfo(message: string) {
|
||||
window.logError = logError;
|
||||
window.logInfo = logInfo;
|
||||
|
||||
function subscribeToMessages(messageHandler: MessageHandler) {
|
||||
export function subscribeToMessages(messageHandler: MessageHandler) {
|
||||
messageHandlers.push(messageHandler);
|
||||
}
|
||||
|
||||
export function unsubscribeToMessage(messageHandler: MessageHandler) {
|
||||
messageHandlers = messageHandlers.filter(handler => handler !== messageHandler);
|
||||
}
|
||||
|
||||
// used to serialize frontend update operations
|
||||
let consumeQueuePromise: Promise<void> | null = null;
|
||||
|
||||
|
@ -294,6 +294,11 @@ button.close:hover {
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.icon-action.btn {
|
||||
padding: 0 8px;
|
||||
min-width: unset !important;
|
||||
}
|
||||
|
||||
.ui-widget-content a:not(.ui-tabs-anchor) {
|
||||
color: #337ab7 !important;
|
||||
}
|
||||
|
@ -96,6 +96,11 @@ button.btn.btn-success kbd {
|
||||
color: var(--icon-button-color);
|
||||
}
|
||||
|
||||
:root .btn-group .icon-action:last-child {
|
||||
border-top-left-radius: unset !important;
|
||||
border-bottom-left-radius: unset !important;
|
||||
}
|
||||
|
||||
.btn-group .tn-tool-button + .tn-tool-button {
|
||||
margin-left: 4px !important;
|
||||
}
|
||||
|
@ -592,7 +592,18 @@
|
||||
"september": "September",
|
||||
"october": "October",
|
||||
"november": "November",
|
||||
"december": "December"
|
||||
"december": "December",
|
||||
"week": "Week",
|
||||
"week_previous": "Previous week",
|
||||
"week_next": "Next week",
|
||||
"month": "Month",
|
||||
"month_previous": "Previous month",
|
||||
"month_next": "Next month",
|
||||
"year": "Year",
|
||||
"year_previous": "Previous year",
|
||||
"year_next": "Next year",
|
||||
"list": "List",
|
||||
"today": "Today"
|
||||
},
|
||||
"close_pane_button": {
|
||||
"close_this_pane": "Close this pane"
|
||||
@ -753,7 +764,8 @@
|
||||
"calendar": "Calendar",
|
||||
"table": "Table",
|
||||
"geo-map": "Geo Map",
|
||||
"board": "Board"
|
||||
"board": "Board",
|
||||
"include_archived_notes": "Show archived notes"
|
||||
},
|
||||
"edited_notes": {
|
||||
"no_edited_notes_found": "No edited notes on this day yet...",
|
||||
@ -954,7 +966,9 @@
|
||||
"no_attachments": "This note has no attachments."
|
||||
},
|
||||
"book": {
|
||||
"no_children_help": "This collection doesn't have any child notes so there's nothing to display. See <a href=\"https://triliumnext.github.io/Docs/Wiki/book-note.html\">wiki</a> for details."
|
||||
"no_children_help": "This collection doesn't have any child notes so there's nothing to display. See <a href=\"https://triliumnext.github.io/Docs/Wiki/book-note.html\">wiki</a> for details.",
|
||||
"drag_locked_title": "Locked for editing",
|
||||
"drag_locked_message": "Dragging not allowed since the collection is locked for editing."
|
||||
},
|
||||
"editable_code": {
|
||||
"placeholder": "Type the content of your code note here..."
|
||||
@ -1580,6 +1594,8 @@
|
||||
"open-in-a-new-split": "Open in a new split",
|
||||
"insert-note-after": "Insert note after",
|
||||
"insert-child-note": "Insert child note",
|
||||
"archive": "Archive",
|
||||
"unarchive": "Unarchive",
|
||||
"delete": "Delete",
|
||||
"search-in-subtree": "Search in subtree",
|
||||
"hoist-note": "Hoist note",
|
||||
@ -1982,14 +1998,21 @@
|
||||
"delete_row": "Delete row"
|
||||
},
|
||||
"board_view": {
|
||||
"delete-note": "Delete Note",
|
||||
"delete-note": "Delete note...",
|
||||
"remove-from-board": "Remove from board",
|
||||
"archive-note": "Archive note",
|
||||
"unarchive-note": "Unarchive note",
|
||||
"move-to": "Move to",
|
||||
"insert-above": "Insert above",
|
||||
"insert-below": "Insert below",
|
||||
"delete-column": "Delete column",
|
||||
"delete-column-confirmation": "Are you sure you want to delete this column? The corresponding attribute will be deleted in the notes under this column as well.",
|
||||
"new-item": "New item",
|
||||
"add-column": "Add Column"
|
||||
"new-item-placeholder": "Enter note title...",
|
||||
"add-column": "Add Column",
|
||||
"add-column-placeholder": "Enter column name...",
|
||||
"edit-note-title": "Click to edit note title",
|
||||
"edit-column-title": "Click to edit column title"
|
||||
},
|
||||
"command_palette": {
|
||||
"tree-action-name": "Tree: {{name}}",
|
||||
@ -2031,5 +2054,12 @@
|
||||
},
|
||||
"units": {
|
||||
"percentage": "%"
|
||||
},
|
||||
"pagination": {
|
||||
"page_title": "Page of {{startIndex}} - {{endIndex}}",
|
||||
"total_notes": "{{count}} notes"
|
||||
},
|
||||
"collections": {
|
||||
"rendering_error": "Unable to show content due to an error."
|
||||
}
|
||||
}
|
||||
|
@ -6,7 +6,7 @@ import { ParentComponent } from "./react/react_utils";
|
||||
import { EventData, EventNames } from "../components/app_context";
|
||||
import { type FloatingButtonsList, type FloatingButtonContext } from "./FloatingButtonsDefinitions";
|
||||
import ActionButton from "./react/ActionButton";
|
||||
import { ViewTypeOptions } from "../services/note_list_renderer";
|
||||
import { ViewTypeOptions } from "./collections/interface";
|
||||
|
||||
interface FloatingButtonsProps {
|
||||
items: FloatingButtonsList;
|
||||
|
@ -19,7 +19,7 @@ import { getHelpUrlForNote } from "../services/in_app_help";
|
||||
import froca from "../services/froca";
|
||||
import NoteLink from "./react/NoteLink";
|
||||
import RawHtml from "./react/RawHtml";
|
||||
import { ViewTypeOptions } from "../services/note_list_renderer";
|
||||
import { ViewTypeOptions } from "./collections/interface";
|
||||
|
||||
export interface FloatingButtonContext {
|
||||
parentComponent: Component;
|
||||
|
25
apps/client/src/widgets/collections/NoteList.css
Normal file
25
apps/client/src/widgets/collections/NoteList.css
Normal file
@ -0,0 +1,25 @@
|
||||
.note-list-widget {
|
||||
min-height: 0;
|
||||
overflow: auto;
|
||||
contain: none !important;
|
||||
}
|
||||
|
||||
.note-list-widget .note-list {
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
.note-list-widget.full-height,
|
||||
.note-list-widget.full-height .note-list-widget-content {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.note-list-widget video {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
/* #region Pagination */
|
||||
.note-list-pager span.current-page {
|
||||
text-decoration: underline;
|
||||
font-weight: bold;
|
||||
}
|
||||
/* #endregion */
|
187
apps/client/src/widgets/collections/NoteList.tsx
Normal file
187
apps/client/src/widgets/collections/NoteList.tsx
Normal file
@ -0,0 +1,187 @@
|
||||
import { allViewTypes, ViewModeProps, ViewTypeOptions } from "./interface";
|
||||
import { useNoteContext, useNoteLabel, useNoteLabelBoolean, useTriliumEvent } from "../react/hooks";
|
||||
import FNote from "../../entities/fnote";
|
||||
import "./NoteList.css";
|
||||
import { ListView, GridView } from "./legacy/ListOrGridView";
|
||||
import { useEffect, useRef, useState } from "preact/hooks";
|
||||
import GeoView from "./geomap";
|
||||
import ViewModeStorage from "./view_mode_storage";
|
||||
import CalendarView from "./calendar";
|
||||
import TableView from "./table";
|
||||
import BoardView from "./board";
|
||||
import { subscribeToMessages, unsubscribeToMessage as unsubscribeFromMessage } from "../../services/ws";
|
||||
import { WebSocketMessage } from "@triliumnext/commons";
|
||||
import froca from "../../services/froca";
|
||||
|
||||
interface NoteListProps<T extends object> {
|
||||
note?: FNote | null;
|
||||
/** if set to `true` then only collection-type views are displayed such as geo-map and the calendar. The original book types grid and list will be ignored. */
|
||||
displayOnlyCollections?: boolean;
|
||||
highlightedTokens?: string[] | null;
|
||||
viewStorage?: ViewModeStorage<T>;
|
||||
}
|
||||
|
||||
export default function NoteList<T extends object>({ note: providedNote, highlightedTokens, displayOnlyCollections }: NoteListProps<T>) {
|
||||
const widgetRef = useRef<HTMLDivElement>(null);
|
||||
const { note: contextNote, noteContext, notePath } = useNoteContext();
|
||||
const note = providedNote ?? contextNote;
|
||||
const viewType = useNoteViewType(note);
|
||||
const noteIds = useNoteIds(note, viewType);
|
||||
const isFullHeight = (viewType && viewType !== "list" && viewType !== "grid");
|
||||
const [ isIntersecting, setIsIntersecting ] = useState(false);
|
||||
const shouldRender = (isFullHeight || isIntersecting || note?.type === "book");
|
||||
const isEnabled = (note && noteContext?.hasNoteList() && !!viewType && shouldRender);
|
||||
|
||||
useEffect(() => {
|
||||
if (isFullHeight || displayOnlyCollections || note?.type === "book") {
|
||||
// Double role: no need to check if the note list is visible if the view is full-height or book, but also prevent legacy views if `displayOnlyCollections` is true.
|
||||
return;
|
||||
}
|
||||
|
||||
const observer = new IntersectionObserver(
|
||||
(entries) => {
|
||||
if (!isIntersecting) {
|
||||
setIsIntersecting(entries[0].isIntersecting);
|
||||
observer.disconnect();
|
||||
}
|
||||
},
|
||||
{
|
||||
rootMargin: "50px",
|
||||
threshold: 0.1
|
||||
}
|
||||
);
|
||||
|
||||
// there seems to be a race condition on Firefox which triggers the observer only before the widget is visible
|
||||
// (intersection is false). https://github.com/zadam/trilium/issues/4165
|
||||
setTimeout(() => widgetRef.current && observer.observe(widgetRef.current), 10);
|
||||
return () => observer.disconnect();
|
||||
}, [ widgetRef, isFullHeight, displayOnlyCollections, note ]);
|
||||
|
||||
// Preload the configuration.
|
||||
let props: ViewModeProps<any> | undefined | null = null;
|
||||
const viewModeConfig = useViewModeConfig(note, viewType);
|
||||
if (note && notePath && viewModeConfig) {
|
||||
props = {
|
||||
note, noteIds, notePath,
|
||||
highlightedTokens,
|
||||
viewConfig: viewModeConfig[0],
|
||||
saveConfig: viewModeConfig[1]
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div ref={widgetRef} className={`note-list-widget component ${isFullHeight ? "full-height" : ""}`}>
|
||||
{props && isEnabled && (
|
||||
<div className="note-list-widget-content">
|
||||
{getComponentByViewType(viewType, props)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function getComponentByViewType(viewType: ViewTypeOptions, props: ViewModeProps<any>) {
|
||||
switch (viewType) {
|
||||
case "list":
|
||||
return <ListView {...props} />;
|
||||
case "grid":
|
||||
return <GridView {...props} />;
|
||||
case "geoMap":
|
||||
return <GeoView {...props} />;
|
||||
case "calendar":
|
||||
return <CalendarView {...props} />
|
||||
case "table":
|
||||
return <TableView {...props} />
|
||||
case "board":
|
||||
return <BoardView {...props} />
|
||||
}
|
||||
}
|
||||
|
||||
function useNoteViewType(note?: FNote | null): ViewTypeOptions | undefined {
|
||||
const [ viewType ] = useNoteLabel(note, "viewType");
|
||||
|
||||
if (!note) {
|
||||
return undefined;
|
||||
} else if (!(allViewTypes as readonly string[]).includes(viewType || "")) {
|
||||
// when not explicitly set, decide based on the note type
|
||||
return note.type === "search" ? "list" : "grid";
|
||||
} else {
|
||||
return viewType as ViewTypeOptions;
|
||||
}
|
||||
}
|
||||
|
||||
function useNoteIds(note: FNote | null | undefined, viewType: ViewTypeOptions | undefined) {
|
||||
const [ noteIds, setNoteIds ] = useState<string[]>([]);
|
||||
const [ includeArchived ] = useNoteLabelBoolean(note, "includeArchived");
|
||||
|
||||
async function refreshNoteIds() {
|
||||
if (!note) {
|
||||
setNoteIds([]);
|
||||
} else {
|
||||
setNoteIds(await getNoteIds(note));
|
||||
}
|
||||
}
|
||||
|
||||
async function getNoteIds(note: FNote) {
|
||||
if (viewType === "list" || viewType === "grid") {
|
||||
return note.getChildNoteIds();
|
||||
} else {
|
||||
return await note.getSubtreeNoteIds(includeArchived);
|
||||
}
|
||||
}
|
||||
|
||||
// Refresh on note switch.
|
||||
useEffect(() => { refreshNoteIds() }, [ note, includeArchived ]);
|
||||
|
||||
// Refresh on alterations to the note subtree.
|
||||
useTriliumEvent("entitiesReloaded", ({ loadResults }) => {
|
||||
if (note && loadResults.getBranchRows().some(branch =>
|
||||
branch.parentNoteId === note.noteId
|
||||
|| noteIds.includes(branch.parentNoteId ?? ""))
|
||||
|| loadResults.getAttributeRows().some(attr => attr.name === "archived" && attr.noteId && noteIds.includes(attr.noteId))
|
||||
) {
|
||||
refreshNoteIds();
|
||||
}
|
||||
})
|
||||
|
||||
// Refresh on import.
|
||||
useEffect(() => {
|
||||
async function onImport(message: WebSocketMessage) {
|
||||
if (!("taskType" in message) || message.taskType !== "importNotes" || message.type !== "taskSucceeded") return;
|
||||
const { parentNoteId, importedNoteId } = message.result;
|
||||
if (!parentNoteId || !importedNoteId) return;
|
||||
if (importedNoteId && (parentNoteId === note?.noteId || noteIds.includes(parentNoteId))) {
|
||||
const importedNote = await froca.getNote(importedNoteId);
|
||||
if (!importedNote) return;
|
||||
setNoteIds([
|
||||
...noteIds,
|
||||
...await getNoteIds(importedNote),
|
||||
importedNoteId
|
||||
])
|
||||
}
|
||||
}
|
||||
|
||||
subscribeToMessages(onImport);
|
||||
return () => unsubscribeFromMessage(onImport);
|
||||
}, [ note, noteIds, setNoteIds ])
|
||||
|
||||
return noteIds;
|
||||
}
|
||||
|
||||
function useViewModeConfig<T extends object>(note: FNote | null | undefined, viewType: ViewTypeOptions | undefined) {
|
||||
const [ viewConfig, setViewConfig ] = useState<[T | undefined, (data: T) => void]>();
|
||||
|
||||
useEffect(() => {
|
||||
if (!note || !viewType) return;
|
||||
const viewStorage = new ViewModeStorage<T>(note, viewType);
|
||||
viewStorage.restore().then(config => {
|
||||
const storeFn = (config: T) => {
|
||||
setViewConfig([ config, storeFn ]);
|
||||
viewStorage.store(config);
|
||||
};
|
||||
setViewConfig([ config, storeFn ]);
|
||||
});
|
||||
}, [ note, viewType ]);
|
||||
|
||||
return viewConfig;
|
||||
}
|
85
apps/client/src/widgets/collections/Pagination.tsx
Normal file
85
apps/client/src/widgets/collections/Pagination.tsx
Normal file
@ -0,0 +1,85 @@
|
||||
import { ComponentChildren } from "preact";
|
||||
import { Dispatch, StateUpdater, useEffect, useState } from "preact/hooks";
|
||||
import FNote from "../../entities/fnote";
|
||||
import froca from "../../services/froca";
|
||||
import { useNoteLabelInt } from "../react/hooks";
|
||||
import { t } from "../../services/i18n";
|
||||
|
||||
interface PaginationContext {
|
||||
page: number;
|
||||
setPage: Dispatch<StateUpdater<number>>;
|
||||
pageNotes?: FNote[];
|
||||
pageCount: number;
|
||||
pageSize: number;
|
||||
totalNotes: number;
|
||||
}
|
||||
|
||||
export function Pager({ page, pageSize, setPage, pageCount, totalNotes }: Omit<PaginationContext, "pageNotes">) {
|
||||
if (pageCount < 2) return;
|
||||
|
||||
let lastPrinted = false;
|
||||
let children: ComponentChildren[] = [];
|
||||
for (let i = 1; i <= pageCount; i++) {
|
||||
if (pageCount < 20 || i <= 5 || pageCount - i <= 5 || Math.abs(page - i) <= 2) {
|
||||
lastPrinted = true;
|
||||
|
||||
const startIndex = (i - 1) * pageSize + 1;
|
||||
const endIndex = Math.min(totalNotes, i * pageSize);
|
||||
|
||||
if (i !== page) {
|
||||
children.push((
|
||||
<a
|
||||
href="javascript:"
|
||||
title={t("pagination.page_title", { startIndex, endIndex })}
|
||||
onClick={() => setPage(i)}
|
||||
>
|
||||
{i}
|
||||
</a>
|
||||
))
|
||||
} else {
|
||||
// Current page
|
||||
children.push(<span className="current-page">{i}</span>)
|
||||
}
|
||||
|
||||
children.push(<>{" "} {" "}</>);
|
||||
} else if (lastPrinted) {
|
||||
children.push(<>{"... "} {" "}</>);
|
||||
lastPrinted = false;
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div class="note-list-pager">
|
||||
{children}
|
||||
|
||||
<span className="note-list-pager-total-count">({t("pagination.total_notes", { count: totalNotes })})</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function usePagination(note: FNote, noteIds: string[]): PaginationContext {
|
||||
const [ page, setPage ] = useState(1);
|
||||
const [ pageNotes, setPageNotes ] = useState<FNote[]>();
|
||||
|
||||
// Parse page size.
|
||||
const [ pageSize ] = useNoteLabelInt(note, "pageSize");
|
||||
const normalizedPageSize = (pageSize && pageSize > 0 ? pageSize : 20);
|
||||
|
||||
// Calculate start/end index.
|
||||
const startIdx = (page - 1) * normalizedPageSize;
|
||||
const endIdx = startIdx + normalizedPageSize;
|
||||
const pageCount = Math.ceil(noteIds.length / normalizedPageSize);
|
||||
|
||||
// Obtain notes within the range.
|
||||
const pageNoteIds = noteIds.slice(startIdx, Math.min(endIdx, noteIds.length));
|
||||
|
||||
useEffect(() => {
|
||||
froca.getNotes(pageNoteIds).then(setPageNotes);
|
||||
}, [ note, noteIds, page, pageSize ]);
|
||||
|
||||
return {
|
||||
page, setPage, pageNotes, pageCount,
|
||||
pageSize: normalizedPageSize,
|
||||
totalNotes: noteIds.length
|
||||
};
|
||||
}
|
210
apps/client/src/widgets/collections/board/api.ts
Normal file
210
apps/client/src/widgets/collections/board/api.ts
Normal file
@ -0,0 +1,210 @@
|
||||
import { BoardViewData } from ".";
|
||||
import appContext from "../../../components/app_context";
|
||||
import FNote from "../../../entities/fnote";
|
||||
import attributes from "../../../services/attributes";
|
||||
import branches from "../../../services/branches";
|
||||
import { executeBulkActions } from "../../../services/bulk_action";
|
||||
import froca from "../../../services/froca";
|
||||
import { t } from "../../../services/i18n";
|
||||
import note_create from "../../../services/note_create";
|
||||
import server from "../../../services/server";
|
||||
import { ColumnMap } from "./data";
|
||||
|
||||
export default class BoardApi {
|
||||
|
||||
constructor(
|
||||
private byColumn: ColumnMap | undefined,
|
||||
public columns: string[],
|
||||
private parentNote: FNote,
|
||||
private statusAttribute: string,
|
||||
private viewConfig: BoardViewData,
|
||||
private saveConfig: (newConfig: BoardViewData) => void,
|
||||
private setBranchIdToEdit: (branchId: string | undefined) => void
|
||||
) {};
|
||||
|
||||
async createNewItem(column: string, title: string) {
|
||||
try {
|
||||
// Get the parent note path
|
||||
const parentNotePath = this.parentNote.noteId;
|
||||
|
||||
// Create a new note as a child of the parent note
|
||||
const { note: newNote, branch: newBranch } = await note_create.createNote(parentNotePath, {
|
||||
activate: false,
|
||||
title
|
||||
});
|
||||
|
||||
if (newNote && newBranch) {
|
||||
await this.changeColumn(newNote.noteId, column);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to create new item:", error);
|
||||
}
|
||||
}
|
||||
|
||||
async changeColumn(noteId: string, newColumn: string) {
|
||||
await attributes.setLabel(noteId, this.statusAttribute, newColumn);
|
||||
}
|
||||
|
||||
async addNewColumn(columnName: string) {
|
||||
if (!columnName.trim()) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!this.viewConfig) {
|
||||
this.viewConfig = {};
|
||||
}
|
||||
|
||||
if (!this.viewConfig.columns) {
|
||||
this.viewConfig.columns = [];
|
||||
}
|
||||
|
||||
// Add the new column to persisted data if it doesn't exist
|
||||
const existingColumn = this.viewConfig.columns.find(col => col.value === columnName);
|
||||
if (!existingColumn) {
|
||||
this.viewConfig.columns.push({ value: columnName });
|
||||
this.saveConfig(this.viewConfig);
|
||||
}
|
||||
}
|
||||
|
||||
async removeColumn(column: string) {
|
||||
// Remove the value from the notes.
|
||||
const noteIds = this.byColumn?.get(column)?.map(item => item.note.noteId) || [];
|
||||
await executeBulkActions(noteIds, [
|
||||
{
|
||||
name: "deleteLabel",
|
||||
labelName: this.statusAttribute
|
||||
}
|
||||
]);
|
||||
|
||||
this.viewConfig.columns = (this.viewConfig.columns ?? []).filter(col => col.value !== column);
|
||||
this.saveConfig(this.viewConfig);
|
||||
}
|
||||
|
||||
async renameColumn(oldValue: string, newValue: string) {
|
||||
const noteIds = this.byColumn?.get(oldValue)?.map(item => item.note.noteId) || [];
|
||||
|
||||
// Change the value in the notes.
|
||||
await executeBulkActions(noteIds, [
|
||||
{
|
||||
name: "updateLabelValue",
|
||||
labelName: this.statusAttribute,
|
||||
labelValue: newValue
|
||||
}
|
||||
]);
|
||||
|
||||
// Rename the column in the persisted data.
|
||||
for (const column of this.viewConfig.columns || []) {
|
||||
if (column.value === oldValue) {
|
||||
column.value = newValue;
|
||||
}
|
||||
}
|
||||
this.saveConfig(this.viewConfig);
|
||||
}
|
||||
|
||||
reorderColumn(fromIndex: number, toIndex: number) {
|
||||
if (!this.columns || fromIndex === toIndex) return;
|
||||
|
||||
const newColumns = [...this.columns];
|
||||
const [movedColumn] = newColumns.splice(fromIndex, 1);
|
||||
|
||||
// Adjust toIndex after removing the element
|
||||
// When moving forward (right), the removal shifts indices left
|
||||
let adjustedToIndex = toIndex;
|
||||
if (fromIndex < toIndex) {
|
||||
adjustedToIndex = toIndex - 1;
|
||||
}
|
||||
|
||||
newColumns.splice(adjustedToIndex, 0, movedColumn);
|
||||
|
||||
// Update view config with new column order
|
||||
const newViewConfig = {
|
||||
...this.viewConfig,
|
||||
columns: newColumns.map(col => ({ value: col }))
|
||||
};
|
||||
|
||||
this.saveConfig(newViewConfig);
|
||||
return newColumns;
|
||||
}
|
||||
|
||||
async insertRowAtPosition(
|
||||
column: string,
|
||||
relativeToBranchId: string,
|
||||
direction: "before" | "after") {
|
||||
const { note, branch } = await note_create.createNote(this.parentNote.noteId, {
|
||||
activate: false,
|
||||
targetBranchId: relativeToBranchId,
|
||||
target: direction,
|
||||
title: t("board_view.new-item")
|
||||
});
|
||||
|
||||
if (!note || !branch) {
|
||||
throw new Error("Failed to create note");
|
||||
}
|
||||
|
||||
const { noteId } = note;
|
||||
await this.changeColumn(noteId, column);
|
||||
this.startEditing(branch.branchId);
|
||||
|
||||
return note;
|
||||
}
|
||||
|
||||
openNote(noteId: string) {
|
||||
appContext.triggerCommand("openInPopup", { noteIdOrPath: noteId });
|
||||
}
|
||||
|
||||
startEditing(branchId: string) {
|
||||
this.setBranchIdToEdit(branchId);
|
||||
}
|
||||
|
||||
dismissEditingTitle() {
|
||||
this.setBranchIdToEdit(undefined);
|
||||
}
|
||||
|
||||
renameCard(noteId: string, newTitle: string) {
|
||||
return server.put(`notes/${noteId}/title`, { title: newTitle.trim() });
|
||||
}
|
||||
|
||||
removeFromBoard(noteId: string) {
|
||||
const note = froca.getNoteFromCache(noteId);
|
||||
if (!note) return;
|
||||
return attributes.removeOwnedLabelByName(note, this.statusAttribute);
|
||||
}
|
||||
|
||||
async moveWithinBoard(noteId: string, sourceBranchId: string, sourceIndex: number, targetIndex: number, sourceColumn: string, targetColumn: string) {
|
||||
const targetItems = this.byColumn?.get(targetColumn) ?? [];
|
||||
|
||||
const note = froca.getNoteFromCache(noteId);
|
||||
if (!note) return;
|
||||
|
||||
if (sourceColumn !== targetColumn) {
|
||||
// Moving to a different column
|
||||
await this.changeColumn(noteId, targetColumn);
|
||||
|
||||
// If there are items in the target column, reorder
|
||||
if (targetItems.length > 0 && targetIndex < targetItems.length) {
|
||||
const targetBranch = targetItems[targetIndex].branch;
|
||||
await branches.moveBeforeBranch([ sourceBranchId ], targetBranch.branchId);
|
||||
}
|
||||
} else if (sourceIndex !== targetIndex) {
|
||||
// Reordering within the same column
|
||||
let targetBranchId: string | null = null;
|
||||
|
||||
if (targetIndex < targetItems.length) {
|
||||
// Moving before an existing item
|
||||
const adjustedIndex = sourceIndex < targetIndex ? targetIndex : targetIndex;
|
||||
if (adjustedIndex < targetItems.length) {
|
||||
targetBranchId = targetItems[adjustedIndex].branch.branchId;
|
||||
if (targetBranchId) {
|
||||
await branches.moveBeforeBranch([ sourceBranchId ], targetBranchId);
|
||||
}
|
||||
}
|
||||
} else if (targetIndex > 0) {
|
||||
// Moving to the end - place after the last item
|
||||
const lastItem = targetItems[targetItems.length - 1];
|
||||
await branches.moveAfterBranch([ sourceBranchId ], lastItem.branch.branchId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
115
apps/client/src/widgets/collections/board/card.tsx
Normal file
115
apps/client/src/widgets/collections/board/card.tsx
Normal file
@ -0,0 +1,115 @@
|
||||
import { useCallback, useContext, useEffect, useRef, useState } from "preact/hooks";
|
||||
import FBranch from "../../../entities/fbranch";
|
||||
import FNote from "../../../entities/fnote";
|
||||
import BoardApi from "./api";
|
||||
import { BoardViewContext, TitleEditor } from ".";
|
||||
import { ContextMenuEvent } from "../../../menus/context_menu";
|
||||
import { openNoteContextMenu } from "./context_menu";
|
||||
import { t } from "../../../services/i18n";
|
||||
|
||||
export const CARD_CLIPBOARD_TYPE = "trilium/board-card";
|
||||
|
||||
export interface CardDragData {
|
||||
noteId: string;
|
||||
branchId: string;
|
||||
index: number;
|
||||
fromColumn: string;
|
||||
}
|
||||
|
||||
export default function Card({
|
||||
api,
|
||||
note,
|
||||
branch,
|
||||
column,
|
||||
index,
|
||||
isDragging
|
||||
}: {
|
||||
api: BoardApi,
|
||||
note: FNote,
|
||||
branch: FBranch,
|
||||
column: string,
|
||||
index: number,
|
||||
isDragging: boolean
|
||||
}) {
|
||||
const { branchIdToEdit, setBranchIdToEdit, setDraggedCard } = useContext(BoardViewContext)!;
|
||||
const isEditing = branch.branchId === branchIdToEdit;
|
||||
const colorClass = note.getColorClass() || '';
|
||||
const editorRef = useRef<HTMLInputElement>(null);
|
||||
const isArchived = note.isArchived;
|
||||
const [ isVisible, setVisible ] = useState(true);
|
||||
const [ title, setTitle ] = useState(note.title);
|
||||
|
||||
const handleDragStart = useCallback((e: DragEvent) => {
|
||||
e.dataTransfer!.effectAllowed = 'move';
|
||||
const data: CardDragData = { noteId: note.noteId, branchId: branch.branchId, fromColumn: column, index };
|
||||
setDraggedCard(data);
|
||||
e.dataTransfer!.setData(CARD_CLIPBOARD_TYPE, JSON.stringify(data));
|
||||
}, [note.noteId, branch.branchId, column, index]);
|
||||
|
||||
const handleDragEnd = useCallback((e: DragEvent) => {
|
||||
setDraggedCard(null);
|
||||
}, [setDraggedCard]);
|
||||
|
||||
const handleContextMenu = useCallback((e: ContextMenuEvent) => {
|
||||
openNoteContextMenu(api, e, note, branch.branchId, column);
|
||||
}, [ api, note, branch, column ]);
|
||||
|
||||
const handleOpen = useCallback(() => {
|
||||
api.openNote(note.noteId);
|
||||
}, [ api, note ]);
|
||||
|
||||
const handleEdit = useCallback((e: MouseEvent) => {
|
||||
e.stopPropagation(); // don't also open the note
|
||||
setBranchIdToEdit?.(branch.branchId);
|
||||
}, [ setBranchIdToEdit, branch ]);
|
||||
|
||||
useEffect(() => {
|
||||
editorRef.current?.focus();
|
||||
}, [ isEditing ]);
|
||||
|
||||
useEffect(() => {
|
||||
setTitle(note.title);
|
||||
}, [ note ]);
|
||||
|
||||
useEffect(() => {
|
||||
setVisible(!isDragging);
|
||||
}, [ isDragging ]);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`board-note ${colorClass} ${isDragging ? 'dragging' : ''} ${isEditing ? "editing" : ""} ${isArchived ? "archived" : ""}`}
|
||||
draggable
|
||||
onDragStart={handleDragStart}
|
||||
onDragEnd={handleDragEnd}
|
||||
onContextMenu={handleContextMenu}
|
||||
onClick={!isEditing ? handleOpen : undefined}
|
||||
style={{
|
||||
display: !isVisible ? "none" : undefined
|
||||
}}
|
||||
>
|
||||
{!isEditing ? (
|
||||
<>
|
||||
<span className="title">
|
||||
<span class={`icon ${note.getIcon()}`} />
|
||||
{title}
|
||||
</span>
|
||||
<span
|
||||
className="edit-icon icon bx bx-edit"
|
||||
title={t("board_view.edit-note-title")}
|
||||
onClick={handleEdit}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<TitleEditor
|
||||
currentValue={note.title}
|
||||
save={newTitle => {
|
||||
api.renameCard(note.noteId, newTitle);
|
||||
setTitle(newTitle);
|
||||
}}
|
||||
dismiss={() => api.dismissEditingTitle()}
|
||||
multiline
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
277
apps/client/src/widgets/collections/board/column.tsx
Normal file
277
apps/client/src/widgets/collections/board/column.tsx
Normal file
@ -0,0 +1,277 @@
|
||||
import { useCallback, useContext, useEffect, useRef, useState } from "preact/hooks";
|
||||
import FBranch from "../../../entities/fbranch";
|
||||
import FNote from "../../../entities/fnote";
|
||||
import { BoardViewContext, TitleEditor } from ".";
|
||||
import branches from "../../../services/branches";
|
||||
import { openColumnContextMenu } from "./context_menu";
|
||||
import { ContextMenuEvent } from "../../../menus/context_menu";
|
||||
import Icon from "../../react/Icon";
|
||||
import { t } from "../../../services/i18n";
|
||||
import BoardApi from "./api";
|
||||
import Card, { CARD_CLIPBOARD_TYPE, CardDragData } from "./card";
|
||||
import { JSX } from "preact/jsx-runtime";
|
||||
import froca from "../../../services/froca";
|
||||
import { DragData, TREE_CLIPBOARD_TYPE } from "../../note_tree";
|
||||
|
||||
interface DragContext {
|
||||
column: string;
|
||||
columnIndex: number,
|
||||
columnItems?: { note: FNote, branch: FBranch }[];
|
||||
}
|
||||
|
||||
export default function Column({
|
||||
column,
|
||||
columnIndex,
|
||||
isDraggingColumn,
|
||||
columnItems,
|
||||
api,
|
||||
onColumnHover,
|
||||
isAnyColumnDragging,
|
||||
}: {
|
||||
columnItems?: { note: FNote, branch: FBranch }[];
|
||||
isDraggingColumn: boolean,
|
||||
api: BoardApi,
|
||||
onColumnHover?: (index: number, mouseX: number, rect: DOMRect) => void,
|
||||
isAnyColumnDragging?: boolean
|
||||
} & DragContext) {
|
||||
const [ isVisible, setVisible ] = useState(true);
|
||||
const { columnNameToEdit, setColumnNameToEdit, dropTarget, draggedCard, dropPosition } = useContext(BoardViewContext)!;
|
||||
const isEditing = (columnNameToEdit === column);
|
||||
const editorRef = useRef<HTMLInputElement>(null);
|
||||
const { handleColumnDragStart, handleColumnDragEnd, handleDragOver, handleDragLeave, handleDrop } = useDragging({
|
||||
column, columnIndex, columnItems, isEditing
|
||||
});
|
||||
|
||||
const handleEdit = useCallback(() => {
|
||||
setColumnNameToEdit?.(column);
|
||||
}, [column]);
|
||||
|
||||
const handleContextMenu = useCallback((e: ContextMenuEvent) => {
|
||||
openColumnContextMenu(api, e, column);
|
||||
}, [ api, column ]);
|
||||
|
||||
/** Allow using mouse wheel to scroll inside card, while also maintaining column horizontal scrolling. */
|
||||
const handleScroll = useCallback((event: JSX.TargetedWheelEvent<HTMLDivElement>) => {
|
||||
const el = event.currentTarget;
|
||||
if (!el) return;
|
||||
|
||||
const needsScroll = el.scrollHeight > el.clientHeight;
|
||||
if (needsScroll) {
|
||||
event.stopPropagation();
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
editorRef.current?.focus();
|
||||
}, [ isEditing ]);
|
||||
|
||||
useEffect(() => {
|
||||
setVisible(!isDraggingColumn);
|
||||
}, [ isDraggingColumn ]);
|
||||
|
||||
const handleColumnDragOver = useCallback((e: DragEvent) => {
|
||||
if (!isAnyColumnDragging || !onColumnHover) return;
|
||||
e.preventDefault();
|
||||
const rect = (e.currentTarget as HTMLElement).getBoundingClientRect();
|
||||
onColumnHover(columnIndex, e.clientX, rect);
|
||||
}, [isAnyColumnDragging, onColumnHover, columnIndex]);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`board-column ${dropTarget === column && draggedCard?.fromColumn !== column ? 'drag-over' : ''}`}
|
||||
onDragOver={isAnyColumnDragging ? handleColumnDragOver : handleDragOver}
|
||||
onDragLeave={handleDragLeave}
|
||||
onDrop={handleDrop}
|
||||
onWheel={handleScroll}
|
||||
style={{
|
||||
display: !isVisible ? "none" : undefined
|
||||
}}
|
||||
>
|
||||
<h3
|
||||
className={`${isEditing ? "editing" : ""}`}
|
||||
draggable
|
||||
onDragStart={handleColumnDragStart}
|
||||
onDragEnd={handleColumnDragEnd}
|
||||
onContextMenu={handleContextMenu}
|
||||
>
|
||||
{!isEditing ? (
|
||||
<>
|
||||
<span className="title">{column}</span>
|
||||
<span
|
||||
className="edit-icon icon bx bx-edit-alt"
|
||||
title={t("board_view.edit-column-title")}
|
||||
onClick={handleEdit}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<TitleEditor
|
||||
currentValue={column}
|
||||
save={newTitle => api.renameColumn(column, newTitle)}
|
||||
dismiss={() => setColumnNameToEdit?.(undefined)}
|
||||
/>
|
||||
)}
|
||||
</h3>
|
||||
|
||||
{(columnItems ?? []).map(({ note, branch }, index) => {
|
||||
const showIndicatorBefore = dropPosition?.column === column &&
|
||||
dropPosition.index === index &&
|
||||
draggedCard?.noteId !== note.noteId;
|
||||
|
||||
return (
|
||||
<>
|
||||
{showIndicatorBefore && (
|
||||
<div className="board-drop-placeholder show" />
|
||||
)}
|
||||
<Card
|
||||
key={note.noteId}
|
||||
api={api}
|
||||
note={note}
|
||||
branch={branch}
|
||||
column={column}
|
||||
index={index}
|
||||
isDragging={draggedCard?.noteId === note.noteId}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
})}
|
||||
{dropPosition?.column === column && dropPosition.index === (columnItems?.length ?? 0) && (
|
||||
<div className="board-drop-placeholder show" />
|
||||
)}
|
||||
|
||||
<AddNewItem api={api} column={column} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function AddNewItem({ column, api }: { column: string, api: BoardApi }) {
|
||||
const [ isCreatingNewItem, setIsCreatingNewItem ] = useState(false);
|
||||
const addItemCallback = useCallback(() => setIsCreatingNewItem(true), []);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`board-new-item ${isCreatingNewItem ? "editing" : ""}`}
|
||||
onClick={addItemCallback}
|
||||
>
|
||||
{!isCreatingNewItem ? (
|
||||
<>
|
||||
<Icon icon="bx bx-plus" />{" "}
|
||||
{t("board_view.new-item")}
|
||||
</>
|
||||
) : (
|
||||
<TitleEditor
|
||||
placeholder={t("board_view.new-item-placeholder")}
|
||||
save={(title) => api.createNewItem(column, title)}
|
||||
dismiss={() => setIsCreatingNewItem(false)}
|
||||
multiline isNewItem
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function useDragging({ column, columnIndex, columnItems, isEditing }: DragContext & { isEditing: boolean }) {
|
||||
const { api, parentNote, draggedColumn, setDraggedColumn, setDropTarget, setDropPosition, dropPosition } = useContext(BoardViewContext)!;
|
||||
/** Needed to track if current column is dragged in real-time, since {@link draggedColumn} is populated one render cycle later. */
|
||||
const isDraggingRef = useRef(false);
|
||||
|
||||
const handleColumnDragStart = useCallback((e: DragEvent) => {
|
||||
if (isEditing) return;
|
||||
|
||||
isDraggingRef.current = true;
|
||||
e.dataTransfer!.effectAllowed = 'move';
|
||||
e.dataTransfer!.setData('text/plain', column);
|
||||
setDraggedColumn({ column, index: columnIndex });
|
||||
e.stopPropagation(); // Prevent card drag from interfering
|
||||
}, [column, columnIndex, setDraggedColumn, isEditing]);
|
||||
|
||||
const handleColumnDragEnd = useCallback(() => {
|
||||
isDraggingRef.current = false;
|
||||
setDraggedColumn(null);
|
||||
}, [setDraggedColumn]);
|
||||
|
||||
const handleDragOver = useCallback((e: DragEvent) => {
|
||||
if (isEditing || draggedColumn || isDraggingRef.current) return; // Don't handle card drops when dragging columns
|
||||
if (!e.dataTransfer?.types.includes(CARD_CLIPBOARD_TYPE) && !e.dataTransfer?.types.includes(TREE_CLIPBOARD_TYPE)) return;
|
||||
|
||||
e.preventDefault();
|
||||
setDropTarget(column);
|
||||
|
||||
// Calculate drop position based on mouse position
|
||||
const cards = Array.from((e.currentTarget as HTMLElement)?.querySelectorAll('.board-note'));
|
||||
const mouseY = e.clientY;
|
||||
|
||||
let newIndex = cards.length;
|
||||
for (let i = 0; i < cards.length; i++) {
|
||||
const card = cards[i] as HTMLElement;
|
||||
const rect = card.getBoundingClientRect();
|
||||
const cardMiddle = rect.top + rect.height / 2;
|
||||
|
||||
if (mouseY < cardMiddle) {
|
||||
newIndex = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!(dropPosition?.column === column && dropPosition.index === newIndex)) {
|
||||
setDropPosition({ column, index: newIndex });
|
||||
}
|
||||
}, [column, setDropTarget, dropPosition, setDropPosition, isEditing]);
|
||||
|
||||
const handleDragLeave = useCallback((e: DragEvent) => {
|
||||
const relatedTarget = e.relatedTarget as HTMLElement;
|
||||
const currentTarget = e.currentTarget as HTMLElement;
|
||||
|
||||
if (!currentTarget.contains(relatedTarget)) {
|
||||
setDropTarget(null);
|
||||
setDropPosition(null);
|
||||
}
|
||||
}, [setDropTarget, setDropPosition]);
|
||||
|
||||
const handleDrop = useCallback(async (e: DragEvent) => {
|
||||
if (draggedColumn) return; // Don't handle card drops when dragging columns
|
||||
e.preventDefault();
|
||||
setDropTarget(null);
|
||||
setDropPosition(null);
|
||||
|
||||
const data = e.dataTransfer?.getData(CARD_CLIPBOARD_TYPE) || e.dataTransfer?.getData("text");
|
||||
if (!data) return;
|
||||
|
||||
let draggedCard: CardDragData | DragData[];
|
||||
try {
|
||||
draggedCard = JSON.parse(data);
|
||||
} catch (e) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (Array.isArray(draggedCard)) {
|
||||
// From note tree.
|
||||
const { noteId, branchId } = draggedCard[0];
|
||||
const targetNote = await froca.getNote(noteId, true);
|
||||
const parentNoteId = parentNote?.noteId;
|
||||
if (!parentNoteId || !dropPosition) return;
|
||||
|
||||
const targetIndex = dropPosition.index - 1;
|
||||
const targetItems = columnItems || [];
|
||||
const targetBranch = targetIndex >= 0 ? targetItems[targetIndex].branch : null;
|
||||
|
||||
await api?.changeColumn(noteId, column);
|
||||
|
||||
const parents = targetNote?.getParentNoteIds();
|
||||
if (!parents?.includes(parentNoteId)) {
|
||||
if (!targetBranch) {
|
||||
// First.
|
||||
await branches.cloneNoteToParentNote(noteId, parentNoteId);
|
||||
} else {
|
||||
await branches.cloneNoteAfter(noteId, targetBranch.branchId);
|
||||
}
|
||||
} else if (targetBranch) {
|
||||
await branches.moveAfterBranch([ branchId ], targetBranch.branchId);
|
||||
}
|
||||
} else if (draggedCard && dropPosition) {
|
||||
api?.moveWithinBoard(draggedCard.noteId, draggedCard.branchId, draggedCard.index, dropPosition.index, draggedCard.fromColumn, column);
|
||||
}
|
||||
|
||||
}, [ api, draggedColumn, dropPosition, columnItems, column, setDropTarget, setDropPosition ]);
|
||||
|
||||
return { handleColumnDragStart, handleColumnDragEnd, handleDragOver, handleDragLeave, handleDrop };
|
||||
}
|
98
apps/client/src/widgets/collections/board/context_menu.ts
Normal file
98
apps/client/src/widgets/collections/board/context_menu.ts
Normal file
@ -0,0 +1,98 @@
|
||||
import FNote from "../../../entities/fnote";
|
||||
import contextMenu, { ContextMenuEvent } from "../../../menus/context_menu";
|
||||
import link_context_menu from "../../../menus/link_context_menu";
|
||||
import attributes from "../../../services/attributes";
|
||||
import branches from "../../../services/branches";
|
||||
import dialog from "../../../services/dialog";
|
||||
import { t } from "../../../services/i18n";
|
||||
import Api from "./api";
|
||||
|
||||
export function openColumnContextMenu(api: Api, event: ContextMenuEvent, column: string) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
|
||||
contextMenu.show({
|
||||
x: event.pageX,
|
||||
y: event.pageY,
|
||||
items: [
|
||||
{
|
||||
title: t("board_view.delete-column"),
|
||||
uiIcon: "bx bx-trash",
|
||||
async handler() {
|
||||
const confirmed = await dialog.confirm(t("board_view.delete-column-confirmation"));
|
||||
if (!confirmed) {
|
||||
return;
|
||||
}
|
||||
|
||||
await api.removeColumn(column);
|
||||
}
|
||||
}
|
||||
],
|
||||
selectMenuItemHandler() {}
|
||||
});
|
||||
}
|
||||
|
||||
export function openNoteContextMenu(api: Api, event: ContextMenuEvent, note: FNote, branchId: string, column: string) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
|
||||
contextMenu.show({
|
||||
x: event.pageX,
|
||||
y: event.pageY,
|
||||
items: [
|
||||
...link_context_menu.getItems(),
|
||||
{ title: "----" },
|
||||
{
|
||||
title: t("board_view.move-to"),
|
||||
uiIcon: "bx bx-transfer",
|
||||
items: api.columns.map(columnToMoveTo => ({
|
||||
title: columnToMoveTo,
|
||||
enabled: columnToMoveTo !== column,
|
||||
handler: () => api.changeColumn(note.noteId, columnToMoveTo)
|
||||
})),
|
||||
},
|
||||
getArchiveMenuItem(note),
|
||||
{ title: "----" },
|
||||
{
|
||||
title: t("board_view.insert-above"),
|
||||
uiIcon: "bx bx-list-plus",
|
||||
handler: () => api.insertRowAtPosition(column, branchId, "before")
|
||||
},
|
||||
{
|
||||
title: t("board_view.insert-below"),
|
||||
uiIcon: "bx bx-empty",
|
||||
handler: () => api.insertRowAtPosition(column, branchId, "after")
|
||||
},
|
||||
{ title: "----" },
|
||||
{
|
||||
title: t("board_view.remove-from-board"),
|
||||
uiIcon: "bx bx-task-x",
|
||||
handler: () => api.removeFromBoard(note.noteId)
|
||||
},
|
||||
{
|
||||
title: t("board_view.delete-note"),
|
||||
uiIcon: "bx bx-trash",
|
||||
handler: () => branches.deleteNotes([ branchId ], false, false)
|
||||
},
|
||||
],
|
||||
selectMenuItemHandler: ({ command }) => link_context_menu.handleLinkContextMenuItem(command, note.noteId),
|
||||
});
|
||||
}
|
||||
|
||||
function getArchiveMenuItem(note: FNote) {
|
||||
if (!note.isArchived) {
|
||||
return {
|
||||
title: t("board_view.archive-note"),
|
||||
uiIcon: "bx bx-archive",
|
||||
handler: () => attributes.addLabel(note.noteId, "archived")
|
||||
}
|
||||
} else {
|
||||
return {
|
||||
title: t("board_view.unarchive-note"),
|
||||
uiIcon: "bx bx-archive-out",
|
||||
handler: async () => {
|
||||
attributes.removeOwnedLabelByName(note, "archived")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
@ -1,17 +1,17 @@
|
||||
import FBranch from "../../../entities/fbranch";
|
||||
import FNote from "../../../entities/fnote";
|
||||
import { BoardData } from "./config";
|
||||
import { BoardViewData } from "./index";
|
||||
|
||||
export type ColumnMap = Map<string, {
|
||||
branch: FBranch;
|
||||
note: FNote;
|
||||
}[]>;
|
||||
|
||||
export async function getBoardData(parentNote: FNote, groupByColumn: string, persistedData: BoardData) {
|
||||
export async function getBoardData(parentNote: FNote, groupByColumn: string, persistedData: BoardViewData, includeArchived: boolean) {
|
||||
const byColumn: ColumnMap = new Map();
|
||||
|
||||
// First, scan all notes to find what columns actually exist
|
||||
await recursiveGroupBy(parentNote.getChildBranches(), byColumn, groupByColumn);
|
||||
await recursiveGroupBy(parentNote.getChildBranches(), byColumn, groupByColumn, includeArchived);
|
||||
|
||||
// Get all columns that exist in the notes
|
||||
const columnsFromNotes = [...byColumn.keys()];
|
||||
@ -43,7 +43,7 @@ export async function getBoardData(parentNote: FNote, groupByColumn: string, per
|
||||
}
|
||||
|
||||
// Return updated persisted data only if there were changes
|
||||
let newPersistedData: BoardData | undefined;
|
||||
let newPersistedData: BoardViewData | undefined;
|
||||
const hasChanges = newColumnValues.length > 0 ||
|
||||
existingPersistedColumns.length !== deduplicatedColumns.length ||
|
||||
!existingPersistedColumns.every((col, idx) => deduplicatedColumns[idx]?.value === col.value);
|
||||
@ -61,15 +61,13 @@ export async function getBoardData(parentNote: FNote, groupByColumn: string, per
|
||||
};
|
||||
}
|
||||
|
||||
async function recursiveGroupBy(branches: FBranch[], byColumn: ColumnMap, groupByColumn: string) {
|
||||
async function recursiveGroupBy(branches: FBranch[], byColumn: ColumnMap, groupByColumn: string, includeArchived: boolean) {
|
||||
for (const branch of branches) {
|
||||
const note = await branch.getNote();
|
||||
if (!note) {
|
||||
continue;
|
||||
}
|
||||
if (!note || (!includeArchived && note.isArchived)) continue;
|
||||
|
||||
if (note.hasChildren()) {
|
||||
await recursiveGroupBy(note.getChildBranches(), byColumn, groupByColumn);
|
||||
await recursiveGroupBy(note.getChildBranches(), byColumn, groupByColumn, includeArchived);
|
||||
}
|
||||
|
||||
const group = note.getLabelValue(groupByColumn);
|
313
apps/client/src/widgets/collections/board/index.css
Normal file
313
apps/client/src/widgets/collections/board/index.css
Normal file
@ -0,0 +1,313 @@
|
||||
.board-view {
|
||||
overflow-x: auto;
|
||||
position: relative;
|
||||
height: 100%;
|
||||
user-select: none;
|
||||
|
||||
--card-font-size: 0.9em;
|
||||
--card-line-height: 1.2;
|
||||
--card-padding: 0.6em;
|
||||
}
|
||||
|
||||
.board-view-container {
|
||||
height: 100%;
|
||||
display: flex;
|
||||
gap: 1em;
|
||||
padding: 1em;
|
||||
padding-bottom: 0;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.board-view-container .board-column {
|
||||
width: 250px;
|
||||
flex-shrink: 0;
|
||||
border: 2px solid transparent;
|
||||
border-radius: 8px;
|
||||
padding: 0.5em;
|
||||
background-color: var(--accented-background-color);
|
||||
transition: border-color 0.2s ease;
|
||||
overflow-y: auto;
|
||||
max-height: 100%;
|
||||
}
|
||||
|
||||
.board-view-container .board-column.drag-over {
|
||||
border-color: var(--main-text-color);
|
||||
background-color: var(--hover-item-background-color);
|
||||
}
|
||||
|
||||
.board-view-container .board-column h3 {
|
||||
font-size: 1em;
|
||||
margin-bottom: 0.75em;
|
||||
padding: 0.5em 0.5em 0.5em 0.5em;
|
||||
border-bottom: 1px solid var(--main-border-color);
|
||||
cursor: grab;
|
||||
position: relative;
|
||||
transition: background-color 0.2s ease, border-radius 0.2s ease;
|
||||
box-sizing: border-box;
|
||||
background-color: transparent;
|
||||
}
|
||||
|
||||
.board-view-container .board-column h3 {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.board-view-container .board-column h3 > .title {
|
||||
flex-grow: 1;
|
||||
}
|
||||
|
||||
.board-view-container .board-column h3:active {
|
||||
cursor: grabbing;
|
||||
}
|
||||
|
||||
.board-view-container .board-column h3.editing {
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.board-view-container .board-column h3.editing input {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.board-view-container .board-column h3:hover {
|
||||
background-color: var(--hover-item-background-color);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.board-view-container .board-column h3.editing {
|
||||
background-color: var(--main-background-color);
|
||||
border: 1px solid var(--main-text-color);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.board-view-container .board-column h3 input {
|
||||
background: transparent;
|
||||
border: none;
|
||||
outline: none;
|
||||
font-size: inherit;
|
||||
font-weight: inherit;
|
||||
color: inherit;
|
||||
width: 100%;
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
.board-view-container .board-column .edit-icon {
|
||||
opacity: 0;
|
||||
margin-left: 0.5em;
|
||||
transition: opacity 0.2s ease;
|
||||
color: var(--muted-text-color);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.board-view-container .board-column h3:hover .edit-icon,
|
||||
.board-view-container .board-note:hover .edit-icon {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
|
||||
.board-view-container .board-note,
|
||||
.board-view-container .board-new-item.editing {
|
||||
box-shadow: 1px 1px 4px rgba(0, 0, 0, 0.1);
|
||||
margin: 0.65em 0;
|
||||
padding: var(--card-padding);
|
||||
border-radius: 5px;
|
||||
cursor: move;
|
||||
position: relative;
|
||||
background-color: var(--main-background-color);
|
||||
border: 1px solid var(--main-border-color);
|
||||
opacity: 1;
|
||||
line-height: var(--card-line-height);
|
||||
overflow-wrap: break-word;
|
||||
overflow: hidden;
|
||||
font-size: var(--card-font-size);
|
||||
}
|
||||
|
||||
.board-view-container .board-note {
|
||||
transition: transform 0.2s ease, box-shadow 0.2s ease, opacity 0.15s ease, margin-top 0.2s ease;
|
||||
}
|
||||
|
||||
.board-view-container .board-note.archived {
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.board-view-container .board-note .icon {
|
||||
margin-right: 0.25em;
|
||||
display: inline;
|
||||
}
|
||||
|
||||
.board-view-container .board-note > .edit-icon {
|
||||
position: absolute;
|
||||
top: 8px;
|
||||
right: 4px;
|
||||
padding: 2px;
|
||||
background-color: var(--main-background-color);
|
||||
}
|
||||
|
||||
.board-view-container .board-note:hover {
|
||||
box-shadow: 2px 4px 8px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.board-view-container .board-note:hover > .edit-icon {
|
||||
position: absolute;
|
||||
top: 8px;
|
||||
right: 4px;
|
||||
color: var(--main-text-color);
|
||||
background-color: var(--main-background-color);
|
||||
padding-left: 6px;
|
||||
}
|
||||
|
||||
.board-view-container .board-note.fade-in {
|
||||
animation: fadeIn 0.15s ease-in;
|
||||
}
|
||||
|
||||
.board-view-container .board-note.fade-out {
|
||||
animation: fadeOut 0.15s ease-out forwards;
|
||||
}
|
||||
|
||||
@keyframes fadeIn {
|
||||
from { opacity: 0; transform: translateY(-10px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
|
||||
@keyframes fadeOut {
|
||||
from { opacity: 1; transform: translateY(0); }
|
||||
to { opacity: 0; transform: translateY(-10px); }
|
||||
}
|
||||
|
||||
.board-view-container .board-note.dragging {
|
||||
opacity: 0.5;
|
||||
transform: rotate(5deg);
|
||||
z-index: 1000;
|
||||
box-shadow: 4px 8px 16px rgba(0, 0, 0, 0.5);
|
||||
}
|
||||
|
||||
.board-view-container .board-note.editing,
|
||||
.board-view-container .board-new-item.editing {
|
||||
box-shadow: 2px 4px 8px rgba(0, 0, 0, 0.2);
|
||||
border-color: var(--main-text-color);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.board-view-container .board-note.editing textarea,
|
||||
.board-view-container .board-new-item textarea.form-control {
|
||||
background: transparent;
|
||||
border: none;
|
||||
outline: none;
|
||||
font-family: inherit;
|
||||
font-size: inherit;
|
||||
color: var(--main-text-color);
|
||||
width: 100%;
|
||||
padding: var(--card-padding);
|
||||
line-height: var(--card-line-height);
|
||||
height: auto;
|
||||
field-sizing: content;
|
||||
resize: none;
|
||||
}
|
||||
|
||||
.board-drop-placeholder {
|
||||
height: 40px;
|
||||
margin: 0.65em 0;
|
||||
padding: 0.5em;
|
||||
border-radius: 5px;
|
||||
background-color: rgba(0, 0, 0, 0.15);
|
||||
opacity: 0;
|
||||
transition: opacity 0.15s ease, height 0.2s ease;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.board-drop-placeholder.show {
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.column-drop-placeholder {
|
||||
width: 250px;
|
||||
flex-shrink: 0;
|
||||
height: 200px;
|
||||
border-radius: 8px;
|
||||
background-color: rgba(0, 0, 0, 0.1);
|
||||
opacity: 0;
|
||||
transition: opacity 0.15s ease;
|
||||
margin: 0 0.5em;
|
||||
}
|
||||
|
||||
.column-drop-placeholder.show {
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.board-new-item {
|
||||
margin-top: 0.5em;
|
||||
padding: 0.25em 0.5em;
|
||||
border-radius: 5px;
|
||||
color: var(--muted-text-color);
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
background-color: transparent;
|
||||
font-size: var(--card-font-size);
|
||||
}
|
||||
|
||||
.board-new-item:hover {
|
||||
border-color: var(--main-text-color);
|
||||
color: var(--main-text-color);
|
||||
background-color: var(--hover-item-background-color);
|
||||
}
|
||||
|
||||
.board-new-item .icon {
|
||||
margin-right: 0.25em;
|
||||
}
|
||||
|
||||
.board-add-column {
|
||||
width: 180px;
|
||||
flex-shrink: 0;
|
||||
height: 60px;
|
||||
border-radius: 8px;
|
||||
padding: 0.5em;
|
||||
background-color: var(--accented-background-color);
|
||||
transition: all 0.2s ease;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
color: var(--muted-text-color);
|
||||
font-size: 0.9em;
|
||||
align-self: flex-start;
|
||||
}
|
||||
|
||||
.board-add-column:hover {
|
||||
border-color: var(--main-text-color);
|
||||
color: var(--main-text-color);
|
||||
background-color: var(--hover-item-background-color);
|
||||
}
|
||||
|
||||
.board-add-column .icon {
|
||||
margin-right: 0.5em;
|
||||
font-size: 1.2em;
|
||||
}
|
||||
|
||||
.board-add-column input {
|
||||
background: var(--main-background-color);
|
||||
border: 1px solid var(--main-text-color);
|
||||
border-radius: 4px;
|
||||
padding: 0.5em;
|
||||
color: var(--main-text-color);
|
||||
font-family: inherit;
|
||||
font-size: inherit;
|
||||
width: 100%;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.board-drag-preview {
|
||||
position: fixed;
|
||||
z-index: 10000;
|
||||
pointer-events: none;
|
||||
opacity: 0.8;
|
||||
transform: rotate(5deg);
|
||||
box-shadow: 4px 8px 16px rgba(0, 0, 0, 0.5);
|
||||
background-color: var(--main-background-color);
|
||||
border: 1px solid var(--main-border-color);
|
||||
border-radius: 5px;
|
||||
padding: 0.5em;
|
||||
font-size: 0.9em;
|
||||
max-width: 200px;
|
||||
word-wrap: break-word;
|
||||
}
|
276
apps/client/src/widgets/collections/board/index.tsx
Normal file
276
apps/client/src/widgets/collections/board/index.tsx
Normal file
@ -0,0 +1,276 @@
|
||||
import { Dispatch, StateUpdater, useCallback, useEffect, useMemo, useRef, useState } from "preact/hooks";
|
||||
import { ViewModeProps } from "../interface";
|
||||
import "./index.css";
|
||||
import { ColumnMap, getBoardData } from "./data";
|
||||
import { useNoteLabelBoolean, useNoteLabelWithDefault, useTriliumEvent } from "../../react/hooks";
|
||||
import Icon from "../../react/Icon";
|
||||
import { t } from "../../../services/i18n";
|
||||
import Api from "./api";
|
||||
import FormTextBox from "../../react/FormTextBox";
|
||||
import { createContext, JSX } from "preact";
|
||||
import { onWheelHorizontalScroll } from "../../widget_utils";
|
||||
import Column from "./column";
|
||||
import BoardApi from "./api";
|
||||
import FormTextArea from "../../react/FormTextArea";
|
||||
import FNote from "../../../entities/fnote";
|
||||
|
||||
export interface BoardViewData {
|
||||
columns?: BoardColumnData[];
|
||||
}
|
||||
|
||||
export interface BoardColumnData {
|
||||
value: string;
|
||||
}
|
||||
|
||||
interface BoardViewContextData {
|
||||
api?: BoardApi;
|
||||
parentNote?: FNote;
|
||||
branchIdToEdit?: string;
|
||||
columnNameToEdit?: string;
|
||||
setColumnNameToEdit?: Dispatch<StateUpdater<string | undefined>>;
|
||||
setBranchIdToEdit?: Dispatch<StateUpdater<string | undefined>>;
|
||||
draggedColumn: { column: string, index: number } | null;
|
||||
setDraggedColumn: (column: { column: string, index: number } | null) => void;
|
||||
dropPosition: { column: string, index: number } | null;
|
||||
setDropPosition: (position: { column: string, index: number } | null) => void;
|
||||
setDropTarget: (target: string | null) => void,
|
||||
dropTarget: string | null;
|
||||
draggedCard: { noteId: string, branchId: string, fromColumn: string, index: number } | null;
|
||||
setDraggedCard: Dispatch<StateUpdater<{ noteId: string; branchId: string; fromColumn: string; index: number; } | null>>;
|
||||
}
|
||||
|
||||
export const BoardViewContext = createContext<BoardViewContextData | undefined>(undefined);
|
||||
|
||||
export default function BoardView({ note: parentNote, noteIds, viewConfig, saveConfig }: ViewModeProps<BoardViewData>) {
|
||||
const [ statusAttribute ] = useNoteLabelWithDefault(parentNote, "board:groupBy", "status");
|
||||
const [ includeArchived ] = useNoteLabelBoolean(parentNote, "includeArchived");
|
||||
const [ byColumn, setByColumn ] = useState<ColumnMap>();
|
||||
const [ columns, setColumns ] = useState<string[]>();
|
||||
const [ draggedCard, setDraggedCard ] = useState<{ noteId: string, branchId: string, fromColumn: string, index: number } | null>(null);
|
||||
const [ dropTarget, setDropTarget ] = useState<string | null>(null);
|
||||
const [ dropPosition, setDropPosition ] = useState<{ column: string, index: number } | null>(null);
|
||||
const [ draggedColumn, setDraggedColumn ] = useState<{ column: string, index: number } | null>(null);
|
||||
const [ columnDropPosition, setColumnDropPosition ] = useState<number | null>(null);
|
||||
const [ columnHoverIndex, setColumnHoverIndex ] = useState<number | null>(null);
|
||||
const [ branchIdToEdit, setBranchIdToEdit ] = useState<string>();
|
||||
const [ columnNameToEdit, setColumnNameToEdit ] = useState<string>();
|
||||
const api = useMemo(() => {
|
||||
return new Api(byColumn, columns ?? [], parentNote, statusAttribute, viewConfig ?? {}, saveConfig, setBranchIdToEdit );
|
||||
}, [ byColumn, columns, parentNote, statusAttribute, viewConfig, saveConfig, setBranchIdToEdit ]);
|
||||
const boardViewContext = useMemo<BoardViewContextData>(() => ({
|
||||
api,
|
||||
parentNote,
|
||||
branchIdToEdit, setBranchIdToEdit,
|
||||
columnNameToEdit, setColumnNameToEdit,
|
||||
draggedColumn, setDraggedColumn,
|
||||
dropPosition, setDropPosition,
|
||||
draggedCard, setDraggedCard,
|
||||
dropTarget, setDropTarget
|
||||
}), [
|
||||
api,
|
||||
parentNote,
|
||||
branchIdToEdit, setBranchIdToEdit,
|
||||
columnNameToEdit, setColumnNameToEdit,
|
||||
draggedColumn, setDraggedColumn,
|
||||
dropPosition, setDropPosition,
|
||||
draggedCard, setDraggedCard,
|
||||
dropTarget, setDropTarget
|
||||
]);
|
||||
|
||||
function refresh() {
|
||||
getBoardData(parentNote, statusAttribute, viewConfig ?? {}, includeArchived).then(({ byColumn, newPersistedData }) => {
|
||||
setByColumn(byColumn);
|
||||
|
||||
if (newPersistedData) {
|
||||
viewConfig = { ...newPersistedData };
|
||||
saveConfig(newPersistedData);
|
||||
}
|
||||
|
||||
// Use the order from persistedData.columns, then add any new columns found
|
||||
const orderedColumns = viewConfig?.columns?.map(col => col.value) || [];
|
||||
const allColumns = Array.from(byColumn.keys());
|
||||
const newColumns = allColumns.filter(col => !orderedColumns.includes(col));
|
||||
setColumns([...orderedColumns, ...newColumns]);
|
||||
});
|
||||
}
|
||||
|
||||
useEffect(refresh, [ parentNote, noteIds, viewConfig ]);
|
||||
|
||||
const handleColumnDrop = useCallback((fromIndex: number, toIndex: number) => {
|
||||
const newColumns = api.reorderColumn(fromIndex, toIndex);
|
||||
if (newColumns) {
|
||||
setColumns(newColumns);
|
||||
}
|
||||
setDraggedColumn(null);
|
||||
setDraggedCard(null);
|
||||
setColumnDropPosition(null);
|
||||
}, [api]);
|
||||
|
||||
useTriliumEvent("entitiesReloaded", ({ loadResults }) => {
|
||||
// Check if any changes affect our board
|
||||
const hasRelevantChanges =
|
||||
// React to changes in status attribute for notes in this board
|
||||
loadResults.getAttributeRows().some(attr => attr.name === statusAttribute && noteIds.includes(attr.noteId!)) ||
|
||||
// React to changes in note title
|
||||
loadResults.getNoteIds().some(noteId => noteIds.includes(noteId)) ||
|
||||
// React to changes in branches for subchildren (e.g., moved, added, or removed notes)
|
||||
loadResults.getBranchRows().some(branch => noteIds.includes(branch.noteId!)) ||
|
||||
// React to changes in note icon or color.
|
||||
loadResults.getAttributeRows().some(attr => [ "iconClass", "color" ].includes(attr.name ?? "") && noteIds.includes(attr.noteId ?? "")) ||
|
||||
// React to attachment change
|
||||
loadResults.getAttachmentRows().some(att => att.ownerId === parentNote.noteId && att.title === "board.json") ||
|
||||
// React to changes in "groupBy"
|
||||
loadResults.getAttributeRows().some(attr => attr.name === "board:groupBy" && attr.noteId === parentNote.noteId);
|
||||
|
||||
if (hasRelevantChanges) {
|
||||
refresh();
|
||||
}
|
||||
});
|
||||
|
||||
const handleColumnDragOver = useCallback((e: DragEvent) => {
|
||||
if (!draggedColumn) return;
|
||||
e.preventDefault();
|
||||
}, [draggedColumn]);
|
||||
|
||||
const handleColumnHover = useCallback((index: number, mouseX: number, columnRect: DOMRect) => {
|
||||
if (!draggedColumn) return;
|
||||
|
||||
const columnMiddle = columnRect.left + columnRect.width / 2;
|
||||
|
||||
// Determine if we should insert before or after this column
|
||||
const insertBefore = mouseX < columnMiddle;
|
||||
|
||||
// Calculate the target position
|
||||
let targetIndex = insertBefore ? index : index + 1;
|
||||
|
||||
setColumnDropPosition(targetIndex);
|
||||
}, [draggedColumn]);
|
||||
|
||||
const handleContainerDrop = useCallback((e: DragEvent) => {
|
||||
e.preventDefault();
|
||||
if (draggedColumn && columnDropPosition !== null) {
|
||||
handleColumnDrop(draggedColumn.index, columnDropPosition);
|
||||
}
|
||||
setColumnHoverIndex(null);
|
||||
}, [draggedColumn, columnDropPosition, handleColumnDrop]);
|
||||
|
||||
return (
|
||||
<div
|
||||
className="board-view"
|
||||
onWheel={onWheelHorizontalScroll}
|
||||
>
|
||||
<BoardViewContext.Provider value={boardViewContext}>
|
||||
<div
|
||||
className="board-view-container"
|
||||
onDragOver={handleColumnDragOver}
|
||||
onDrop={handleContainerDrop}
|
||||
>
|
||||
{byColumn && columns?.map((column, index) => (
|
||||
<>
|
||||
{columnDropPosition === index && (
|
||||
<div className="column-drop-placeholder show" />
|
||||
)}
|
||||
<Column
|
||||
api={api}
|
||||
column={column}
|
||||
columnIndex={index}
|
||||
columnItems={byColumn.get(column)}
|
||||
isDraggingColumn={draggedColumn?.column === column}
|
||||
onColumnHover={handleColumnHover}
|
||||
isAnyColumnDragging={!!draggedColumn}
|
||||
/>
|
||||
</>
|
||||
))}
|
||||
{columnDropPosition === columns?.length && draggedColumn && (
|
||||
<div className="column-drop-placeholder show" />
|
||||
)}
|
||||
|
||||
<AddNewColumn api={api} />
|
||||
</div>
|
||||
</BoardViewContext.Provider>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function AddNewColumn({ api }: { api: BoardApi }) {
|
||||
const [ isCreatingNewColumn, setIsCreatingNewColumn ] = useState(false);
|
||||
|
||||
const addColumnCallback = useCallback(() => {
|
||||
setIsCreatingNewColumn(true);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className={`board-add-column ${isCreatingNewColumn ? "editing" : ""}`} onClick={addColumnCallback}>
|
||||
{!isCreatingNewColumn
|
||||
? <>
|
||||
<Icon icon="bx bx-plus" />{" "}
|
||||
{t("board_view.add-column")}
|
||||
</>
|
||||
: (
|
||||
<TitleEditor
|
||||
placeholder={t("board_view.add-column-placeholder")}
|
||||
save={(columnName) => api.addNewColumn(columnName)}
|
||||
dismiss={() => setIsCreatingNewColumn(false)}
|
||||
isNewItem
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function TitleEditor({ currentValue, placeholder, save, dismiss, multiline, isNewItem }: {
|
||||
currentValue?: string;
|
||||
placeholder?: string;
|
||||
save: (newValue: string) => void;
|
||||
dismiss: () => void;
|
||||
multiline?: boolean;
|
||||
isNewItem?: boolean;
|
||||
}) {
|
||||
const inputRef = useRef<any>(null);
|
||||
const dismissOnNextRefreshRef = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
inputRef.current?.focus();
|
||||
inputRef.current?.select();
|
||||
}, [ inputRef ]);
|
||||
|
||||
const Element = multiline ? FormTextArea : FormTextBox;
|
||||
|
||||
useEffect(() => {
|
||||
if (dismissOnNextRefreshRef.current) {
|
||||
dismiss();
|
||||
dismissOnNextRefreshRef.current = false;
|
||||
}
|
||||
});
|
||||
|
||||
return (
|
||||
<Element
|
||||
inputRef={inputRef}
|
||||
currentValue={currentValue ?? ""}
|
||||
placeholder={placeholder}
|
||||
autoComplete="trilium-title-entry" // forces the auto-fill off better than the "off" value.
|
||||
rows={multiline ? 4 : undefined}
|
||||
onKeyDown={(e: JSX.TargetedKeyboardEvent<HTMLInputElement | HTMLTextAreaElement>) => {
|
||||
if (e.key === "Enter") {
|
||||
const newValue = e.currentTarget?.value;
|
||||
if (newValue.trim() && (newValue !== currentValue || isNewItem)) {
|
||||
save(newValue);
|
||||
dismissOnNextRefreshRef.current = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (e.key === "Escape") {
|
||||
dismiss();
|
||||
}
|
||||
}}
|
||||
onBlur={(newValue) => {
|
||||
if (newValue.trim() && (newValue !== currentValue || isNewItem)) {
|
||||
save(newValue);
|
||||
dismissOnNextRefreshRef.current = true;
|
||||
} else {
|
||||
dismiss();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
65
apps/client/src/widgets/collections/calendar/api.ts
Normal file
65
apps/client/src/widgets/collections/calendar/api.ts
Normal file
@ -0,0 +1,65 @@
|
||||
import { CreateChildrenResponse } from "@triliumnext/commons";
|
||||
import server from "../../../services/server";
|
||||
import FNote from "../../../entities/fnote";
|
||||
import { setAttribute, setLabel } from "../../../services/attributes";
|
||||
import froca from "../../../services/froca";
|
||||
|
||||
interface NewEventOpts {
|
||||
title: string;
|
||||
startDate: string;
|
||||
endDate?: string | null;
|
||||
startTime?: string | null;
|
||||
endTime?: string | null;
|
||||
}
|
||||
|
||||
interface ChangeEventOpts {
|
||||
startDate: string;
|
||||
endDate?: string | null;
|
||||
startTime?: string | null;
|
||||
endTime?: string | null;
|
||||
}
|
||||
|
||||
export async function newEvent(parentNote: FNote, { title, startDate, endDate, startTime, endTime }: NewEventOpts) {
|
||||
// Create the note.
|
||||
const { note } = await server.post<CreateChildrenResponse>(`notes/${parentNote.noteId}/children?target=into`, {
|
||||
title,
|
||||
content: "",
|
||||
type: "text"
|
||||
});
|
||||
|
||||
// Set the attributes.
|
||||
setLabel(note.noteId, "startDate", startDate);
|
||||
if (endDate) {
|
||||
setLabel(note.noteId, "endDate", endDate);
|
||||
}
|
||||
if (startTime) {
|
||||
setLabel(note.noteId, "startTime", startTime);
|
||||
}
|
||||
if (endTime) {
|
||||
setLabel(note.noteId, "endTime", endTime);
|
||||
}
|
||||
}
|
||||
|
||||
export async function changeEvent(note: FNote, { startDate, endDate, startTime, endTime }: ChangeEventOpts) {
|
||||
// Don't store the end date if it's empty.
|
||||
if (endDate === startDate) {
|
||||
endDate = undefined;
|
||||
}
|
||||
|
||||
// Since they can be customized via calendar:startDate=$foo and calendar:endDate=$bar we need to determine the
|
||||
// attributes to be effectively updated
|
||||
let startAttribute = note.getAttributes("label").filter(attr => attr.name == "calendar:startDate").shift()?.value||"startDate";
|
||||
let endAttribute = note.getAttributes("label").filter(attr => attr.name == "calendar:endDate").shift()?.value||"endDate";
|
||||
|
||||
const noteId = note.noteId;
|
||||
setLabel(noteId, startAttribute, startDate);
|
||||
setAttribute(note, "label", endAttribute, endDate);
|
||||
|
||||
startAttribute = note.getAttributes("label").filter(attr => attr.name == "calendar:startTime").shift()?.value||"startTime";
|
||||
endAttribute = note.getAttributes("label").filter(attr => attr.name == "calendar:endTime").shift()?.value||"endTime";
|
||||
|
||||
if (startTime && endTime) {
|
||||
setAttribute(note, "label", startAttribute, startTime);
|
||||
setAttribute(note, "label", endAttribute, endTime);
|
||||
}
|
||||
}
|
32
apps/client/src/widgets/collections/calendar/calendar.tsx
Normal file
32
apps/client/src/widgets/collections/calendar/calendar.tsx
Normal file
@ -0,0 +1,32 @@
|
||||
import { useEffect, useLayoutEffect, useRef } from "preact/hooks";
|
||||
import { CalendarOptions, Calendar as FullCalendar, PluginDef } from "@fullcalendar/core";
|
||||
import { RefObject } from "preact";
|
||||
|
||||
interface CalendarProps extends CalendarOptions {
|
||||
calendarRef?: RefObject<FullCalendar>;
|
||||
}
|
||||
|
||||
export default function Calendar({ calendarRef, ...options }: CalendarProps) {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (!containerRef.current) return;
|
||||
|
||||
const calendar = new FullCalendar(containerRef.current, options);
|
||||
calendar.render();
|
||||
|
||||
if (calendarRef) {
|
||||
calendarRef.current = calendar;
|
||||
}
|
||||
|
||||
return () => calendar.destroy();
|
||||
}, [ ]);
|
||||
|
||||
useEffect(() => {
|
||||
calendarRef?.current?.resetOptions(options);
|
||||
}, [ options ]);
|
||||
|
||||
return (
|
||||
<div ref={containerRef} className="calendar-container" />
|
||||
);
|
||||
}
|
@ -1,6 +1,7 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { buildNote, buildNotes } from "../../test/easy-froca.js";
|
||||
import CalendarView, { getFullCalendarLocale } from "./calendar_view.js";
|
||||
import { buildNote, buildNotes } from "../../../test/easy-froca.js";
|
||||
import { buildEvent, buildEvents } from "./event_builder.js";
|
||||
import { LOCALE_MAPPINGS } from "./index.js";
|
||||
import { LOCALES } from "@triliumnext/commons";
|
||||
|
||||
describe("Building events", () => {
|
||||
@ -9,7 +10,7 @@ describe("Building events", () => {
|
||||
{ title: "Note 1", "#startDate": "2025-05-05" },
|
||||
{ title: "Note 2", "#startDate": "2025-05-07" },
|
||||
]);
|
||||
const events = await CalendarView.buildEvents(noteIds);
|
||||
const events = await buildEvents(noteIds);
|
||||
|
||||
expect(events).toHaveLength(2);
|
||||
expect(events[0]).toMatchObject({ title: "Note 1", start: "2025-05-05", end: "2025-05-06" });
|
||||
@ -21,7 +22,7 @@ describe("Building events", () => {
|
||||
{ title: "Note 1", "#endDate": "2025-05-05" },
|
||||
{ title: "Note 2", "#endDateDate": "2025-05-07" }
|
||||
]);
|
||||
const events = await CalendarView.buildEvents(noteIds);
|
||||
const events = await buildEvents(noteIds);
|
||||
|
||||
expect(events).toHaveLength(0);
|
||||
});
|
||||
@ -31,7 +32,7 @@ describe("Building events", () => {
|
||||
{ title: "Note 1", "#startDate": "2025-05-05", "#endDate": "2025-05-05" },
|
||||
{ title: "Note 2", "#startDate": "2025-05-07", "#endDate": "2025-05-08" },
|
||||
]);
|
||||
const events = await CalendarView.buildEvents(noteIds);
|
||||
const events = await buildEvents(noteIds);
|
||||
|
||||
expect(events).toHaveLength(2);
|
||||
expect(events[0]).toMatchObject({ title: "Note 1", start: "2025-05-05", end: "2025-05-06" });
|
||||
@ -43,7 +44,7 @@ describe("Building events", () => {
|
||||
{ title: "Note 1", "#myStartDate": "2025-05-05", "#calendar:startDate": "myStartDate" },
|
||||
{ title: "Note 2", "#startDate": "2025-05-07", "#calendar:startDate": "myStartDate" },
|
||||
]);
|
||||
const events = await CalendarView.buildEvents(noteIds);
|
||||
const events = await buildEvents(noteIds);
|
||||
|
||||
expect(events).toHaveLength(2);
|
||||
expect(events[0]).toMatchObject({
|
||||
@ -65,7 +66,7 @@ describe("Building events", () => {
|
||||
{ title: "Note 3", "#startDate": "2025-05-05", "#myEndDate": "2025-05-05", "#calendar:startDate": "myStartDate", "#calendar:endDate": "myEndDate" },
|
||||
{ title: "Note 4", "#startDate": "2025-05-07", "#myEndDate": "2025-05-08", "#calendar:startDate": "myStartDate", "#calendar:endDate": "myEndDate" },
|
||||
]);
|
||||
const events = await CalendarView.buildEvents(noteIds);
|
||||
const events = await buildEvents(noteIds);
|
||||
|
||||
expect(events).toHaveLength(4);
|
||||
expect(events[0]).toMatchObject({ title: "Note 1", start: "2025-05-05", end: "2025-05-06" });
|
||||
@ -79,7 +80,7 @@ describe("Building events", () => {
|
||||
{ title: "Note 1", "#myTitle": "My Custom Title 1", "#startDate": "2025-05-05", "#calendar:title": "myTitle" },
|
||||
{ title: "Note 2", "#startDate": "2025-05-07", "#calendar:title": "myTitle" },
|
||||
]);
|
||||
const events = await CalendarView.buildEvents(noteIds);
|
||||
const events = await buildEvents(noteIds);
|
||||
|
||||
expect(events).toHaveLength(2);
|
||||
expect(events[0]).toMatchObject({ title: "My Custom Title 1", start: "2025-05-05" });
|
||||
@ -92,7 +93,7 @@ describe("Building events", () => {
|
||||
{ title: "Note 1", "~myTitle": "mySharedTitle", "#startDate": "2025-05-05", "#calendar:title": "myTitle" },
|
||||
{ title: "Note 2", "#startDate": "2025-05-07", "#calendar:title": "myTitle" },
|
||||
]);
|
||||
const events = await CalendarView.buildEvents(noteIds);
|
||||
const events = await buildEvents(noteIds);
|
||||
|
||||
expect(events).toHaveLength(2);
|
||||
expect(events[0]).toMatchObject({ title: "My shared title", start: "2025-05-05" });
|
||||
@ -105,7 +106,7 @@ describe("Building events", () => {
|
||||
{ title: "Note 1", "~myTitle": "mySharedTitle", "#startDate": "2025-05-05", "#calendar:title": "myTitle" },
|
||||
{ title: "Note 2", "#startDate": "2025-05-07", "#calendar:title": "myTitle" },
|
||||
]);
|
||||
const events = await CalendarView.buildEvents(noteIds);
|
||||
const events = await buildEvents(noteIds);
|
||||
|
||||
expect(events).toHaveLength(2);
|
||||
expect(events[0]).toMatchObject({ title: "My shared custom title", start: "2025-05-05" });
|
||||
@ -125,7 +126,7 @@ describe("Promoted attributes", () => {
|
||||
"#calendar:displayedAttributes": "weight,mood"
|
||||
});
|
||||
|
||||
const event = await CalendarView.buildEvent(note, { startDate: "2025-04-04" });
|
||||
const event = await buildEvent(note, { startDate: "2025-04-04" });
|
||||
expect(event).toHaveLength(1);
|
||||
expect(event[0]?.promotedAttributes).toMatchObject([
|
||||
[ "weight", "75" ],
|
||||
@ -143,7 +144,7 @@ describe("Promoted attributes", () => {
|
||||
"#relation:assignee": "promoted,alias=Assignee,single,text",
|
||||
});
|
||||
|
||||
const event = await CalendarView.buildEvent(note, { startDate: "2025-04-04" });
|
||||
const event = await buildEvent(note, { startDate: "2025-04-04" });
|
||||
expect(event).toHaveLength(1);
|
||||
expect(event[0]?.promotedAttributes).toMatchObject([
|
||||
[ "assignee", "Target note" ]
|
||||
@ -155,7 +156,7 @@ describe("Promoted attributes", () => {
|
||||
{ title: "Note 1", "#startDate": "2025-05-05", "#startTime": "13:36", "#endTime": "14:56" },
|
||||
{ title: "Note 2", "#startDate": "2025-05-07", "#endDate": "2025-05-08", "#startTime": "13:36", "#endTime": "14:56" },
|
||||
]);
|
||||
const events = await CalendarView.buildEvents(noteIds);
|
||||
const events = await buildEvents(noteIds);
|
||||
|
||||
expect(events).toHaveLength(2);
|
||||
expect(events[0]).toMatchObject({ title: "Note 1", start: "2025-05-05T13:36:00", end: "2025-05-05T14:56:00" });
|
||||
@ -167,7 +168,7 @@ describe("Promoted attributes", () => {
|
||||
{ title: "Note 1", "#startDate": "2025-05-05", "#startTime": "13:30" },
|
||||
{ title: "Note 2", "#startDate": "2025-05-07", "#endDate": "2025-05-08", "#startTime": "13:36" },
|
||||
]);
|
||||
const events = await CalendarView.buildEvents(noteIds);
|
||||
const events = await buildEvents(noteIds);
|
||||
|
||||
expect(events).toHaveLength(2);
|
||||
expect(events[0]).toMatchObject({ title: "Note 1", start: "2025-05-05T13:30:00" });
|
||||
@ -183,12 +184,12 @@ describe("Building locales", () => {
|
||||
continue;
|
||||
}
|
||||
|
||||
const fullCalendarLocale = await getFullCalendarLocale(id);
|
||||
const fullCalendarLocale = LOCALE_MAPPINGS[id];
|
||||
|
||||
if (id !== "en") {
|
||||
expect(fullCalendarLocale, `For locale ${id}`).toBeDefined();
|
||||
} else {
|
||||
expect(fullCalendarLocale).toBeUndefined();
|
||||
expect(fullCalendarLocale).toBeNull();
|
||||
}
|
||||
}
|
||||
});
|
160
apps/client/src/widgets/collections/calendar/event_builder.ts
Normal file
160
apps/client/src/widgets/collections/calendar/event_builder.ts
Normal file
@ -0,0 +1,160 @@
|
||||
import { EventInput, EventSourceFuncArg, EventSourceInput } from "@fullcalendar/core/index.js";
|
||||
import froca from "../../../services/froca";
|
||||
import { formatDateToLocalISO, getCustomisableLabel, getMonthsInDateRange, offsetDate } from "./utils";
|
||||
import FNote from "../../../entities/fnote";
|
||||
import server from "../../../services/server";
|
||||
|
||||
interface Event {
|
||||
startDate: string,
|
||||
endDate?: string | null,
|
||||
startTime?: string | null,
|
||||
endTime?: string | null,
|
||||
isArchived?: boolean;
|
||||
}
|
||||
|
||||
export async function buildEvents(noteIds: string[]) {
|
||||
const notes = await froca.getNotes(noteIds);
|
||||
const events: EventSourceInput = [];
|
||||
|
||||
for (const note of notes) {
|
||||
const startDate = getCustomisableLabel(note, "startDate", "calendar:startDate");
|
||||
|
||||
if (!startDate) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const endDate = getCustomisableLabel(note, "endDate", "calendar:endDate");
|
||||
const startTime = getCustomisableLabel(note, "startTime", "calendar:startTime");
|
||||
const endTime = getCustomisableLabel(note, "endTime", "calendar:endTime");
|
||||
const isArchived = note.hasLabel("archived");
|
||||
events.push(await buildEvent(note, { startDate, endDate, startTime, endTime, isArchived }));
|
||||
}
|
||||
|
||||
return events.flat();
|
||||
}
|
||||
|
||||
export async function buildEventsForCalendar(note: FNote, e: EventSourceFuncArg) {
|
||||
const events: EventInput[] = [];
|
||||
|
||||
// Gather all the required date note IDs.
|
||||
const dateRange = getMonthsInDateRange(e.startStr, e.endStr);
|
||||
let allDateNoteIds: string[] = [];
|
||||
for (const month of dateRange) {
|
||||
// TODO: Deduplicate get type.
|
||||
const dateNotesForMonth = await server.get<Record<string, string>>(`special-notes/notes-for-month/${month}?calendarRoot=${note.noteId}`);
|
||||
const dateNoteIds = Object.values(dateNotesForMonth);
|
||||
allDateNoteIds = [...allDateNoteIds, ...dateNoteIds];
|
||||
}
|
||||
|
||||
// Request all the date notes.
|
||||
const dateNotes = await froca.getNotes(allDateNoteIds);
|
||||
const childNoteToDateMapping: Record<string, string> = {};
|
||||
for (const dateNote of dateNotes) {
|
||||
const startDate = dateNote.getLabelValue("dateNote");
|
||||
if (!startDate) {
|
||||
continue;
|
||||
}
|
||||
|
||||
events.push(await buildEvent(dateNote, { startDate }));
|
||||
|
||||
if (dateNote.hasChildren()) {
|
||||
const childNoteIds = await dateNote.getSubtreeNoteIds();
|
||||
for (const childNoteId of childNoteIds) {
|
||||
childNoteToDateMapping[childNoteId] = startDate;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Request all child notes of date notes in a single run.
|
||||
const childNoteIds = Object.keys(childNoteToDateMapping);
|
||||
const childNotes = await froca.getNotes(childNoteIds);
|
||||
for (const childNote of childNotes) {
|
||||
const startDate = childNoteToDateMapping[childNote.noteId];
|
||||
const event = await buildEvent(childNote, { startDate });
|
||||
events.push(event);
|
||||
}
|
||||
|
||||
return events.flat();
|
||||
}
|
||||
|
||||
export async function buildEvent(note: FNote, { startDate, endDate, startTime, endTime, isArchived }: Event) {
|
||||
const customTitleAttributeName = note.getLabelValue("calendar:title");
|
||||
const titles = await parseCustomTitle(customTitleAttributeName, note);
|
||||
const color = note.getLabelValue("calendar:color") ?? note.getLabelValue("color");
|
||||
const events: EventInput[] = [];
|
||||
|
||||
const calendarDisplayedAttributes = note.getLabelValue("calendar:displayedAttributes")?.split(",");
|
||||
let displayedAttributesData: Array<[string, string]> | null = null;
|
||||
if (calendarDisplayedAttributes) {
|
||||
displayedAttributesData = await buildDisplayedAttributes(note, calendarDisplayedAttributes);
|
||||
}
|
||||
|
||||
for (const title of titles) {
|
||||
if (startTime && endTime && !endDate) {
|
||||
endDate = startDate;
|
||||
}
|
||||
|
||||
startDate = (startTime ? `${startDate}T${startTime}:00` : startDate);
|
||||
if (!startTime) {
|
||||
const endDateOffset = offsetDate(endDate ?? startDate, 1);
|
||||
if (endDateOffset) {
|
||||
endDate = formatDateToLocalISO(endDateOffset);
|
||||
}
|
||||
}
|
||||
|
||||
endDate = (endTime ? `${endDate}T${endTime}:00` : endDate);
|
||||
const eventData: EventInput = {
|
||||
title: title,
|
||||
start: startDate,
|
||||
url: `#${note.noteId}?popup`,
|
||||
noteId: note.noteId,
|
||||
color: color ?? undefined,
|
||||
iconClass: note.getLabelValue("iconClass"),
|
||||
promotedAttributes: displayedAttributesData,
|
||||
className: isArchived ? "archived" : ""
|
||||
};
|
||||
if (endDate) {
|
||||
eventData.end = endDate;
|
||||
}
|
||||
events.push(eventData);
|
||||
}
|
||||
return events;
|
||||
}
|
||||
|
||||
async function parseCustomTitle(customTitlettributeName: string | null, note: FNote, allowRelations = true): Promise<string[]> {
|
||||
if (customTitlettributeName) {
|
||||
const labelValue = note.getAttributeValue("label", customTitlettributeName);
|
||||
if (labelValue) return [labelValue];
|
||||
|
||||
if (allowRelations) {
|
||||
const relations = note.getRelations(customTitlettributeName);
|
||||
if (relations.length > 0) {
|
||||
const noteIds = relations.map((r) => r.targetNoteId);
|
||||
const notesFromRelation = await froca.getNotes(noteIds);
|
||||
const titles: string[][] = [];
|
||||
|
||||
for (const targetNote of notesFromRelation) {
|
||||
const targetCustomTitleValue = targetNote.getAttributeValue("label", "calendar:title");
|
||||
const targetTitles = await parseCustomTitle(targetCustomTitleValue, targetNote, false);
|
||||
titles.push(targetTitles.flat());
|
||||
}
|
||||
|
||||
return titles.flat();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return [note.title];
|
||||
}
|
||||
|
||||
async function buildDisplayedAttributes(note: FNote, calendarDisplayedAttributes: string[]) {
|
||||
const filteredDisplayedAttributes = note.getAttributes().filter((attr): boolean => calendarDisplayedAttributes.includes(attr.name))
|
||||
const result: Array<[string, string]> = [];
|
||||
|
||||
for (const attribute of filteredDisplayedAttributes) {
|
||||
if (attribute.type === "label") result.push([attribute.name, attribute.value]);
|
||||
else result.push([attribute.name, (await attribute.getTargetNote())?.title || ""])
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
76
apps/client/src/widgets/collections/calendar/index.css
Normal file
76
apps/client/src/widgets/collections/calendar/index.css
Normal file
@ -0,0 +1,76 @@
|
||||
.calendar-view {
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
height: 100%;
|
||||
user-select: none;
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
.calendar-view a {
|
||||
color: unset;
|
||||
}
|
||||
|
||||
.search-result-widget-content .calendar-view {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
}
|
||||
|
||||
.calendar-container {
|
||||
height: 100%;
|
||||
--fc-page-bg-color: var(--main-background-color);
|
||||
--fc-border-color: var(--main-border-color);
|
||||
--fc-neutral-bg-color: var(--launcher-pane-background-color);
|
||||
--fc-list-event-hover-bg-color: var(--left-pane-item-hover-background);
|
||||
}
|
||||
|
||||
.calendar-container .fc-list-sticky .fc-list-day > * {
|
||||
z-index: 50;
|
||||
}
|
||||
|
||||
.calendar-container a.fc-event {
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.calendar-container a.fc-event.archived {
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.calendar-container .fc-button {
|
||||
padding: 0.2em 0.5em;
|
||||
}
|
||||
|
||||
.calendar-container .promoted-attribute {
|
||||
font-size: 0.85em;
|
||||
opacity: 0.85;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* #region Header */
|
||||
.calendar-view .calendar-header {
|
||||
margin-bottom: 10px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.calendar-view .calendar-header .btn {
|
||||
min-width: unset !important;
|
||||
}
|
||||
|
||||
.calendar-view .calendar-header > .title {
|
||||
flex-grow: 1;
|
||||
font-size: 1.3rem;
|
||||
font-weight: normal;
|
||||
}
|
||||
|
||||
body.desktop:not(.zen) .calendar-view .calendar-header {
|
||||
padding-right: 5em;
|
||||
}
|
||||
|
||||
.search-result-widget-content .calendar-view .calendar-header {
|
||||
padding-right: unset !important;
|
||||
}
|
||||
/* #endregion */
|
354
apps/client/src/widgets/collections/calendar/index.tsx
Normal file
354
apps/client/src/widgets/collections/calendar/index.tsx
Normal file
@ -0,0 +1,354 @@
|
||||
import { DateSelectArg, EventChangeArg, EventMountArg, EventSourceFuncArg, LocaleInput, PluginDef } from "@fullcalendar/core/index.js";
|
||||
import { ViewModeProps } from "../interface";
|
||||
import Calendar from "./calendar";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "preact/hooks";
|
||||
import "./index.css";
|
||||
import { useNoteLabel, useNoteLabelBoolean, useResizeObserver, useSpacedUpdate, useTouchBar, useTriliumEvent, useTriliumOption, useTriliumOptionInt } from "../../react/hooks";
|
||||
import { LOCALE_IDS } from "@triliumnext/commons";
|
||||
import { Calendar as FullCalendar } from "@fullcalendar/core";
|
||||
import { parseStartEndDateFromEvent, parseStartEndTimeFromEvent } from "./utils";
|
||||
import dialog from "../../../services/dialog";
|
||||
import { t } from "../../../services/i18n";
|
||||
import { buildEvents, buildEventsForCalendar } from "./event_builder";
|
||||
import { changeEvent, newEvent } from "./api";
|
||||
import froca from "../../../services/froca";
|
||||
import date_notes from "../../../services/date_notes";
|
||||
import appContext from "../../../components/app_context";
|
||||
import { DateClickArg } from "@fullcalendar/interaction";
|
||||
import FNote from "../../../entities/fnote";
|
||||
import Button, { ButtonGroup } from "../../react/Button";
|
||||
import ActionButton from "../../react/ActionButton";
|
||||
import { RefObject } from "preact";
|
||||
import TouchBar, { TouchBarButton, TouchBarLabel, TouchBarSegmentedControl, TouchBarSpacer } from "../../react/TouchBar";
|
||||
|
||||
interface CalendarViewData {
|
||||
|
||||
}
|
||||
|
||||
interface CalendarViewData {
|
||||
type: string;
|
||||
name: string;
|
||||
previousText: string;
|
||||
nextText: string;
|
||||
}
|
||||
|
||||
const CALENDAR_VIEWS = [
|
||||
{
|
||||
type: "timeGridWeek",
|
||||
name: t("calendar.week"),
|
||||
previousText: t("calendar.week_previous"),
|
||||
nextText: t("calendar.week_next")
|
||||
},
|
||||
{
|
||||
type: "dayGridMonth",
|
||||
name: t("calendar.month"),
|
||||
previousText: t("calendar.month_previous"),
|
||||
nextText: t("calendar.month_next")
|
||||
},
|
||||
{
|
||||
type: "multiMonthYear",
|
||||
name: t("calendar.year"),
|
||||
previousText: t("calendar.year_previous"),
|
||||
nextText: t("calendar.year_next")
|
||||
},
|
||||
{
|
||||
type: "listMonth",
|
||||
name: t("calendar.list"),
|
||||
previousText: t("calendar.month_previous"),
|
||||
nextText: t("calendar.month_next")
|
||||
}
|
||||
]
|
||||
|
||||
const SUPPORTED_CALENDAR_VIEW_TYPE = CALENDAR_VIEWS.map(v => v.type);
|
||||
|
||||
// Here we hard-code the imports in order to ensure that they are embedded by webpack without having to load all the languages.
|
||||
export const LOCALE_MAPPINGS: Record<LOCALE_IDS, (() => Promise<{ default: LocaleInput }>) | null> = {
|
||||
de: () => import("@fullcalendar/core/locales/de"),
|
||||
es: () => import("@fullcalendar/core/locales/es"),
|
||||
fr: () => import("@fullcalendar/core/locales/fr"),
|
||||
cn: () => import("@fullcalendar/core/locales/zh-cn"),
|
||||
tw: () => import("@fullcalendar/core/locales/zh-tw"),
|
||||
ro: () => import("@fullcalendar/core/locales/ro"),
|
||||
ru: () => import("@fullcalendar/core/locales/ru"),
|
||||
ja: () => import("@fullcalendar/core/locales/ja"),
|
||||
"pt_br": () => import("@fullcalendar/core/locales/pt-br"),
|
||||
uk: () => import("@fullcalendar/core/locales/uk"),
|
||||
en: null
|
||||
};
|
||||
|
||||
export default function CalendarView({ note, noteIds }: ViewModeProps<CalendarViewData>) {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const calendarRef = useRef<FullCalendar>(null);
|
||||
|
||||
const [ calendarRoot ] = useNoteLabelBoolean(note, "calendarRoot");
|
||||
const [ workspaceCalendarRoot ] = useNoteLabelBoolean(note, "workspaceCalendarRoot");
|
||||
const [ firstDayOfWeek ] = useTriliumOptionInt("firstDayOfWeek");
|
||||
const [ hideWeekends ] = useNoteLabelBoolean(note, "calendar:hideWeekends");
|
||||
const [ weekNumbers ] = useNoteLabelBoolean(note, "calendar:weekNumbers");
|
||||
const [ calendarView, setCalendarView ] = useNoteLabel(note, "calendar:view");
|
||||
const initialView = useRef(calendarView);
|
||||
const viewSpacedUpdate = useSpacedUpdate(() => setCalendarView(initialView.current));
|
||||
useResizeObserver(containerRef, () => calendarRef.current?.updateSize());
|
||||
const isCalendarRoot = (calendarRoot || workspaceCalendarRoot);
|
||||
const isEditable = !isCalendarRoot;
|
||||
const eventBuilder = useMemo(() => {
|
||||
if (!isCalendarRoot) {
|
||||
return async () => await buildEvents(noteIds);
|
||||
} else {
|
||||
return async (e: EventSourceFuncArg) => await buildEventsForCalendar(note, e);
|
||||
}
|
||||
}, [isCalendarRoot, noteIds]);
|
||||
|
||||
const plugins = usePlugins(isEditable, isCalendarRoot);
|
||||
const locale = useLocale();
|
||||
|
||||
const { eventDidMount } = useEventDisplayCustomization();
|
||||
const editingProps = useEditing(note, isEditable, isCalendarRoot);
|
||||
|
||||
// React to changes.
|
||||
useTriliumEvent("entitiesReloaded", ({ loadResults }) => {
|
||||
if (loadResults.getNoteIds().some(noteId => noteIds.includes(noteId)) // note title change.
|
||||
|| loadResults.getAttributeRows().some((a) => noteIds.includes(a.noteId ?? ""))) // subnote change.
|
||||
{
|
||||
calendarRef.current?.refetchEvents();
|
||||
}
|
||||
});
|
||||
|
||||
return (plugins &&
|
||||
<div className="calendar-view" ref={containerRef} tabIndex={100}>
|
||||
<CalendarHeader calendarRef={calendarRef} />
|
||||
<Calendar
|
||||
events={eventBuilder}
|
||||
calendarRef={calendarRef}
|
||||
plugins={plugins}
|
||||
initialView={initialView.current && SUPPORTED_CALENDAR_VIEW_TYPE.includes(initialView.current) ? initialView.current : "dayGridMonth"}
|
||||
headerToolbar={false}
|
||||
firstDay={firstDayOfWeek ?? 0}
|
||||
weekends={!hideWeekends}
|
||||
weekNumbers={weekNumbers}
|
||||
height="90%"
|
||||
nowIndicator
|
||||
handleWindowResize={false}
|
||||
locale={locale}
|
||||
{...editingProps}
|
||||
eventDidMount={eventDidMount}
|
||||
viewDidMount={({ view }) => {
|
||||
if (initialView.current !== view.type) {
|
||||
initialView.current = view.type;
|
||||
viewSpacedUpdate.scheduleUpdate();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<CalendarTouchBar calendarRef={calendarRef} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CalendarHeader({ calendarRef }: { calendarRef: RefObject<FullCalendar> }) {
|
||||
const { title, viewType: currentViewType } = useOnDatesSet(calendarRef);
|
||||
const currentViewData = CALENDAR_VIEWS.find(v => calendarRef.current && v.type === currentViewType);
|
||||
|
||||
return (
|
||||
<div className="calendar-header">
|
||||
<span className="title">{title}</span>
|
||||
<ButtonGroup>
|
||||
{CALENDAR_VIEWS.map(viewData => (
|
||||
<Button
|
||||
text={viewData.name.toLocaleLowerCase()}
|
||||
className={currentViewType === viewData.type ? "active" : ""}
|
||||
onClick={() => calendarRef.current?.changeView(viewData.type)}
|
||||
/>
|
||||
))}
|
||||
</ButtonGroup>
|
||||
<Button text={t("calendar.today").toLocaleLowerCase()} onClick={() => calendarRef.current?.today()} />
|
||||
<ButtonGroup>
|
||||
<ActionButton icon="bx bx-chevron-left" text={currentViewData?.previousText ?? ""} frame onClick={() => calendarRef.current?.prev()} />
|
||||
<ActionButton icon="bx bx-chevron-right" text={currentViewData?.nextText ?? ""} frame onClick={() => calendarRef.current?.next()} />
|
||||
</ButtonGroup>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function usePlugins(isEditable: boolean, isCalendarRoot: boolean) {
|
||||
const [ plugins, setPlugins ] = useState<PluginDef[]>();
|
||||
|
||||
useEffect(() => {
|
||||
async function loadPlugins() {
|
||||
const plugins: PluginDef[] = [];
|
||||
plugins.push((await import("@fullcalendar/daygrid")).default);
|
||||
plugins.push((await import("@fullcalendar/timegrid")).default);
|
||||
plugins.push((await import("@fullcalendar/list")).default);
|
||||
plugins.push((await import("@fullcalendar/multimonth")).default);
|
||||
if (isEditable || isCalendarRoot) {
|
||||
plugins.push((await import("@fullcalendar/interaction")).default);
|
||||
}
|
||||
setPlugins(plugins);
|
||||
}
|
||||
|
||||
loadPlugins();
|
||||
}, [ isEditable, isCalendarRoot ]);
|
||||
|
||||
return plugins;
|
||||
}
|
||||
|
||||
function useLocale() {
|
||||
const [ locale ] = useTriliumOption("locale");
|
||||
const [ calendarLocale, setCalendarLocale ] = useState<LocaleInput>();
|
||||
|
||||
useEffect(() => {
|
||||
const correspondingLocale = LOCALE_MAPPINGS[locale];
|
||||
if (correspondingLocale) {
|
||||
correspondingLocale().then((locale) => setCalendarLocale(locale.default));
|
||||
} else {
|
||||
setCalendarLocale(undefined);
|
||||
}
|
||||
});
|
||||
|
||||
return calendarLocale;
|
||||
}
|
||||
|
||||
function useEditing(note: FNote, isEditable: boolean, isCalendarRoot: boolean) {
|
||||
const onCalendarSelection = useCallback(async (e: DateSelectArg) => {
|
||||
const { startDate, endDate } = parseStartEndDateFromEvent(e);
|
||||
if (!startDate) return;
|
||||
const { startTime, endTime } = parseStartEndTimeFromEvent(e);
|
||||
|
||||
// Ask for the title
|
||||
const title = await dialog.prompt({ message: t("relation_map.enter_title_of_new_note"), defaultValue: t("relation_map.default_new_note_title") });
|
||||
if (!title?.trim()) {
|
||||
return;
|
||||
}
|
||||
|
||||
newEvent(note, { title, startDate, endDate, startTime, endTime });
|
||||
}, [ note ]);
|
||||
|
||||
const onEventChange = useCallback(async (e: EventChangeArg) => {
|
||||
const { startDate, endDate } = parseStartEndDateFromEvent(e.event);
|
||||
if (!startDate) return;
|
||||
|
||||
const { startTime, endTime } = parseStartEndTimeFromEvent(e.event);
|
||||
const note = await froca.getNote(e.event.extendedProps.noteId);
|
||||
if (!note) return;
|
||||
changeEvent(note, { startDate, endDate, startTime, endTime });
|
||||
}, []);
|
||||
|
||||
// Called upon when clicking the day number in the calendar, opens or creates the day note but only if in a calendar root.
|
||||
const onDateClick = useCallback(async (e: DateClickArg) => {
|
||||
const eventNote = await date_notes.getDayNote(e.dateStr);
|
||||
if (eventNote) {
|
||||
appContext.triggerCommand("openInPopup", { noteIdOrPath: eventNote.noteId });
|
||||
}
|
||||
}, []);
|
||||
|
||||
return {
|
||||
select: onCalendarSelection,
|
||||
eventChange: onEventChange,
|
||||
dateClick: isCalendarRoot ? onDateClick : undefined,
|
||||
editable: isEditable,
|
||||
selectable: isEditable
|
||||
};
|
||||
}
|
||||
|
||||
function useEventDisplayCustomization() {
|
||||
const eventDidMount = useCallback((e: EventMountArg) => {
|
||||
const { iconClass, promotedAttributes } = e.event.extendedProps;
|
||||
|
||||
// Prepend the icon to the title, if any.
|
||||
if (iconClass) {
|
||||
let titleContainer;
|
||||
switch (e.view.type) {
|
||||
case "timeGridWeek":
|
||||
case "dayGridMonth":
|
||||
titleContainer = e.el.querySelector(".fc-event-title");
|
||||
break;
|
||||
case "multiMonthYear":
|
||||
break;
|
||||
case "listMonth":
|
||||
titleContainer = e.el.querySelector(".fc-list-event-title a");
|
||||
break;
|
||||
}
|
||||
|
||||
if (titleContainer) {
|
||||
const icon = /*html*/`<span class="${iconClass}"></span> `;
|
||||
titleContainer.insertAdjacentHTML("afterbegin", icon);
|
||||
}
|
||||
}
|
||||
|
||||
// Append promoted attributes to the end of the event container.
|
||||
if (promotedAttributes) {
|
||||
let promotedAttributesHtml = "";
|
||||
for (const [name, value] of promotedAttributes) {
|
||||
promotedAttributesHtml = promotedAttributesHtml + /*html*/`\
|
||||
<div class="promoted-attribute">
|
||||
<span class="promoted-attribute-name">${name}</span>: <span class="promoted-attribute-value">${value}</span>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
let mainContainer;
|
||||
switch (e.view.type) {
|
||||
case "timeGridWeek":
|
||||
case "dayGridMonth":
|
||||
mainContainer = e.el.querySelector(".fc-event-main");
|
||||
break;
|
||||
case "multiMonthYear":
|
||||
break;
|
||||
case "listMonth":
|
||||
mainContainer = e.el.querySelector(".fc-list-event-title");
|
||||
break;
|
||||
}
|
||||
$(mainContainer ?? e.el).append($(promotedAttributesHtml));
|
||||
}
|
||||
}, []);
|
||||
return { eventDidMount };
|
||||
}
|
||||
|
||||
function CalendarTouchBar({ calendarRef }: { calendarRef: RefObject<FullCalendar> }) {
|
||||
const { title, viewType } = useOnDatesSet(calendarRef);
|
||||
|
||||
return (
|
||||
<TouchBar>
|
||||
<TouchBarSegmentedControl
|
||||
mode="single"
|
||||
segments={CALENDAR_VIEWS.map(({ name }) => ({
|
||||
label: name,
|
||||
}))}
|
||||
selectedIndex={CALENDAR_VIEWS.findIndex(v => v.type === viewType) ?? 0}
|
||||
onChange={(selectedIndex) => calendarRef.current?.changeView(CALENDAR_VIEWS[selectedIndex].type)}
|
||||
/>
|
||||
|
||||
<TouchBarSpacer size="flexible" />
|
||||
<TouchBarLabel label={title ?? ""} />
|
||||
<TouchBarSpacer size="flexible" />
|
||||
|
||||
<TouchBarButton
|
||||
label={t("calendar.today")}
|
||||
click={() => calendarRef.current?.today()}
|
||||
/>
|
||||
<TouchBarButton
|
||||
icon="NSImageNameTouchBarGoBackTemplate"
|
||||
click={() => calendarRef.current?.prev()}
|
||||
/>
|
||||
<TouchBarButton
|
||||
icon="NSImageNameTouchBarGoForwardTemplate"
|
||||
click={() => calendarRef.current?.next()}
|
||||
/>
|
||||
</TouchBar>
|
||||
);
|
||||
}
|
||||
|
||||
function useOnDatesSet(calendarRef: RefObject<FullCalendar>) {
|
||||
const [ title, setTitle ] = useState<string>();
|
||||
const [ viewType ,setViewType ] = useState<string>();
|
||||
useEffect(() => {
|
||||
const api = calendarRef.current;
|
||||
if (!api) return;
|
||||
const handler = () => {
|
||||
setTitle(api.view.title);
|
||||
setViewType(api.view.type);
|
||||
};
|
||||
handler();
|
||||
api.on("datesSet", handler);
|
||||
return () => api.off("datesSet", handler);
|
||||
}, [calendarRef]);
|
||||
return { title, viewType };
|
||||
}
|
103
apps/client/src/widgets/collections/calendar/utils.ts
Normal file
103
apps/client/src/widgets/collections/calendar/utils.ts
Normal file
@ -0,0 +1,103 @@
|
||||
import { DateSelectArg } from "@fullcalendar/core/index.js";
|
||||
import { EventImpl } from "@fullcalendar/core/internal";
|
||||
import FNote from "../../../entities/fnote";
|
||||
|
||||
export function parseStartEndDateFromEvent(e: DateSelectArg | EventImpl) {
|
||||
const startDate = formatDateToLocalISO(e.start);
|
||||
if (!startDate) {
|
||||
return { startDate: null, endDate: null };
|
||||
}
|
||||
let endDate;
|
||||
if (e.allDay) {
|
||||
endDate = formatDateToLocalISO(offsetDate(e.end, -1));
|
||||
} else {
|
||||
endDate = formatDateToLocalISO(e.end);
|
||||
}
|
||||
return { startDate, endDate };
|
||||
}
|
||||
|
||||
export function parseStartEndTimeFromEvent(e: DateSelectArg | EventImpl) {
|
||||
let startTime: string | undefined | null = null;
|
||||
let endTime: string | undefined | null = null;
|
||||
if (!e.allDay) {
|
||||
startTime = formatTimeToLocalISO(e.start);
|
||||
endTime = formatTimeToLocalISO(e.end);
|
||||
}
|
||||
|
||||
return { startTime, endTime };
|
||||
}
|
||||
|
||||
export function formatDateToLocalISO(date: Date | null | undefined) {
|
||||
if (!date) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const offset = date.getTimezoneOffset();
|
||||
const localDate = new Date(date.getTime() - offset * 60 * 1000);
|
||||
return localDate.toISOString().split("T")[0];
|
||||
}
|
||||
|
||||
export function offsetDate(date: Date | string | null | undefined, offset: number) {
|
||||
if (!date) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const newDate = new Date(date);
|
||||
newDate.setDate(newDate.getDate() + offset);
|
||||
return newDate;
|
||||
}
|
||||
|
||||
export function formatTimeToLocalISO(date: Date | null | undefined) {
|
||||
if (!date) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const offset = date.getTimezoneOffset();
|
||||
const localDate = new Date(date.getTime() - offset * 60 * 1000);
|
||||
return localDate.toISOString()
|
||||
.split("T")[1]
|
||||
.substring(0, 5);
|
||||
}
|
||||
|
||||
/**
|
||||
* Allows the user to customize the attribute from which to obtain a particular value. For example, if `customLabelNameAttribute` is `calendar:startDate`
|
||||
* and `defaultLabelName` is `startDate` and the note at hand has `#calendar:startDate=myStartDate #myStartDate=2025-02-26` then the value returned will
|
||||
* be `2025-02-26`. If there is no custom attribute value, then the value of the default attribute is returned instead (e.g. `#startDate`).
|
||||
*
|
||||
* @param note the note from which to read the values.
|
||||
* @param defaultLabelName the name of the label in case a custom value is not found.
|
||||
* @param customLabelNameAttribute the name of the label to look for a custom value.
|
||||
* @returns the value of either the custom label or the default label.
|
||||
*/
|
||||
export function getCustomisableLabel(note: FNote, defaultLabelName: string, customLabelNameAttribute: string) {
|
||||
const customAttributeName = note.getLabelValue(customLabelNameAttribute);
|
||||
if (customAttributeName) {
|
||||
const customValue = note.getLabelValue(customAttributeName);
|
||||
if (customValue) {
|
||||
return customValue;
|
||||
}
|
||||
}
|
||||
|
||||
return note.getLabelValue(defaultLabelName);
|
||||
}
|
||||
|
||||
// Source: https://stackoverflow.com/a/30465299/4898894
|
||||
export function getMonthsInDateRange(startDate: string, endDate: string) {
|
||||
const start = startDate.split("-");
|
||||
const end = endDate.split("-");
|
||||
const startYear = parseInt(start[0]);
|
||||
const endYear = parseInt(end[0]);
|
||||
const dates: string[] = [];
|
||||
|
||||
for (let i = startYear; i <= endYear; i++) {
|
||||
const endMonth = i != endYear ? 11 : parseInt(end[1]) - 1;
|
||||
const startMon = i === startYear ? parseInt(start[1]) - 1 : 0;
|
||||
|
||||
for (let j = startMon; j <= endMonth; j = j > 12 ? j % 12 || 11 : j + 1) {
|
||||
const month = j + 1;
|
||||
const displayMonth = month < 10 ? "0" + month : month;
|
||||
dates.push([i, displayMonth].join("-"));
|
||||
}
|
||||
}
|
||||
return dates;
|
||||
}
|
28
apps/client/src/widgets/collections/geomap/api.ts
Normal file
28
apps/client/src/widgets/collections/geomap/api.ts
Normal file
@ -0,0 +1,28 @@
|
||||
import type { LatLng, LeafletMouseEvent } from "leaflet";
|
||||
import { LOCATION_ATTRIBUTE } from ".";
|
||||
import attributes from "../../../services/attributes";
|
||||
import { prompt } from "../../../services/dialog";
|
||||
import server from "../../../services/server";
|
||||
import { t } from "../../../services/i18n";
|
||||
import { CreateChildrenResponse } from "@triliumnext/commons";
|
||||
|
||||
const CHILD_NOTE_ICON = "bx bx-pin";
|
||||
|
||||
export async function moveMarker(noteId: string, latLng: LatLng | null) {
|
||||
const value = latLng ? [latLng.lat, latLng.lng].join(",") : "";
|
||||
await attributes.setLabel(noteId, LOCATION_ATTRIBUTE, value);
|
||||
}
|
||||
|
||||
export async function createNewNote(noteId: string, e: LeafletMouseEvent) {
|
||||
const title = await prompt({ message: t("relation_map.enter_title_of_new_note"), defaultValue: t("relation_map.default_new_note_title") });
|
||||
|
||||
if (title?.trim()) {
|
||||
const { note } = await server.post<CreateChildrenResponse>(`notes/${noteId}/children?target=into`, {
|
||||
title,
|
||||
content: "",
|
||||
type: "text"
|
||||
});
|
||||
attributes.setLabel(note.noteId, "iconClass", CHILD_NOTE_ICON);
|
||||
moveMarker(note.noteId, e.latlng);
|
||||
}
|
||||
}
|
@ -3,7 +3,7 @@ import appContext, { type CommandMappings } from "../../../components/app_contex
|
||||
import contextMenu, { type MenuItem } from "../../../menus/context_menu.js";
|
||||
import linkContextMenu from "../../../menus/link_context_menu.js";
|
||||
import { t } from "../../../services/i18n.js";
|
||||
import { createNewNote } from "./editing.js";
|
||||
import { createNewNote } from "./api.js";
|
||||
import { copyTextWithToast } from "../../../services/clipboard_ext.js";
|
||||
import link from "../../../services/link.js";
|
||||
|
78
apps/client/src/widgets/collections/geomap/index.css
Normal file
78
apps/client/src/widgets/collections/geomap/index.css
Normal file
@ -0,0 +1,78 @@
|
||||
.geo-view {
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.geo-map-container {
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.leaflet-pane {
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.leaflet-top,
|
||||
.leaflet-bottom {
|
||||
z-index: 997;
|
||||
}
|
||||
|
||||
.geo-view.placing-note .geo-map-container {
|
||||
cursor: crosshair;
|
||||
}
|
||||
|
||||
.geo-map-container .marker-pin {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.geo-map-container .leaflet-div-icon {
|
||||
position: relative;
|
||||
background: transparent;
|
||||
border: 0;
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.geo-map-container .leaflet-div-icon .icon-shadow {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
z-index: -1;
|
||||
}
|
||||
|
||||
.geo-map-container .leaflet-div-icon .bx {
|
||||
position: absolute;
|
||||
top: 3px;
|
||||
left: 2px;
|
||||
background-color: white;
|
||||
color: black;
|
||||
padding: 2px;
|
||||
border-radius: 50%;
|
||||
font-size: 17px;
|
||||
}
|
||||
|
||||
.geo-map-container .leaflet-div-icon .title-label {
|
||||
display: block;
|
||||
position: absolute;
|
||||
top: 100%;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
font-size: 0.75rem;
|
||||
height: 1rem;
|
||||
color: black;
|
||||
width: 100px;
|
||||
text-align: center;
|
||||
text-overflow: ellipsis;
|
||||
text-shadow: -1px -1px 0 white, 1px -1px 0 white, -1px 1px 0 white, 1px 1px 0 white;
|
||||
white-space: no-wrap;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.geo-map-container .leaflet-div-icon .archived {
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.geo-map-container.dark .leaflet-div-icon .title-label {
|
||||
color: white;
|
||||
text-shadow: -1px -1px 0 black, 1px -1px 0 black, -1px 1px 0 black, 1px 1px 0 black;
|
||||
}
|
296
apps/client/src/widgets/collections/geomap/index.tsx
Normal file
296
apps/client/src/widgets/collections/geomap/index.tsx
Normal file
@ -0,0 +1,296 @@
|
||||
import Map from "./map";
|
||||
import "./index.css";
|
||||
import { ViewModeProps } from "../interface";
|
||||
import { useNoteBlob, useNoteLabel, useNoteLabelBoolean, useNoteProperty, useNoteTreeDrag, useSpacedUpdate, useTouchBar, useTriliumEvent } from "../../react/hooks";
|
||||
import { DEFAULT_MAP_LAYER_NAME } from "./map_layer";
|
||||
import { divIcon, GPXOptions, LatLng, LeafletMouseEvent } from "leaflet";
|
||||
import { useCallback, useContext, useEffect, useMemo, useRef, useState } from "preact/hooks";
|
||||
import Marker, { GpxTrack } from "./marker";
|
||||
import froca from "../../../services/froca";
|
||||
import FNote from "../../../entities/fnote";
|
||||
import markerIcon from "leaflet/dist/images/marker-icon.png";
|
||||
import markerIconShadow from "leaflet/dist/images/marker-shadow.png";
|
||||
import appContext from "../../../components/app_context";
|
||||
import { createNewNote, moveMarker } from "./api";
|
||||
import openContextMenu, { openMapContextMenu } from "./context_menu";
|
||||
import toast from "../../../services/toast";
|
||||
import { t } from "../../../services/i18n";
|
||||
import server from "../../../services/server";
|
||||
import branches from "../../../services/branches";
|
||||
import TouchBar, { TouchBarButton, TouchBarLabel, TouchBarSlider } from "../../react/TouchBar";
|
||||
import { ParentComponent } from "../../react/react_utils";
|
||||
|
||||
const DEFAULT_COORDINATES: [number, number] = [3.878638227135724, 446.6630455551659];
|
||||
const DEFAULT_ZOOM = 2;
|
||||
export const LOCATION_ATTRIBUTE = "geolocation";
|
||||
|
||||
interface MapData {
|
||||
view?: {
|
||||
center?: LatLng | [number, number];
|
||||
zoom?: number;
|
||||
};
|
||||
}
|
||||
|
||||
enum State {
|
||||
Normal,
|
||||
NewNote
|
||||
}
|
||||
|
||||
export default function GeoView({ note, noteIds, viewConfig, saveConfig }: ViewModeProps<MapData>) {
|
||||
const [ state, setState ] = useState(State.Normal);
|
||||
const [ coordinates, setCoordinates ] = useState(viewConfig?.view?.center);
|
||||
const [ zoom, setZoom ] = useState(viewConfig?.view?.zoom);
|
||||
const [ layerName ] = useNoteLabel(note, "map:style");
|
||||
const [ hasScale ] = useNoteLabelBoolean(note, "map:scale");
|
||||
const [ isReadOnly ] = useNoteLabelBoolean(note, "readOnly");
|
||||
const [ notes, setNotes ] = useState<FNote[]>([]);
|
||||
const spacedUpdate = useSpacedUpdate(() => {
|
||||
if (viewConfig) {
|
||||
saveConfig(viewConfig);
|
||||
}
|
||||
}, 5000);
|
||||
|
||||
useEffect(() => { froca.getNotes(noteIds).then(setNotes) }, [ noteIds ]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!note) return;
|
||||
setCoordinates(viewConfig?.view?.center ?? DEFAULT_COORDINATES);
|
||||
setZoom(viewConfig?.view?.zoom ?? DEFAULT_ZOOM);
|
||||
}, [ note, viewConfig ]);
|
||||
|
||||
// Note creation.
|
||||
useTriliumEvent("geoMapCreateChildNote", () => {
|
||||
toast.showPersistent({
|
||||
icon: "plus",
|
||||
id: "geo-new-note",
|
||||
title: "New note",
|
||||
message: t("geo-map.create-child-note-instruction")
|
||||
});
|
||||
|
||||
setState(State.NewNote);
|
||||
|
||||
const globalKeyListener: (this: Window, ev: KeyboardEvent) => any = (e) => {
|
||||
if (e.key === "Escape") {
|
||||
setState(State.Normal);
|
||||
|
||||
window.removeEventListener("keydown", globalKeyListener);
|
||||
toast.closePersistent("geo-new-note");
|
||||
}
|
||||
};
|
||||
window.addEventListener("keydown", globalKeyListener);
|
||||
});
|
||||
|
||||
useTriliumEvent("deleteFromMap", ({ noteId }) => {
|
||||
moveMarker(noteId, null);
|
||||
});
|
||||
|
||||
const onClick = useCallback(async (e: LeafletMouseEvent) => {
|
||||
if (state === State.NewNote) {
|
||||
toast.closePersistent("geo-new-note");
|
||||
await createNewNote(note.noteId, e);
|
||||
setState(State.Normal);
|
||||
}
|
||||
}, [ state ]);
|
||||
|
||||
const onContextMenu = useCallback((e: LeafletMouseEvent) => {
|
||||
openMapContextMenu(note.noteId, e, !isReadOnly);
|
||||
}, [ note.noteId, isReadOnly ]);
|
||||
|
||||
// Dragging
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const apiRef = useRef<L.Map>(null);
|
||||
useNoteTreeDrag(containerRef, {
|
||||
dragEnabled: !isReadOnly,
|
||||
dragNotEnabledMessage: {
|
||||
icon: "bx bx-lock-alt",
|
||||
title: t("book.drag_locked_title"),
|
||||
message: t("book.drag_locked_message")
|
||||
},
|
||||
async callback(treeData, e) {
|
||||
const api = apiRef.current;
|
||||
if (!note || !api || isReadOnly) return;
|
||||
|
||||
const { noteId } = treeData[0];
|
||||
|
||||
const offset = containerRef.current?.getBoundingClientRect();
|
||||
const x = e.clientX - (offset?.left ?? 0);
|
||||
const y = e.clientY - (offset?.top ?? 0);
|
||||
const latlng = api.containerPointToLatLng([ x, y ]);
|
||||
|
||||
const targetNote = await froca.getNote(noteId, true);
|
||||
const parents = targetNote?.getParentNoteIds();
|
||||
if (parents?.includes(note.noteId)) {
|
||||
await moveMarker(noteId, latlng);
|
||||
} else {
|
||||
await branches.cloneNoteToParentNote(noteId, noteId);
|
||||
await moveMarker(noteId, latlng);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return (
|
||||
<div className={`geo-view ${state === State.NewNote ? "placing-note" : ""}`}>
|
||||
{ coordinates && zoom && <Map
|
||||
apiRef={apiRef} containerRef={containerRef}
|
||||
coordinates={coordinates}
|
||||
zoom={zoom}
|
||||
layerName={layerName ?? DEFAULT_MAP_LAYER_NAME}
|
||||
viewportChanged={(coordinates, zoom) => {
|
||||
if (!viewConfig) viewConfig = {};
|
||||
viewConfig.view = { center: coordinates, zoom };
|
||||
spacedUpdate.scheduleUpdate();
|
||||
}}
|
||||
onClick={onClick}
|
||||
onContextMenu={onContextMenu}
|
||||
scale={hasScale}
|
||||
>
|
||||
{notes.map(note => <NoteWrapper note={note} isReadOnly={isReadOnly} />)}
|
||||
</Map>}
|
||||
<GeoMapTouchBar state={state} map={apiRef.current} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function NoteWrapper({ note, isReadOnly }: { note: FNote, isReadOnly: boolean }) {
|
||||
const mime = useNoteProperty(note, "mime");
|
||||
const [ location ] = useNoteLabel(note, LOCATION_ATTRIBUTE);
|
||||
|
||||
if (mime === "application/gpx+xml") {
|
||||
return <NoteGpxTrack note={note} />;
|
||||
}
|
||||
|
||||
if (location) {
|
||||
const latLng = location?.split(",", 2).map((el) => parseFloat(el)) as [ number, number ] | undefined;
|
||||
if (!latLng) return;
|
||||
return <NoteMarker note={note} editable={!isReadOnly} latLng={latLng} />;
|
||||
}
|
||||
}
|
||||
|
||||
function NoteMarker({ note, editable, latLng }: { note: FNote, editable: boolean, latLng: [number, number] }) {
|
||||
// React to changes
|
||||
const [ color ] = useNoteLabel(note, "color");
|
||||
const [ iconClass ] = useNoteLabel(note, "iconClass");
|
||||
const [ archived ] = useNoteLabelBoolean(note, "archived");
|
||||
|
||||
const title = useNoteProperty(note, "title");
|
||||
const icon = useMemo(() => {
|
||||
return buildIcon(note.getIcon(), note.getColorClass() ?? undefined, title, note.noteId, archived);
|
||||
}, [ iconClass, color, title, note.noteId, archived]);
|
||||
|
||||
const onClick = useCallback(() => {
|
||||
appContext.triggerCommand("openInPopup", { noteIdOrPath: note.noteId });
|
||||
}, [ note.noteId ]);
|
||||
|
||||
// Middle click to open in new tab
|
||||
const onMouseDown = useCallback((e: MouseEvent) => {
|
||||
if (e.button === 1) {
|
||||
const hoistedNoteId = appContext.tabManager.getActiveContext()?.hoistedNoteId;
|
||||
appContext.tabManager.openInNewTab(note.noteId, hoistedNoteId);
|
||||
return true;
|
||||
}
|
||||
}, [ note.noteId ]);
|
||||
|
||||
const onDragged = useCallback((newCoordinates: LatLng) => {
|
||||
moveMarker(note.noteId, newCoordinates);
|
||||
}, [ note.noteId ]);
|
||||
|
||||
const onContextMenu = useCallback((e: LeafletMouseEvent) => openContextMenu(note.noteId, e, editable), [ note.noteId, editable ]);
|
||||
|
||||
return latLng && <Marker
|
||||
coordinates={latLng}
|
||||
icon={icon}
|
||||
draggable={editable}
|
||||
onMouseDown={onMouseDown}
|
||||
onDragged={editable ? onDragged : undefined}
|
||||
onClick={!editable ? onClick : undefined}
|
||||
onContextMenu={onContextMenu}
|
||||
/>
|
||||
}
|
||||
|
||||
function NoteGpxTrack({ note }: { note: FNote }) {
|
||||
const [ xmlString, setXmlString ] = useState<string>();
|
||||
const blob = useNoteBlob(note);
|
||||
|
||||
useEffect(() => {
|
||||
if (!blob) return;
|
||||
server.get<string | Uint8Array>(`notes/${note.noteId}/open`, undefined, true).then(xmlResponse => {
|
||||
if (xmlResponse instanceof Uint8Array) {
|
||||
setXmlString(new TextDecoder().decode(xmlResponse));
|
||||
} else {
|
||||
setXmlString(xmlResponse);
|
||||
}
|
||||
});
|
||||
}, [ blob ]);
|
||||
|
||||
// React to changes
|
||||
const color = useNoteLabel(note, "color");
|
||||
const iconClass = useNoteLabel(note, "iconClass");
|
||||
|
||||
const options = useMemo<GPXOptions>(() => ({
|
||||
markers: {
|
||||
startIcon: buildIcon(note.getIcon(), note.getColorClass(), note.title),
|
||||
endIcon: buildIcon("bxs-flag-checkered"),
|
||||
wptIcons: {
|
||||
"": buildIcon("bx bx-pin")
|
||||
}
|
||||
},
|
||||
polyline_options: {
|
||||
color: note.getLabelValue("color") ?? "blue"
|
||||
}
|
||||
}), [ color, iconClass ]);
|
||||
return xmlString && <GpxTrack gpxXmlString={xmlString} options={options} />
|
||||
}
|
||||
|
||||
function buildIcon(bxIconClass: string, colorClass?: string, title?: string, noteIdLink?: string, archived?: boolean) {
|
||||
let html = /*html*/`\
|
||||
<img class="icon" src="${markerIcon}" />
|
||||
<img class="icon-shadow" src="${markerIconShadow}" />
|
||||
<span class="bx ${bxIconClass} ${colorClass ?? ""}"></span>
|
||||
<span class="title-label">${title ?? ""}</span>`;
|
||||
|
||||
if (noteIdLink) {
|
||||
html = `<div data-href="#root/${noteIdLink}" class="${archived ? "archived" : ""}">${html}</div>`;
|
||||
}
|
||||
|
||||
return divIcon({
|
||||
html,
|
||||
iconSize: [25, 41],
|
||||
iconAnchor: [12, 41]
|
||||
});
|
||||
}
|
||||
|
||||
function GeoMapTouchBar({ state, map }: { state: State, map: L.Map | null | undefined }) {
|
||||
const [ currentZoom, setCurrentZoom ] = useState<number>();
|
||||
const parentComponent = useContext(ParentComponent);
|
||||
|
||||
useEffect(() => {
|
||||
if (!map) return;
|
||||
|
||||
function onZoomChanged() {
|
||||
setCurrentZoom(map?.getZoom());
|
||||
}
|
||||
|
||||
map.on("zoom", onZoomChanged);
|
||||
return () => map.off("zoom", onZoomChanged);
|
||||
}, [ map ]);
|
||||
|
||||
return map && currentZoom && (
|
||||
<TouchBar>
|
||||
<TouchBarSlider
|
||||
label="Zoom"
|
||||
value={currentZoom}
|
||||
minValue={map.getMinZoom()}
|
||||
maxValue={map.getMaxZoom()}
|
||||
onChange={(newValue) => {
|
||||
setCurrentZoom(newValue);
|
||||
map.setZoom(newValue);
|
||||
}}
|
||||
/>
|
||||
<TouchBarButton
|
||||
label="New geo note"
|
||||
click={() => parentComponent?.triggerCommand("geoMapCreateChildNote")}
|
||||
enabled={state === State.Normal}
|
||||
/>
|
||||
</TouchBar>
|
||||
)
|
||||
}
|
143
apps/client/src/widgets/collections/geomap/map.tsx
Normal file
143
apps/client/src/widgets/collections/geomap/map.tsx
Normal file
@ -0,0 +1,143 @@
|
||||
import { useEffect, useImperativeHandle, useRef, useState } from "preact/hooks";
|
||||
import L, { control, LatLng, Layer, LeafletMouseEvent } from "leaflet";
|
||||
import "leaflet/dist/leaflet.css";
|
||||
import { MAP_LAYERS } from "./map_layer";
|
||||
import { ComponentChildren, createContext, RefObject } from "preact";
|
||||
import { useElementSize, useSyncedRef } from "../../react/hooks";
|
||||
|
||||
export const ParentMap = createContext<L.Map | null>(null);
|
||||
|
||||
interface MapProps {
|
||||
apiRef?: RefObject<L.Map | null>;
|
||||
containerRef?: RefObject<HTMLDivElement>;
|
||||
coordinates: LatLng | [number, number];
|
||||
zoom: number;
|
||||
layerName: string;
|
||||
viewportChanged: (coordinates: LatLng, zoom: number) => void;
|
||||
children: ComponentChildren;
|
||||
onClick?: (e: LeafletMouseEvent) => void;
|
||||
onContextMenu?: (e: LeafletMouseEvent) => void;
|
||||
onZoom?: () => void;
|
||||
scale: boolean;
|
||||
}
|
||||
|
||||
export default function Map({ coordinates, zoom, layerName, viewportChanged, children, onClick, onContextMenu, scale, apiRef, containerRef: _containerRef, onZoom }: MapProps) {
|
||||
const mapRef = useRef<L.Map>(null);
|
||||
const containerRef = useSyncedRef<HTMLDivElement>(_containerRef);
|
||||
|
||||
useImperativeHandle(apiRef ?? null, () => mapRef.current);
|
||||
|
||||
useEffect(() => {
|
||||
if (!containerRef.current) return;
|
||||
const mapInstance = L.map(containerRef.current, {
|
||||
worldCopyJump: true
|
||||
});
|
||||
|
||||
mapRef.current = mapInstance;
|
||||
return () => {
|
||||
mapInstance.off();
|
||||
mapInstance.remove();
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Load the layer asynchronously.
|
||||
const [ layer, setLayer ] = useState<Layer>();
|
||||
useEffect(() => {
|
||||
async function load() {
|
||||
const layerData = MAP_LAYERS[layerName];
|
||||
|
||||
if (layerData.type === "vector") {
|
||||
const style = (typeof layerData.style === "string" ? layerData.style : await layerData.style());
|
||||
await import("@maplibre/maplibre-gl-leaflet");
|
||||
|
||||
setLayer(L.maplibreGL({
|
||||
style: style as any
|
||||
}));
|
||||
} else {
|
||||
setLayer(L.tileLayer(layerData.url, {
|
||||
attribution: layerData.attribution,
|
||||
detectRetina: true
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
load();
|
||||
}, [ layerName ]);
|
||||
|
||||
// Attach layer to the map.
|
||||
useEffect(() => {
|
||||
const map = mapRef.current;
|
||||
const layerToAdd = layer;
|
||||
if (!map || !layerToAdd) return;
|
||||
layerToAdd.addTo(map);
|
||||
return () => layerToAdd.removeFrom(map);
|
||||
}, [ mapRef, layer ]);
|
||||
|
||||
// React to coordinate changes.
|
||||
useEffect(() => {
|
||||
if (!mapRef.current) return;
|
||||
mapRef.current.setView(coordinates, zoom);
|
||||
}, [ mapRef, coordinates, zoom ]);
|
||||
|
||||
// Viewport callback.
|
||||
useEffect(() => {
|
||||
const map = mapRef.current;
|
||||
if (!map) return;
|
||||
|
||||
const updateFn = () => viewportChanged(map.getBounds().getCenter(), map.getZoom());
|
||||
map.on("moveend", updateFn);
|
||||
map.on("zoomend", updateFn);
|
||||
|
||||
return () => {
|
||||
map.off("moveend", updateFn);
|
||||
map.off("zoomend", updateFn);
|
||||
};
|
||||
}, [ mapRef, viewportChanged ]);
|
||||
|
||||
useEffect(() => {
|
||||
if (onClick && mapRef.current) {
|
||||
mapRef.current.on("click", onClick);
|
||||
return () => mapRef.current?.off("click", onClick);
|
||||
}
|
||||
}, [ mapRef, onClick ]);
|
||||
|
||||
useEffect(() => {
|
||||
if (onContextMenu && mapRef.current) {
|
||||
mapRef.current.on("contextmenu", onContextMenu);
|
||||
return () => mapRef.current?.off("contextmenu", onContextMenu);
|
||||
}
|
||||
}, [ mapRef, onContextMenu ]);
|
||||
|
||||
useEffect(() => {
|
||||
if (onZoom && mapRef.current) {
|
||||
mapRef.current.on("zoom", onZoom);
|
||||
return () => mapRef.current?.off("zoom", onZoom);
|
||||
}
|
||||
}, [ mapRef, onZoom ]);
|
||||
|
||||
// Scale
|
||||
useEffect(() => {
|
||||
const map = mapRef.current;
|
||||
if (!scale || !map) return;
|
||||
const scaleControl = control.scale();
|
||||
scaleControl.addTo(map);
|
||||
return () => scaleControl.remove();
|
||||
}, [ mapRef, scale ]);
|
||||
|
||||
// Adapt to container size changes.
|
||||
const size = useElementSize(containerRef);
|
||||
useEffect(() => {
|
||||
mapRef.current?.invalidateSize();
|
||||
}, [ size?.width, size?.height ]);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
className={`geo-map-container ${MAP_LAYERS[layerName].isDarkTheme ? "dark" : ""}`}
|
||||
>
|
||||
<ParentMap.Provider value={mapRef.current}>
|
||||
{children}
|
||||
</ParentMap.Provider>
|
||||
</div>
|
||||
);
|
||||
}
|
71
apps/client/src/widgets/collections/geomap/marker.tsx
Normal file
71
apps/client/src/widgets/collections/geomap/marker.tsx
Normal file
@ -0,0 +1,71 @@
|
||||
import { useContext, useEffect } from "preact/hooks";
|
||||
import { ParentMap } from "./map";
|
||||
import { DivIcon, GPX, GPXOptions, Icon, LatLng, Marker as LeafletMarker, LeafletMouseEvent, marker, MarkerOptions } from "leaflet";
|
||||
import "leaflet-gpx";
|
||||
|
||||
export interface MarkerProps {
|
||||
coordinates: [ number, number ];
|
||||
icon?: Icon | DivIcon;
|
||||
onClick?: () => void;
|
||||
onMouseDown?: (e: MouseEvent) => void;
|
||||
onDragged?: ((newCoordinates: LatLng) => void);
|
||||
onContextMenu: (e: LeafletMouseEvent) => void;
|
||||
draggable?: boolean;
|
||||
}
|
||||
|
||||
export default function Marker({ coordinates, icon, draggable, onClick, onDragged, onMouseDown, onContextMenu }: MarkerProps) {
|
||||
const parentMap = useContext(ParentMap);
|
||||
|
||||
useEffect(() => {
|
||||
if (!parentMap) return;
|
||||
|
||||
const options: MarkerOptions = { icon };
|
||||
if (draggable) {
|
||||
options.draggable = true;
|
||||
options.autoPan = true;
|
||||
options.autoPanSpeed = 5;
|
||||
}
|
||||
|
||||
const newMarker = marker(coordinates, options);
|
||||
|
||||
if (onClick) {
|
||||
newMarker.on("click", () => onClick());
|
||||
}
|
||||
|
||||
if (onMouseDown) {
|
||||
newMarker.on("mousedown", e => onMouseDown(e.originalEvent));
|
||||
}
|
||||
|
||||
if (onDragged) {
|
||||
newMarker.on("moveend", e => {
|
||||
const coordinates = (e.target as LeafletMarker).getLatLng();
|
||||
onDragged(coordinates);
|
||||
});
|
||||
}
|
||||
|
||||
if (onContextMenu) {
|
||||
newMarker.on("contextmenu", e => onContextMenu(e))
|
||||
}
|
||||
|
||||
newMarker.addTo(parentMap);
|
||||
|
||||
return () => newMarker.removeFrom(parentMap);
|
||||
}, [ parentMap, coordinates, onMouseDown, onDragged, icon ]);
|
||||
|
||||
return (<div />)
|
||||
}
|
||||
|
||||
export function GpxTrack({ gpxXmlString, options }: { gpxXmlString: string, options: GPXOptions }) {
|
||||
const parentMap = useContext(ParentMap);
|
||||
|
||||
useEffect(() => {
|
||||
if (!parentMap) return;
|
||||
|
||||
const track = new GPX(gpxXmlString, options);
|
||||
track.addTo(parentMap);
|
||||
|
||||
return () => track.removeFrom(parentMap);
|
||||
}, [ parentMap, gpxXmlString, options ]);
|
||||
|
||||
return <div />;
|
||||
}
|
16
apps/client/src/widgets/collections/interface.ts
Normal file
16
apps/client/src/widgets/collections/interface.ts
Normal file
@ -0,0 +1,16 @@
|
||||
import FNote from "../../entities/fnote";
|
||||
|
||||
export const allViewTypes = ["list", "grid", "calendar", "table", "geoMap", "board"] as const;
|
||||
export type ViewTypeOptions = typeof allViewTypes[number];
|
||||
|
||||
export interface ViewModeProps<T extends object> {
|
||||
note: FNote;
|
||||
notePath: string;
|
||||
/**
|
||||
* We're using noteIds so that it's not necessary to load all notes at once when paging.
|
||||
*/
|
||||
noteIds: string[];
|
||||
highlightedTokens: string[] | null | undefined;
|
||||
viewConfig: T | undefined;
|
||||
saveConfig(newConfig: T): void;
|
||||
}
|
134
apps/client/src/widgets/collections/legacy/ListOrGridView.css
Normal file
134
apps/client/src/widgets/collections/legacy/ListOrGridView.css
Normal file
@ -0,0 +1,134 @@
|
||||
.note-list {
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.note-book-card {
|
||||
border-radius: 10px;
|
||||
background-color: var(--accented-background-color);
|
||||
padding: 10px 15px 15px 8px;
|
||||
margin: 5px 5px 5px 5px;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex-shrink: 0;
|
||||
flex-grow: 1;
|
||||
}
|
||||
|
||||
.note-book-card:not(.expanded) .note-book-content {
|
||||
padding: 10px
|
||||
}
|
||||
|
||||
.note-book-card.expanded .note-book-content {
|
||||
display: block;
|
||||
min-height: 0;
|
||||
height: 100%;
|
||||
padding-top: 10px;
|
||||
}
|
||||
|
||||
.note-book-content .rendered-content {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.note-book-header {
|
||||
border-bottom: 1px solid var(--main-border-color);
|
||||
margin-bottom: 0;
|
||||
padding-bottom: .5rem;
|
||||
word-break: break-all;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* not-expanded title is limited to one line only */
|
||||
.note-book-card:not(.expanded) .note-book-header {
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.note-book-header .rendered-note-attributes {
|
||||
font-size: medium;
|
||||
}
|
||||
|
||||
.note-book-header .rendered-note-attributes:before {
|
||||
content: "\00a0\00a0";
|
||||
}
|
||||
|
||||
.note-book-header .note-icon {
|
||||
font-size: 100%;
|
||||
display: inline-block;
|
||||
padding-right: 7px;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.note-book-card .note-book-card {
|
||||
border: 1px solid var(--main-border-color);
|
||||
}
|
||||
|
||||
.note-book-content.type-image, .note-book-content.type-file, .note-book-content.type-protectedSession {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
text-align: center;
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
.note-book-content.type-image img, .note-book-content.type-canvas svg {
|
||||
max-width: 100%;
|
||||
max-height: 100%;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.note-book-card.type-image .note-book-content img,
|
||||
.note-book-card.type-text .note-book-content img,
|
||||
.note-book-card.type-canvas .note-book-content img {
|
||||
max-width: 100%;
|
||||
max-height: 100%;
|
||||
}
|
||||
|
||||
.note-book-header {
|
||||
flex-grow: 0;
|
||||
}
|
||||
|
||||
.note-list-wrapper {
|
||||
height: 100%;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.note-expander {
|
||||
font-size: x-large;
|
||||
position: relative;
|
||||
top: 3px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.note-list-pager {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
/* #region Grid view */
|
||||
.note-list.grid-view .note-list-container {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.note-list.grid-view .note-book-card {
|
||||
flex-basis: 300px;
|
||||
border: 1px solid transparent;
|
||||
}
|
||||
|
||||
.note-list.grid-view .note-book-card {
|
||||
max-height: 300px;
|
||||
}
|
||||
|
||||
.note-list.grid-view .note-book-card img {
|
||||
max-height: 220px;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.note-list.grid-view .note-book-card:hover {
|
||||
cursor: pointer;
|
||||
border: 1px solid var(--main-border-color);
|
||||
background: var(--more-accented-background-color);
|
||||
}
|
||||
/* #endregion */
|
183
apps/client/src/widgets/collections/legacy/ListOrGridView.tsx
Normal file
183
apps/client/src/widgets/collections/legacy/ListOrGridView.tsx
Normal file
@ -0,0 +1,183 @@
|
||||
import { useEffect, useMemo, useRef, useState } from "preact/hooks";
|
||||
import FNote from "../../../entities/fnote";
|
||||
import Icon from "../../react/Icon";
|
||||
import { ViewModeProps } from "../interface";
|
||||
import { useNoteLabelBoolean, useImperativeSearchHighlighlighting } from "../../react/hooks";
|
||||
import NoteLink from "../../react/NoteLink";
|
||||
import "./ListOrGridView.css";
|
||||
import content_renderer from "../../../services/content_renderer";
|
||||
import { Pager, usePagination } from "../Pagination";
|
||||
import tree from "../../../services/tree";
|
||||
import link from "../../../services/link";
|
||||
import { t } from "../../../services/i18n";
|
||||
import attribute_renderer from "../../../services/attribute_renderer";
|
||||
|
||||
export function ListView({ note, noteIds: unfilteredNoteIds, highlightedTokens }: ViewModeProps<{}>) {
|
||||
const [ isExpanded ] = useNoteLabelBoolean(note, "expanded");
|
||||
const noteIds = useFilteredNoteIds(note, unfilteredNoteIds);
|
||||
const { pageNotes, ...pagination } = usePagination(note, noteIds);
|
||||
|
||||
return (
|
||||
<div class="note-list list-view">
|
||||
{ noteIds.length > 0 && <div class="note-list-wrapper">
|
||||
<Pager {...pagination} />
|
||||
|
||||
<div class="note-list-container use-tn-links">
|
||||
{pageNotes?.map(childNote => (
|
||||
<ListNoteCard note={childNote} parentNote={note} expand={isExpanded} highlightedTokens={highlightedTokens} />
|
||||
))}
|
||||
</div>
|
||||
|
||||
<Pager {...pagination} />
|
||||
</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function GridView({ note, noteIds: unfilteredNoteIds, highlightedTokens }: ViewModeProps<{}>) {
|
||||
const noteIds = useFilteredNoteIds(note, unfilteredNoteIds);
|
||||
const { pageNotes, ...pagination } = usePagination(note, noteIds);
|
||||
|
||||
return (
|
||||
<div class="note-list grid-view">
|
||||
<div class="note-list-wrapper">
|
||||
<Pager {...pagination} />
|
||||
|
||||
<div class="note-list-container use-tn-links">
|
||||
{pageNotes?.map(childNote => (
|
||||
<GridNoteCard note={childNote} parentNote={note} highlightedTokens={highlightedTokens} />
|
||||
))}
|
||||
</div>
|
||||
|
||||
<Pager {...pagination} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ListNoteCard({ note, parentNote, expand, highlightedTokens }: { note: FNote, parentNote: FNote, expand?: boolean, highlightedTokens: string[] | null | undefined }) {
|
||||
const [ isExpanded, setExpanded ] = useState(expand);
|
||||
const notePath = getNotePath(parentNote, note);
|
||||
|
||||
// Reset expand state if switching to another note.
|
||||
useEffect(() => setExpanded(expand), [ note ]);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`note-book-card no-tooltip-preview ${isExpanded ? "expanded" : ""}`}
|
||||
data-note-id={note.noteId}
|
||||
>
|
||||
<h5 className="note-book-header">
|
||||
<span
|
||||
className={`note-expander ${isExpanded ? "bx bx-chevron-down" : "bx bx-chevron-right"}`}
|
||||
onClick={() => setExpanded(!isExpanded)}
|
||||
/>
|
||||
|
||||
<Icon className="note-icon" icon={note.getIcon()} />
|
||||
<NoteLink className="note-book-title" notePath={notePath} noPreview showNotePath={note.type === "search"} highlightedTokens={highlightedTokens} />
|
||||
<NoteAttributes note={note} />
|
||||
</h5>
|
||||
|
||||
{isExpanded && <>
|
||||
<NoteContent note={note} highlightedTokens={highlightedTokens} />
|
||||
<NoteChildren note={note} parentNote={parentNote} highlightedTokens={highlightedTokens} />
|
||||
</>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function GridNoteCard({ note, parentNote, highlightedTokens }: { note: FNote, parentNote: FNote, highlightedTokens: string[] | null | undefined }) {
|
||||
const titleRef = useRef<HTMLSpanElement>(null);
|
||||
const [ noteTitle, setNoteTitle ] = useState<string>();
|
||||
const notePath = getNotePath(parentNote, note);
|
||||
const highlightSearch = useImperativeSearchHighlighlighting(highlightedTokens);
|
||||
|
||||
useEffect(() => {
|
||||
tree.getNoteTitle(note.noteId, parentNote.noteId).then(setNoteTitle);
|
||||
}, [ note ]);
|
||||
|
||||
useEffect(() => highlightSearch(titleRef.current), [ noteTitle, highlightedTokens ]);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`note-book-card no-tooltip-preview block-link`}
|
||||
data-href={`#${notePath}`}
|
||||
data-note-id={note.noteId}
|
||||
onClick={(e) => link.goToLink(e)}
|
||||
>
|
||||
<h5 className="note-book-header">
|
||||
<Icon className="note-icon" icon={note.getIcon()} />
|
||||
<span ref={titleRef} className="note-book-title">{noteTitle}</span>
|
||||
<NoteAttributes note={note} />
|
||||
</h5>
|
||||
<NoteContent note={note} trim highlightedTokens={highlightedTokens} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function NoteAttributes({ note }: { note: FNote }) {
|
||||
const ref = useRef<HTMLSpanElement>(null);
|
||||
useEffect(() => {
|
||||
attribute_renderer.renderNormalAttributes(note).then(({$renderedAttributes}) => {
|
||||
ref.current?.replaceChildren(...$renderedAttributes);
|
||||
});
|
||||
}, [ note ]);
|
||||
|
||||
return <span className="note-list-attributes" ref={ref} />
|
||||
}
|
||||
|
||||
function NoteContent({ note, trim, highlightedTokens }: { note: FNote, trim?: boolean, highlightedTokens }) {
|
||||
const contentRef = useRef<HTMLDivElement>(null);
|
||||
const highlightSearch = useImperativeSearchHighlighlighting(highlightedTokens);
|
||||
|
||||
useEffect(() => {
|
||||
content_renderer.getRenderedContent(note, { trim })
|
||||
.then(({ $renderedContent, type }) => {
|
||||
if (!contentRef.current) return;
|
||||
contentRef.current.replaceChildren(...$renderedContent);
|
||||
contentRef.current.classList.add(`type-${type}`);
|
||||
highlightSearch(contentRef.current);
|
||||
})
|
||||
.catch(e => {
|
||||
console.warn(`Caught error while rendering note '${note.noteId}' of type '${note.type}'`);
|
||||
console.error(e);
|
||||
contentRef.current?.replaceChildren(t("collections.rendering_error"));
|
||||
})
|
||||
}, [ note, highlightedTokens ]);
|
||||
|
||||
return <div ref={contentRef} className="note-book-content" />;
|
||||
}
|
||||
|
||||
function NoteChildren({ note, parentNote, highlightedTokens }: { note: FNote, parentNote: FNote, highlightedTokens: string[] | null | undefined }) {
|
||||
const imageLinks = note.getRelations("imageLink");
|
||||
const [ childNotes, setChildNotes ] = useState<FNote[]>();
|
||||
|
||||
useEffect(() => {
|
||||
note.getChildNotes().then(childNotes => {
|
||||
const filteredChildNotes = childNotes.filter((childNote) => !imageLinks.find((rel) => rel.value === childNote.noteId));
|
||||
setChildNotes(filteredChildNotes);
|
||||
});
|
||||
}, [ note ]);
|
||||
|
||||
return childNotes?.map(childNote => <ListNoteCard note={childNote} parentNote={parentNote} highlightedTokens={highlightedTokens} />)
|
||||
}
|
||||
|
||||
/**
|
||||
* Filters the note IDs for the legacy view to filter out subnotes that are already included in the note content such as images, included notes.
|
||||
*/
|
||||
function useFilteredNoteIds(note: FNote, noteIds: string[]) {
|
||||
return useMemo(() => {
|
||||
const includedLinks = note ? note.getRelations().filter((rel) => rel.name === "imageLink" || rel.name === "includeNoteLink") : [];
|
||||
const includedNoteIds = new Set(includedLinks.map((rel) => rel.value));
|
||||
return noteIds.filter((noteId) => !includedNoteIds.has(noteId) && noteId !== "_hidden");
|
||||
}, noteIds);
|
||||
}
|
||||
|
||||
function getNotePath(parentNote: FNote, childNote: FNote) {
|
||||
if (parentNote.type === "search") {
|
||||
// for search note parent, we want to display a non-search path
|
||||
return childNote.noteId;
|
||||
} else {
|
||||
return `${parentNote.noteId}/${childNote.noteId}`
|
||||
}
|
||||
}
|
148
apps/client/src/widgets/collections/table/col_editing.ts
Normal file
148
apps/client/src/widgets/collections/table/col_editing.ts
Normal file
@ -0,0 +1,148 @@
|
||||
import { useLegacyImperativeHandlers } from "../../react/hooks";
|
||||
import { Attribute } from "../../../services/attribute_parser";
|
||||
import { RefObject } from "preact";
|
||||
import { Tabulator } from "tabulator-tables";
|
||||
import { useRef } from "preact/hooks";
|
||||
import { CommandListenerData, EventData } from "../../../components/app_context";
|
||||
import AttributeDetailWidget from "../../attribute_widgets/attribute_detail";
|
||||
import attributes from "../../../services/attributes";
|
||||
import FNote from "../../../entities/fnote";
|
||||
import { getAttributeFromField } from "./utils";
|
||||
import dialog from "../../../services/dialog";
|
||||
import { t } from "i18next";
|
||||
import { executeBulkActions } from "../../../services/bulk_action";
|
||||
|
||||
export default function useColTableEditing(api: RefObject<Tabulator>, attributeDetailWidget: AttributeDetailWidget, parentNote: FNote) {
|
||||
|
||||
const existingAttributeToEdit = useRef<Attribute>();
|
||||
const newAttribute = useRef<Attribute>();
|
||||
const newAttributePosition = useRef<number>();
|
||||
|
||||
useLegacyImperativeHandlers({
|
||||
addNewTableColumnCommand({ referenceColumn, columnToEdit, direction, type }: EventData<"addNewTableColumn">) {
|
||||
let attr: Attribute | undefined;
|
||||
|
||||
existingAttributeToEdit.current = undefined;
|
||||
if (columnToEdit) {
|
||||
attr = getAttributeFromField(parentNote, columnToEdit.getField());
|
||||
if (attr) {
|
||||
existingAttributeToEdit.current = { ...attr };
|
||||
}
|
||||
}
|
||||
|
||||
if (!attr) {
|
||||
attr = {
|
||||
type: "label",
|
||||
name: `${type ?? "label"}:myLabel`,
|
||||
value: "promoted,single,text",
|
||||
isInheritable: true
|
||||
};
|
||||
}
|
||||
|
||||
if (referenceColumn && api.current) {
|
||||
let newPosition = api.current.getColumns().indexOf(referenceColumn);
|
||||
if (direction === "after") {
|
||||
newPosition++;
|
||||
}
|
||||
|
||||
newAttributePosition.current = newPosition;
|
||||
} else {
|
||||
newAttributePosition.current = undefined;
|
||||
}
|
||||
|
||||
attributeDetailWidget.showAttributeDetail({
|
||||
attribute: attr,
|
||||
allAttributes: [ attr ],
|
||||
isOwned: true,
|
||||
x: 0,
|
||||
y: 150,
|
||||
focus: "name",
|
||||
hideMultiplicity: true
|
||||
});
|
||||
},
|
||||
async updateAttributeListCommand({ attributes }: CommandListenerData<"updateAttributeList">) {
|
||||
newAttribute.current = attributes[0];
|
||||
},
|
||||
async saveAttributesCommand() {
|
||||
if (!newAttribute.current || !api.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { name, value, isInheritable } = newAttribute.current;
|
||||
|
||||
api.current.blockRedraw();
|
||||
const isRename = (existingAttributeToEdit.current && existingAttributeToEdit.current.name !== name);
|
||||
try {
|
||||
if (isRename) {
|
||||
const oldName = existingAttributeToEdit.current!.name.split(":")[1];
|
||||
const [ type, newName ] = name.split(":");
|
||||
await renameColumn(parentNote.noteId, type as "label" | "relation", oldName, newName);
|
||||
}
|
||||
|
||||
if (existingAttributeToEdit.current && (isRename || existingAttributeToEdit.current.isInheritable !== isInheritable)) {
|
||||
attributes.removeOwnedLabelByName(parentNote, existingAttributeToEdit.current.name);
|
||||
}
|
||||
attributes.setLabel(parentNote.noteId, name, value, isInheritable);
|
||||
} finally {
|
||||
api.current.restoreRedraw();
|
||||
}
|
||||
},
|
||||
async deleteTableColumnCommand({ columnToDelete }: CommandListenerData<"deleteTableColumn">) {
|
||||
if (!api.current || !columnToDelete || !await dialog.confirm(t("table_view.delete_column_confirmation"))) {
|
||||
return;
|
||||
}
|
||||
|
||||
let [ type, name ] = columnToDelete.getField()?.split(".", 2);
|
||||
if (!type || !name) {
|
||||
return;
|
||||
}
|
||||
type = type.replace("s", "");
|
||||
|
||||
api.current.blockRedraw();
|
||||
try {
|
||||
await deleteColumn(parentNote.noteId, type as "label" | "relation", name);
|
||||
attributes.removeOwnedLabelByName(parentNote, `${type}:${name}`);
|
||||
} finally {
|
||||
api.current.restoreRedraw();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
function resetNewAttributePosition() {
|
||||
newAttribute.current = undefined;
|
||||
newAttributePosition.current = undefined;
|
||||
existingAttributeToEdit.current = undefined;
|
||||
}
|
||||
|
||||
return { newAttributePosition, resetNewAttributePosition };
|
||||
}
|
||||
|
||||
async function deleteColumn(parentNoteId: string, type: "label" | "relation", columnName: string) {
|
||||
if (type === "label") {
|
||||
return executeBulkActions([parentNoteId], [{
|
||||
name: "deleteLabel",
|
||||
labelName: columnName
|
||||
}], true);
|
||||
} else {
|
||||
return executeBulkActions([parentNoteId], [{
|
||||
name: "deleteRelation",
|
||||
relationName: columnName
|
||||
}], true);
|
||||
}
|
||||
}
|
||||
|
||||
async function renameColumn(parentNoteId: string, type: "label" | "relation", originalName: string, newName: string) {
|
||||
if (type === "label") {
|
||||
return executeBulkActions([parentNoteId], [{
|
||||
name: "renameLabel",
|
||||
oldLabelName: originalName,
|
||||
newLabelName: newName
|
||||
}], true);
|
||||
} else {
|
||||
return executeBulkActions([parentNoteId], [{
|
||||
name: "renameRelation",
|
||||
oldRelationName: originalName,
|
||||
newRelationName: newName
|
||||
}], true);
|
||||
}
|
||||
}
|
@ -1,7 +1,11 @@
|
||||
import { RelationEditor } from "./relation_editor.js";
|
||||
import { MonospaceFormatter, NoteFormatter, NoteTitleFormatter, RowNumberFormatter } from "./formatters.js";
|
||||
import type { ColumnDefinition } from "tabulator-tables";
|
||||
import type { CellComponent, ColumnDefinition, EmptyCallback, FormatterParams, ValueBooleanCallback, ValueVoidCallback } from "tabulator-tables";
|
||||
import { LabelType } from "../../../services/promoted_attribute_definition_parser.js";
|
||||
import { JSX } from "preact";
|
||||
import { renderReactWidget } from "../../react/react_utils.jsx";
|
||||
import Icon from "../../react/Icon.jsx";
|
||||
import { useEffect, useRef, useState } from "preact/hooks";
|
||||
import froca from "../../../services/froca.js";
|
||||
import NoteAutocomplete from "../../react/NoteAutocomplete.jsx";
|
||||
|
||||
type ColumnType = LabelType | "relation";
|
||||
|
||||
@ -45,8 +49,8 @@ const labelTypeMappings: Record<ColumnType, Partial<ColumnDefinition>> = {
|
||||
}
|
||||
},
|
||||
relation: {
|
||||
editor: RelationEditor,
|
||||
formatter: NoteFormatter
|
||||
editor: wrapEditor(RelationEditor),
|
||||
formatter: wrapFormatter(NoteFormatter)
|
||||
}
|
||||
};
|
||||
|
||||
@ -58,6 +62,10 @@ interface BuildColumnArgs {
|
||||
position?: number;
|
||||
}
|
||||
|
||||
interface RowNumberFormatterParams {
|
||||
movableRows?: boolean;
|
||||
}
|
||||
|
||||
export function buildColumnDefinitions({ info, movableRows, existingColumnData, rowNumberHint, position }: BuildColumnArgs) {
|
||||
let columnDefs: ColumnDefinition[] = [
|
||||
{
|
||||
@ -68,19 +76,28 @@ export function buildColumnDefinitions({ info, movableRows, existingColumnData,
|
||||
frozen: true,
|
||||
rowHandle: movableRows,
|
||||
width: calculateIndexColumnWidth(rowNumberHint, movableRows),
|
||||
formatter: RowNumberFormatter(movableRows)
|
||||
formatter: wrapFormatter(({ cell, formatterParams }) => <div>
|
||||
{(formatterParams as RowNumberFormatterParams).movableRows && <><span class="bx bx-dots-vertical-rounded"></span>{" "}</>}
|
||||
{cell.getRow().getPosition(true)}
|
||||
</div>),
|
||||
formatterParams: { movableRows } satisfies RowNumberFormatterParams
|
||||
},
|
||||
{
|
||||
field: "noteId",
|
||||
title: "Note ID",
|
||||
formatter: MonospaceFormatter,
|
||||
formatter: wrapFormatter(({ cell }) => <code>{cell.getValue()}</code>),
|
||||
visible: false
|
||||
},
|
||||
{
|
||||
field: "title",
|
||||
title: "Title",
|
||||
editor: "input",
|
||||
formatter: NoteTitleFormatter,
|
||||
formatter: wrapFormatter(({ cell }) => {
|
||||
const { noteId, iconClass, colorClass } = cell.getRow().getData();
|
||||
return <span className={`reference-link ${colorClass}`} data-href={`#root/${noteId}`}>
|
||||
<Icon icon={iconClass} />{" "}{cell.getValue()}
|
||||
</span>;
|
||||
}),
|
||||
width: 400
|
||||
}
|
||||
];
|
||||
@ -154,3 +171,64 @@ function calculateIndexColumnWidth(rowNumberHint: number, movableRows: boolean):
|
||||
}
|
||||
return columnWidth;
|
||||
}
|
||||
|
||||
interface FormatterOpts {
|
||||
cell: CellComponent
|
||||
formatterParams: FormatterParams;
|
||||
}
|
||||
|
||||
interface EditorOpts {
|
||||
cell: CellComponent,
|
||||
success: ValueBooleanCallback,
|
||||
cancel: ValueVoidCallback,
|
||||
editorParams: {}
|
||||
}
|
||||
|
||||
function wrapFormatter(Component: (opts: FormatterOpts) => JSX.Element): ((cell: CellComponent, formatterParams: {}, onRendered: EmptyCallback) => string | HTMLElement) {
|
||||
return (cell, formatterParams, onRendered) => {
|
||||
const elWithParams = <Component cell={cell} formatterParams={formatterParams} />;
|
||||
return renderReactWidget(null, elWithParams)[0];
|
||||
};
|
||||
}
|
||||
|
||||
function wrapEditor(Component: (opts: EditorOpts) => JSX.Element): ((
|
||||
cell: CellComponent,
|
||||
onRendered: EmptyCallback,
|
||||
success: ValueBooleanCallback,
|
||||
cancel: ValueVoidCallback,
|
||||
editorParams: {},
|
||||
) => HTMLElement | false) {
|
||||
return (cell, _, success, cancel, editorParams) => {
|
||||
const elWithParams = <Component cell={cell} success={success} cancel={cancel} editorParams={editorParams} />
|
||||
return renderReactWidget(null, elWithParams)[0];
|
||||
};
|
||||
}
|
||||
|
||||
function NoteFormatter({ cell }: FormatterOpts) {
|
||||
const noteId = cell.getValue();
|
||||
const [ note, setNote ] = useState(noteId ? froca.getNoteFromCache(noteId) : null)
|
||||
|
||||
useEffect(() => {
|
||||
if (!noteId || note?.noteId === noteId) return;
|
||||
froca.getNote(noteId).then(setNote);
|
||||
}, [ noteId ]);
|
||||
|
||||
return <span className={`reference-link ${note?.getColorClass()}`} data-href={`#root/${noteId}`}>
|
||||
{note && <><Icon icon={note?.getIcon()} />{" "}{note.title}</>}
|
||||
</span>;
|
||||
}
|
||||
|
||||
function RelationEditor({ cell, success }: EditorOpts) {
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
useEffect(() => inputRef.current?.focus());
|
||||
|
||||
return <NoteAutocomplete
|
||||
inputRef={inputRef}
|
||||
noteId={cell.getValue()}
|
||||
opts={{
|
||||
allowCreatingNotes: true,
|
||||
hideAllButtons: true
|
||||
}}
|
||||
noteIdChanged={success}
|
||||
/>
|
||||
}
|
@ -1,31 +1,35 @@
|
||||
import { ColumnComponent, RowComponent, Tabulator } from "tabulator-tables";
|
||||
import { ColumnComponent, EventCallBackMethods, RowComponent, Tabulator } from "tabulator-tables";
|
||||
import contextMenu, { MenuItem } from "../../../menus/context_menu.js";
|
||||
import { TableData } from "./rows.js";
|
||||
import branches from "../../../services/branches.js";
|
||||
import FNote from "../../../entities/fnote.js";
|
||||
import { t } from "../../../services/i18n.js";
|
||||
import { TableData } from "./rows.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";
|
||||
import branches from "../../../services/branches.js";
|
||||
import Component from "../../../components/component.js";
|
||||
import { RefObject } from "preact";
|
||||
|
||||
export function setupContextMenu(tabulator: Tabulator, parentNote: FNote) {
|
||||
tabulator.on("rowContext", (e, row) => showRowContextMenu(e, row, parentNote, tabulator));
|
||||
tabulator.on("headerContext", (e, col) => showColumnContextMenu(e, col, parentNote, tabulator));
|
||||
tabulator.on("renderComplete", () => {
|
||||
const headerRow = tabulator.element.querySelector(".tabulator-header-contents");
|
||||
headerRow?.addEventListener("contextmenu", (e) => showHeaderContextMenu(e, tabulator));
|
||||
});
|
||||
export function useContextMenu(parentNote: FNote, parentComponent: Component | null | undefined, tabulator: RefObject<Tabulator>): Partial<EventCallBackMethods> {
|
||||
const events: Partial<EventCallBackMethods> = {};
|
||||
if (!tabulator || !parentComponent) return events;
|
||||
|
||||
// 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);
|
||||
events["rowContext"] = (e, row) => tabulator.current && showRowContextMenu(parentComponent, e as MouseEvent, row, parentNote, tabulator.current);
|
||||
events["headerContext"] = (e, col) => tabulator.current && showColumnContextMenu(parentComponent, e as MouseEvent, col, parentNote, tabulator.current);
|
||||
events["renderComplete"] = () => {
|
||||
const headerRow = tabulator.current?.element.querySelector(".tabulator-header-contents");
|
||||
headerRow?.addEventListener("contextmenu", (e) => showHeaderContextMenu(parentComponent, e as MouseEvent, tabulator.current!));
|
||||
}
|
||||
// Pressing the expand button prevents bubbling and the context menu remains menu when it shouldn't.
|
||||
if (tabulator.current?.options.dataTree) {
|
||||
const dismissContextMenu = () => contextMenu.hide();
|
||||
events["dataTreeRowExpanded"] = dismissContextMenu;
|
||||
events["dataTreeRowCollapsed"] = dismissContextMenu;
|
||||
}
|
||||
|
||||
return events;
|
||||
}
|
||||
|
||||
function showColumnContextMenu(_e: UIEvent, column: ColumnComponent, parentNote: FNote, tabulator: Tabulator) {
|
||||
const e = _e as MouseEvent;
|
||||
function showColumnContextMenu(parentComponent: Component, e: MouseEvent, column: ColumnComponent, parentNote: FNote, tabulator: Tabulator) {
|
||||
const { title, field } = column.getDefinition();
|
||||
|
||||
const sorters = tabulator.getSorters();
|
||||
@ -87,16 +91,16 @@ function showColumnContextMenu(_e: UIEvent, column: ColumnComponent, parentNote:
|
||||
title: t("table_view.add-column-to-the-left"),
|
||||
uiIcon: "bx bx-horizontal-left",
|
||||
enabled: !column.getDefinition().frozen,
|
||||
items: buildInsertSubmenu(e, column, "before"),
|
||||
handler: () => getParentComponent(e)?.triggerCommand("addNewTableColumn", {
|
||||
items: buildInsertSubmenu(parentComponent, column, "before"),
|
||||
handler: () => parentComponent?.triggerCommand("addNewTableColumn", {
|
||||
referenceColumn: column
|
||||
})
|
||||
},
|
||||
{
|
||||
title: t("table_view.add-column-to-the-right"),
|
||||
uiIcon: "bx bx-horizontal-right",
|
||||
items: buildInsertSubmenu(e, column, "after"),
|
||||
handler: () => getParentComponent(e)?.triggerCommand("addNewTableColumn", {
|
||||
items: buildInsertSubmenu(parentComponent, column, "after"),
|
||||
handler: () => parentComponent?.triggerCommand("addNewTableColumn", {
|
||||
referenceColumn: column,
|
||||
direction: "after"
|
||||
})
|
||||
@ -106,7 +110,7 @@ function showColumnContextMenu(_e: UIEvent, column: ColumnComponent, parentNote:
|
||||
title: t("table_view.edit-column"),
|
||||
uiIcon: "bx bxs-edit-alt",
|
||||
enabled: isUserDefinedColumn,
|
||||
handler: () => getParentComponent(e)?.triggerCommand("addNewTableColumn", {
|
||||
handler: () => parentComponent?.triggerCommand("addNewTableColumn", {
|
||||
referenceColumn: column,
|
||||
columnToEdit: column
|
||||
})
|
||||
@ -115,7 +119,7 @@ function showColumnContextMenu(_e: UIEvent, column: ColumnComponent, parentNote:
|
||||
title: t("table_view.delete-column"),
|
||||
uiIcon: "bx bx-trash",
|
||||
enabled: isUserDefinedColumn,
|
||||
handler: () => getParentComponent(e)?.triggerCommand("deleteTableColumn", {
|
||||
handler: () => parentComponent?.triggerCommand("deleteTableColumn", {
|
||||
columnToDelete: column
|
||||
})
|
||||
}
|
||||
@ -131,8 +135,7 @@ function showColumnContextMenu(_e: UIEvent, column: ColumnComponent, parentNote:
|
||||
* Shows a context menu which has options dedicated to the header area (the part where the columns are, but in the empty space).
|
||||
* Provides generic options such as toggling columns.
|
||||
*/
|
||||
function showHeaderContextMenu(_e: Event, tabulator: Tabulator) {
|
||||
const e = _e as MouseEvent;
|
||||
function showHeaderContextMenu(parentComponent: Component, e: MouseEvent, tabulator: Tabulator) {
|
||||
contextMenu.show({
|
||||
items: [
|
||||
{
|
||||
@ -146,7 +149,7 @@ function showHeaderContextMenu(_e: Event, tabulator: Tabulator) {
|
||||
uiIcon: "bx bx-empty",
|
||||
enabled: false
|
||||
},
|
||||
...buildInsertSubmenu(e)
|
||||
...buildInsertSubmenu(parentComponent)
|
||||
],
|
||||
selectMenuItemHandler() {},
|
||||
x: e.pageX,
|
||||
@ -155,9 +158,9 @@ function showHeaderContextMenu(_e: Event, tabulator: Tabulator) {
|
||||
e.preventDefault();
|
||||
}
|
||||
|
||||
export function showRowContextMenu(_e: UIEvent, row: RowComponent, parentNote: FNote, tabulator: Tabulator) {
|
||||
const e = _e as MouseEvent;
|
||||
export function showRowContextMenu(parentComponent: Component, e: MouseEvent, row: RowComponent, parentNote: FNote, tabulator: Tabulator) {
|
||||
const rowData = row.getData() as TableData;
|
||||
const sorters = tabulator.getSorters();
|
||||
|
||||
let parentNoteId: string = parentNote.noteId;
|
||||
|
||||
@ -175,7 +178,8 @@ export function showRowContextMenu(_e: UIEvent, row: RowComponent, parentNote: F
|
||||
{
|
||||
title: t("table_view.row-insert-above"),
|
||||
uiIcon: "bx bx-horizontal-left bx-rotate-90",
|
||||
handler: () => getParentComponent(e)?.triggerCommand("addNewRow", {
|
||||
enabled: !sorters.length,
|
||||
handler: () => parentComponent?.triggerCommand("addNewRow", {
|
||||
parentNotePath: parentNoteId,
|
||||
customOpts: {
|
||||
target: "before",
|
||||
@ -189,7 +193,7 @@ export function showRowContextMenu(_e: UIEvent, row: RowComponent, parentNote: F
|
||||
handler: async () => {
|
||||
const branchId = row.getData().branchId;
|
||||
const note = await froca.getBranch(branchId)?.getNote();
|
||||
getParentComponent(e)?.triggerCommand("addNewRow", {
|
||||
parentComponent?.triggerCommand("addNewRow", {
|
||||
parentNotePath: note?.noteId,
|
||||
customOpts: {
|
||||
target: "after",
|
||||
@ -201,7 +205,8 @@ export function showRowContextMenu(_e: UIEvent, row: RowComponent, parentNote: F
|
||||
{
|
||||
title: t("table_view.row-insert-below"),
|
||||
uiIcon: "bx bx-horizontal-left bx-rotate-270",
|
||||
handler: () => getParentComponent(e)?.triggerCommand("addNewRow", {
|
||||
enabled: !sorters.length,
|
||||
handler: () => parentComponent?.triggerCommand("addNewRow", {
|
||||
parentNotePath: parentNoteId,
|
||||
customOpts: {
|
||||
target: "after",
|
||||
@ -223,16 +228,6 @@ 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;
|
||||
}
|
||||
|
||||
function buildColumnItems(tabulator: Tabulator) {
|
||||
const items: MenuItem<unknown>[] = [];
|
||||
for (const column of tabulator.getColumns()) {
|
||||
@ -249,13 +244,13 @@ function buildColumnItems(tabulator: Tabulator) {
|
||||
return items;
|
||||
}
|
||||
|
||||
function buildInsertSubmenu(e: MouseEvent, referenceColumn?: ColumnComponent, direction?: "before" | "after"): MenuItem<unknown>[] {
|
||||
function buildInsertSubmenu(parentComponent: Component, referenceColumn?: ColumnComponent, direction?: "before" | "after"): MenuItem<unknown>[] {
|
||||
return [
|
||||
{
|
||||
title: t("table_view.new-column-label"),
|
||||
uiIcon: "bx bx-hash",
|
||||
handler: () => {
|
||||
getParentComponent(e)?.triggerCommand("addNewTableColumn", {
|
||||
parentComponent?.triggerCommand("addNewTableColumn", {
|
||||
referenceColumn,
|
||||
type: "label",
|
||||
direction
|
||||
@ -266,7 +261,7 @@ function buildInsertSubmenu(e: MouseEvent, referenceColumn?: ColumnComponent, di
|
||||
title: t("table_view.new-column-relation"),
|
||||
uiIcon: "bx bx-transfer",
|
||||
handler: () => {
|
||||
getParentComponent(e)?.triggerCommand("addNewTableColumn", {
|
||||
parentComponent?.triggerCommand("addNewTableColumn", {
|
||||
referenceColumn,
|
||||
type: "relation",
|
||||
direction
|
71
apps/client/src/widgets/collections/table/index.css
Normal file
71
apps/client/src/widgets/collections/table/index.css
Normal file
@ -0,0 +1,71 @@
|
||||
.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-row.archived {
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.tabulator .tabulator-footer {
|
||||
background-color: unset;
|
||||
padding: 5px 0;
|
||||
}
|
||||
|
||||
.tabulator .tabulator-footer .tabulator-footer-contents {
|
||||
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%);
|
||||
}
|
171
apps/client/src/widgets/collections/table/index.tsx
Normal file
171
apps/client/src/widgets/collections/table/index.tsx
Normal file
@ -0,0 +1,171 @@
|
||||
import { useCallback, useContext, useEffect, useMemo, useRef, useState } from "preact/hooks";
|
||||
import { ViewModeProps } from "../interface";
|
||||
import { buildColumnDefinitions } from "./columns";
|
||||
import getAttributeDefinitionInformation, { buildRowDefinitions, TableData } from "./rows";
|
||||
import { useLegacyWidget, useNoteLabelBoolean, useNoteLabelInt, useSpacedUpdate, useTriliumEvent } from "../../react/hooks";
|
||||
import Tabulator from "./tabulator";
|
||||
import { Tabulator as VanillaTabulator, SortModule, FormatModule, InteractionModule, EditModule, ResizeColumnsModule, FrozenColumnsModule, PersistenceModule, MoveColumnsModule, MoveRowsModule, ColumnDefinition, DataTreeModule, Options, RowComponent} from 'tabulator-tables';
|
||||
import { useContextMenu } from "./context_menu";
|
||||
import { ParentComponent } from "../../react/react_utils";
|
||||
import FNote from "../../../entities/fnote";
|
||||
import { t } from "../../../services/i18n";
|
||||
import Button from "../../react/Button";
|
||||
import "./index.css";
|
||||
import useRowTableEditing from "./row_editing";
|
||||
import useColTableEditing from "./col_editing";
|
||||
import AttributeDetailWidget from "../../attribute_widgets/attribute_detail";
|
||||
import attributes from "../../../services/attributes";
|
||||
import { RefObject } from "preact";
|
||||
|
||||
interface TableConfig {
|
||||
tableData?: {
|
||||
columns?: ColumnDefinition[];
|
||||
};
|
||||
}
|
||||
|
||||
export default function TableView({ note, noteIds, notePath, viewConfig, saveConfig }: ViewModeProps<TableConfig>) {
|
||||
const tabulatorRef = useRef<VanillaTabulator>(null);
|
||||
const parentComponent = useContext(ParentComponent);
|
||||
|
||||
const [ attributeDetailWidgetEl, attributeDetailWidget ] = useLegacyWidget(() => new AttributeDetailWidget().contentSized());
|
||||
const contextMenuEvents = useContextMenu(note, parentComponent, tabulatorRef);
|
||||
const persistenceProps = usePersistence(viewConfig, saveConfig);
|
||||
const rowEditingEvents = useRowTableEditing(tabulatorRef, attributeDetailWidget, notePath);
|
||||
const { newAttributePosition, resetNewAttributePosition } = useColTableEditing(tabulatorRef, attributeDetailWidget, note);
|
||||
const { columnDefs, rowData, movableRows, hasChildren } = useData(note, noteIds, viewConfig, newAttributePosition, resetNewAttributePosition);
|
||||
const dataTreeProps = useMemo<Options>(() => {
|
||||
if (!hasChildren) return {};
|
||||
return {
|
||||
dataTree: true,
|
||||
dataTreeStartExpanded: true,
|
||||
dataTreeBranchElement: false,
|
||||
dataTreeElementColumn: "title",
|
||||
dataTreeChildIndent: 20,
|
||||
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>`
|
||||
}
|
||||
}, [ hasChildren ]);
|
||||
|
||||
const rowFormatter = useCallback((row: RowComponent) => {
|
||||
const data = row.getData() as TableData;
|
||||
row.getElement().classList.toggle("archived", !!data.isArchived);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="table-view">
|
||||
{columnDefs && (
|
||||
<>
|
||||
<Tabulator
|
||||
tabulatorRef={tabulatorRef}
|
||||
className="table-view-container"
|
||||
columns={columnDefs}
|
||||
data={rowData}
|
||||
modules={[ SortModule, FormatModule, InteractionModule, EditModule, ResizeColumnsModule, FrozenColumnsModule, PersistenceModule, MoveColumnsModule, MoveRowsModule, DataTreeModule ]}
|
||||
footerElement={<TableFooter note={note} />}
|
||||
events={{
|
||||
...contextMenuEvents,
|
||||
...rowEditingEvents
|
||||
}}
|
||||
persistence {...persistenceProps}
|
||||
layout="fitDataFill"
|
||||
index="branchId"
|
||||
movableColumns
|
||||
movableRows={movableRows}
|
||||
rowFormatter={rowFormatter}
|
||||
{...dataTreeProps}
|
||||
/>
|
||||
<TableFooter note={note} />
|
||||
</>
|
||||
)}
|
||||
{attributeDetailWidgetEl}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function TableFooter({ note }: { note: FNote }) {
|
||||
return (note.type !== "search" &&
|
||||
<div className="tabulator-footer">
|
||||
<div className="tabulator-footer-contents">
|
||||
<Button triggerCommand="addNewRow" icon="bx bx-plus" text={t("table_view.new-row")} />
|
||||
{" "}
|
||||
<Button triggerCommand="addNewTableColumn" icon="bx bx-carousel" text={t("table_view.new-column")} />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function usePersistence(initialConfig: TableConfig | null | undefined, saveConfig: (newConfig: TableConfig) => void) {
|
||||
const config = useRef<TableConfig | null | undefined>(initialConfig);
|
||||
const spacedUpdate = useSpacedUpdate(() => {
|
||||
if (config.current) {
|
||||
saveConfig(config.current);
|
||||
}
|
||||
}, 5_000);
|
||||
const persistenceWriterFunc = useCallback((_id, type: string, data: object) => {
|
||||
if (!config.current) config.current = {};
|
||||
if (!config.current.tableData) config.current.tableData = {};
|
||||
(config.current.tableData as Record<string, {}>)[type] = data;
|
||||
spacedUpdate.scheduleUpdate();
|
||||
}, []);
|
||||
const persistenceReaderFunc = useCallback((_id, type: string) => {
|
||||
return config.current?.tableData?.[type];
|
||||
}, []);
|
||||
return { persistenceReaderFunc, persistenceWriterFunc };
|
||||
}
|
||||
|
||||
function useData(note: FNote, noteIds: string[], viewConfig: TableConfig | undefined, newAttributePosition: RefObject<number | undefined>, resetNewAttributePosition: () => void) {
|
||||
const [ maxDepth ] = useNoteLabelInt(note, "maxNestingDepth") ?? -1;
|
||||
const [ includeArchived ] = useNoteLabelBoolean(note, "includeArchived");
|
||||
|
||||
const [ columnDefs, setColumnDefs ] = useState<ColumnDefinition[]>();
|
||||
const [ rowData, setRowData ] = useState<TableData[]>();
|
||||
const [ hasChildren, setHasChildren ] = useState<boolean>();
|
||||
const [ isSorted ] = useNoteLabelBoolean(note, "sorted");
|
||||
const [ movableRows, setMovableRows ] = useState(false);
|
||||
|
||||
function refresh() {
|
||||
const info = getAttributeDefinitionInformation(note);
|
||||
buildRowDefinitions(note, info, includeArchived, maxDepth).then(({ definitions: rowData, hasSubtree: hasChildren, rowNumber }) => {
|
||||
const columnDefs = buildColumnDefinitions({
|
||||
info,
|
||||
movableRows,
|
||||
existingColumnData: viewConfig?.tableData?.columns,
|
||||
rowNumberHint: rowNumber,
|
||||
position: newAttributePosition.current ?? undefined
|
||||
});
|
||||
setColumnDefs(columnDefs);
|
||||
setRowData(rowData);
|
||||
setHasChildren(hasChildren);
|
||||
resetNewAttributePosition();
|
||||
});
|
||||
}
|
||||
|
||||
useEffect(refresh, [ note, noteIds, maxDepth, movableRows ]);
|
||||
|
||||
useTriliumEvent("entitiesReloaded", ({ loadResults}) => {
|
||||
// React to column changes.
|
||||
if (loadResults.getAttributeRows().find(attr =>
|
||||
attr.type === "label" &&
|
||||
(attr.name?.startsWith("label:") || attr.name?.startsWith("relation:")) &&
|
||||
attributes.isAffecting(attr, note))) {
|
||||
refresh();
|
||||
return;
|
||||
}
|
||||
|
||||
// React to external row updates.
|
||||
if (loadResults.getBranchRows().some(branch => branch.parentNoteId === note.noteId || noteIds.includes(branch.parentNoteId ?? ""))
|
||||
|| loadResults.getNoteIds().some(noteId => noteIds.includes(noteId))
|
||||
|| loadResults.getAttributeRows().some(attr => noteIds.includes(attr.noteId!))
|
||||
|| loadResults.getAttributeRows().some(attr => attr.name === "archived" && attr.noteId && noteIds.includes(attr.noteId))) {
|
||||
refresh();
|
||||
return;
|
||||
}
|
||||
});
|
||||
|
||||
// Identify if movable rows.
|
||||
useEffect(() => {
|
||||
setMovableRows(!isSorted && note.type !== "search" && !hasChildren);
|
||||
}, [ isSorted, note, hasChildren ]);
|
||||
|
||||
return { columnDefs, rowData, movableRows, hasChildren };
|
||||
}
|
105
apps/client/src/widgets/collections/table/row_editing.ts
Normal file
105
apps/client/src/widgets/collections/table/row_editing.ts
Normal file
@ -0,0 +1,105 @@
|
||||
import { EventCallBackMethods, RowComponent, Tabulator } from "tabulator-tables";
|
||||
import { CommandListenerData } from "../../../components/app_context";
|
||||
import note_create, { CreateNoteOpts } from "../../../services/note_create";
|
||||
import { useLegacyImperativeHandlers } from "../../react/hooks";
|
||||
import { RefObject } from "preact";
|
||||
import { setAttribute, setLabel } from "../../../services/attributes";
|
||||
import froca from "../../../services/froca";
|
||||
import server from "../../../services/server";
|
||||
import branches from "../../../services/branches";
|
||||
import AttributeDetailWidget from "../../attribute_widgets/attribute_detail";
|
||||
|
||||
export default function useRowTableEditing(api: RefObject<Tabulator>, attributeDetailWidget: AttributeDetailWidget, parentNotePath: string): Partial<EventCallBackMethods> {
|
||||
// Adding new rows
|
||||
useLegacyImperativeHandlers({
|
||||
addNewRowCommand({ customOpts, parentNotePath: customNotePath }: CommandListenerData<"addNewRow">) {
|
||||
const notePath = customNotePath ?? parentNotePath;
|
||||
if (notePath) {
|
||||
const opts: CreateNoteOpts = {
|
||||
activate: false,
|
||||
...customOpts
|
||||
}
|
||||
note_create.createNote(notePath, opts).then(({ branch }) => {
|
||||
if (branch) {
|
||||
setTimeout(() => {
|
||||
if (!api.current) return;
|
||||
focusOnBranch(api.current, branch?.branchId);
|
||||
}, 100);
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Editing existing rows.
|
||||
return {
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
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);
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function focusOnBranch(api: Tabulator, branchId: string) {
|
||||
const row = findRowDataById(api.getRows(), branchId);
|
||||
if (!row) return;
|
||||
|
||||
// Expand the parent tree if any.
|
||||
if (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;
|
||||
}
|
@ -10,10 +10,11 @@ export type TableData = {
|
||||
relations: Record<string, boolean | string | null>;
|
||||
branchId: string;
|
||||
colorClass: string | undefined;
|
||||
isArchived: boolean;
|
||||
_children?: TableData[];
|
||||
};
|
||||
|
||||
export async function buildRowDefinitions(parentNote: FNote, infos: AttributeDefinitionInformation[], maxDepth = -1, currentDepth = 0) {
|
||||
export async function buildRowDefinitions(parentNote: FNote, infos: AttributeDefinitionInformation[], includeArchived: boolean, maxDepth = -1, currentDepth = 0) {
|
||||
const definitions: TableData[] = [];
|
||||
const childBranches = parentNote.getChildBranches();
|
||||
let hasSubtree = false;
|
||||
@ -21,8 +22,8 @@ export async function buildRowDefinitions(parentNote: FNote, infos: AttributeDef
|
||||
|
||||
for (const branch of childBranches) {
|
||||
const note = await branch.getNote();
|
||||
if (!note) {
|
||||
continue; // Skip if the note is not found
|
||||
if (!note || (!includeArchived && note.isArchived)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const labels: typeof definitions[0]["labels"] = {};
|
||||
@ -41,12 +42,13 @@ export async function buildRowDefinitions(parentNote: FNote, infos: AttributeDef
|
||||
title: note.title,
|
||||
labels,
|
||||
relations,
|
||||
isArchived: note.isArchived,
|
||||
branchId: branch.branchId,
|
||||
colorClass: note.getColorClass()
|
||||
}
|
||||
|
||||
if (note.hasChildren() && (maxDepth < 0 || currentDepth < maxDepth)) {
|
||||
const { definitions, rowNumber: subRowNumber } = (await buildRowDefinitions(note, infos, maxDepth, currentDepth + 1));
|
||||
const { definitions, rowNumber: subRowNumber } = (await buildRowDefinitions(note, infos, includeArchived, maxDepth, currentDepth + 1));
|
||||
def._children = definitions;
|
||||
hasSubtree = true;
|
||||
rowNumber += subRowNumber;
|
72
apps/client/src/widgets/collections/table/tabulator.tsx
Normal file
72
apps/client/src/widgets/collections/table/tabulator.tsx
Normal file
@ -0,0 +1,72 @@
|
||||
import { useContext, useEffect, useLayoutEffect, useRef } from "preact/hooks";
|
||||
import { EventCallBackMethods, Module, Options, Tabulator as VanillaTabulator } from "tabulator-tables";
|
||||
import "tabulator-tables/dist/css/tabulator.css";
|
||||
import "../../../../src/stylesheets/table.css";
|
||||
import { ParentComponent, renderReactWidget } from "../../react/react_utils";
|
||||
import { JSX } from "preact/jsx-runtime";
|
||||
import { isValidElement, RefObject } from "preact";
|
||||
|
||||
interface TableProps<T> extends Omit<Options, "data" | "footerElement" | "index"> {
|
||||
tabulatorRef: RefObject<VanillaTabulator>;
|
||||
className?: string;
|
||||
data?: T[];
|
||||
modules?: (new (table: VanillaTabulator) => Module)[];
|
||||
events?: Partial<EventCallBackMethods>;
|
||||
index: keyof T;
|
||||
footerElement?: string | HTMLElement | JSX.Element;
|
||||
}
|
||||
|
||||
export default function Tabulator<T>({ className, columns, data, modules, tabulatorRef: externalTabulatorRef, footerElement, events, index, ...restProps }: TableProps<T>) {
|
||||
const parentComponent = useContext(ParentComponent);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const tabulatorRef = useRef<VanillaTabulator>(null);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (!modules) return;
|
||||
for (const module of modules) {
|
||||
VanillaTabulator.registerModule(module);
|
||||
}
|
||||
}, [modules]);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (!containerRef.current) return;
|
||||
|
||||
const tabulator = new VanillaTabulator(containerRef.current, {
|
||||
columns,
|
||||
data,
|
||||
footerElement: (parentComponent && isValidElement(footerElement) ? renderReactWidget(parentComponent, footerElement)[0] : undefined),
|
||||
index: index as string | number | undefined,
|
||||
...restProps
|
||||
});
|
||||
|
||||
tabulator.on("tableBuilt", () => {
|
||||
tabulatorRef.current = tabulator;
|
||||
externalTabulatorRef.current = tabulator;
|
||||
});
|
||||
|
||||
return () => tabulator.destroy();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const tabulator = tabulatorRef.current;
|
||||
if (!tabulator || !events) return;
|
||||
|
||||
for (const [ eventName, handler ] of Object.entries(events)) {
|
||||
tabulator.on(eventName as keyof EventCallBackMethods, handler);
|
||||
}
|
||||
|
||||
return () => {
|
||||
for (const [ eventName, handler ] of Object.entries(events)) {
|
||||
tabulator.off(eventName as keyof EventCallBackMethods, handler);
|
||||
}
|
||||
}
|
||||
}, Object.values(events ?? {}));
|
||||
|
||||
// Change in data.
|
||||
useEffect(() => { tabulatorRef.current?.setData(data) }, [ data ]);
|
||||
useEffect(() => { columns && tabulatorRef.current?.setColumns(columns)}, [ data]);
|
||||
|
||||
return (
|
||||
<div ref={containerRef} className={className} />
|
||||
);
|
||||
}
|
21
apps/client/src/widgets/collections/table/utils.ts
Normal file
21
apps/client/src/widgets/collections/table/utils.ts
Normal file
@ -0,0 +1,21 @@
|
||||
import FNote from "../../../entities/fnote";
|
||||
import { Attribute } from "../../../services/attribute_parser";
|
||||
|
||||
export function getFAttributeFromField(parentNote: FNote, field: string) {
|
||||
const [ type, name ] = field.split(".", 2);
|
||||
const attrName = `${type.replace("s", "")}:${name}`;
|
||||
return parentNote.getLabel(attrName);
|
||||
}
|
||||
|
||||
export function getAttributeFromField(parentNote: FNote, field: string): Attribute | undefined {
|
||||
const fAttribute = getFAttributeFromField(parentNote, field);
|
||||
if (fAttribute) {
|
||||
return {
|
||||
name: fAttribute.name,
|
||||
value: fAttribute.value,
|
||||
type: fAttribute.type,
|
||||
isInheritable: fAttribute.isInheritable
|
||||
};
|
||||
}
|
||||
return undefined;
|
||||
}
|
@ -1,6 +1,6 @@
|
||||
import type FNote from "../../entities/fnote";
|
||||
import type { ViewTypeOptions } from "../../services/note_list_renderer";
|
||||
import server from "../../services/server";
|
||||
import { ViewTypeOptions } from "../collections/interface";
|
||||
|
||||
const ATTACHMENT_ROLE = "viewConfig";
|
||||
|
@ -140,7 +140,7 @@ ws.subscribeToMessages(async (message) => {
|
||||
};
|
||||
}
|
||||
|
||||
if (message.taskType !== "export") {
|
||||
if (!("taskType" in message) || message.taskType !== "export") {
|
||||
return;
|
||||
}
|
||||
|
||||
|
@ -1,178 +0,0 @@
|
||||
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, CommandMappings, CommandNames, EventData, EventNames } from "../components/app_context.js";
|
||||
import type ViewMode from "./view_widgets/view_mode.js";
|
||||
|
||||
const TPL = /*html*/`
|
||||
<div class="note-list-widget">
|
||||
<style>
|
||||
.note-list-widget {
|
||||
min-height: 0;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.note-list-widget .note-list {
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
.note-list-widget.full-height,
|
||||
.note-list-widget.full-height .note-list-widget-content {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.note-list-widget video {
|
||||
height: 100%;
|
||||
}
|
||||
</style>
|
||||
|
||||
<div class="note-list-widget-content">
|
||||
</div>
|
||||
</div>`;
|
||||
|
||||
export default class NoteListWidget extends NoteContextAwareWidget {
|
||||
|
||||
private $content!: JQuery<HTMLElement>;
|
||||
private isIntersecting?: boolean;
|
||||
private noteIdRefreshed?: string;
|
||||
private shownNoteId?: string | null;
|
||||
private viewMode?: ViewMode<any> | null;
|
||||
private displayOnlyCollections: boolean;
|
||||
|
||||
/**
|
||||
* @param displayOnlyCollections if set to `true` then only collection-type views are displayed such as geo-map and the calendar. The original book types grid and list will be ignored.
|
||||
*/
|
||||
constructor(displayOnlyCollections: boolean) {
|
||||
super();
|
||||
|
||||
this.displayOnlyCollections = displayOnlyCollections;
|
||||
}
|
||||
|
||||
isEnabled() {
|
||||
if (!super.isEnabled()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (this.displayOnlyCollections && this.note?.type !== "book") {
|
||||
const viewType = this.note?.getLabelValue("viewType");
|
||||
if (!viewType || ["grid", "list"].includes(viewType)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return this.noteContext?.hasNoteList();
|
||||
}
|
||||
|
||||
doRender() {
|
||||
this.$widget = $(TPL);
|
||||
this.contentSized();
|
||||
this.$content = this.$widget.find(".note-list-widget-content");
|
||||
|
||||
const observer = new IntersectionObserver(
|
||||
(entries) => {
|
||||
this.isIntersecting = entries[0].isIntersecting;
|
||||
|
||||
this.checkRenderStatus();
|
||||
},
|
||||
{
|
||||
rootMargin: "50px",
|
||||
threshold: 0.1
|
||||
}
|
||||
);
|
||||
|
||||
// there seems to be a race condition on Firefox which triggers the observer only before the widget is visible
|
||||
// (intersection is false). https://github.com/zadam/trilium/issues/4165
|
||||
setTimeout(() => observer.observe(this.$widget[0]), 10);
|
||||
}
|
||||
|
||||
checkRenderStatus() {
|
||||
// console.log("this.isIntersecting", this.isIntersecting);
|
||||
// console.log(`${this.noteIdRefreshed} === ${this.noteId}`, this.noteIdRefreshed === this.noteId);
|
||||
// console.log("this.shownNoteId !== this.noteId", this.shownNoteId !== this.noteId);
|
||||
|
||||
if (this.note && this.isIntersecting && this.noteIdRefreshed === this.noteId && this.shownNoteId !== this.noteId) {
|
||||
this.shownNoteId = this.noteId;
|
||||
this.renderNoteList(this.note);
|
||||
}
|
||||
}
|
||||
|
||||
async renderNoteList(note: FNote) {
|
||||
const noteListRenderer = new NoteListRenderer({
|
||||
$parent: this.$content,
|
||||
parentNote: note,
|
||||
parentNotePath: this.notePath
|
||||
});
|
||||
this.$widget.toggleClass("full-height", noteListRenderer.isFullHeight);
|
||||
await noteListRenderer.renderList();
|
||||
this.viewMode = noteListRenderer.viewMode;
|
||||
}
|
||||
|
||||
async refresh() {
|
||||
this.shownNoteId = null;
|
||||
|
||||
await super.refresh();
|
||||
}
|
||||
|
||||
async refreshNoteListEvent({ noteId }: EventData<"refreshNoteList">) {
|
||||
if (this.isNote(noteId) && this.note) {
|
||||
await this.renderNoteList(this.note);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* We have this event so that we evaluate intersection only after note detail is loaded.
|
||||
* If it's evaluated before note detail, then it's clearly intersected (visible) although after note detail load
|
||||
* it is not intersected (visible) anymore.
|
||||
*/
|
||||
noteDetailRefreshedEvent({ ntxId }: EventData<"noteDetailRefreshed">) {
|
||||
if (!this.isNoteContext(ntxId)) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.noteIdRefreshed = this.noteId;
|
||||
|
||||
setTimeout(() => this.checkRenderStatus(), 100);
|
||||
}
|
||||
|
||||
notesReloadedEvent({ noteIds }: EventData<"notesReloaded">) {
|
||||
if (this.noteId && noteIds.includes(this.noteId)) {
|
||||
this.refresh();
|
||||
}
|
||||
}
|
||||
|
||||
entitiesReloadedEvent(e: EventData<"entitiesReloaded">) {
|
||||
if (e.loadResults.getAttributeRows().find((attr) => attr.noteId === this.noteId && attr.name && ["viewType", "expanded", "pageSize"].includes(attr.name))) {
|
||||
this.refresh();
|
||||
this.checkRenderStatus();
|
||||
}
|
||||
}
|
||||
|
||||
buildTouchBarCommand(data: CommandListenerData<"buildTouchBar">) {
|
||||
if (this.viewMode && "buildTouchBarCommand" in this.viewMode) {
|
||||
return (this.viewMode as CommandListener<"buildTouchBar">).buildTouchBarCommand(data);
|
||||
}
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
handleEventInChildren<T extends EventNames>(name: T, data: EventData<T>): Promise<unknown[] | unknown> | null {
|
||||
super.handleEventInChildren(name, data);
|
||||
|
||||
if (this.viewMode) {
|
||||
const ret = this.viewMode.handleEvent(name, data);
|
||||
if (ret) {
|
||||
return ret;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
}
|
@ -195,6 +195,8 @@ export interface DragData {
|
||||
title: string;
|
||||
}
|
||||
|
||||
export const TREE_CLIPBOARD_TYPE = "application/x-fancytree-node";
|
||||
|
||||
export default class NoteTreeWidget extends NoteContextAwareWidget {
|
||||
private $tree!: JQuery<HTMLElement>;
|
||||
private $treeActions!: JQuery<HTMLElement>;
|
||||
|
@ -11,9 +11,10 @@ export interface ActionButtonProps {
|
||||
onClick?: (e: MouseEvent) => void;
|
||||
triggerCommand?: CommandNames;
|
||||
noIconActionClass?: boolean;
|
||||
frame?: boolean;
|
||||
}
|
||||
|
||||
export default function ActionButton({ text, icon, className, onClick, triggerCommand, titlePosition, noIconActionClass }: ActionButtonProps) {
|
||||
export default function ActionButton({ text, icon, className, onClick, triggerCommand, titlePosition, noIconActionClass, frame }: ActionButtonProps) {
|
||||
const buttonRef = useRef<HTMLButtonElement>(null);
|
||||
const [ keyboardShortcut, setKeyboardShortcut ] = useState<string[]>();
|
||||
|
||||
@ -31,7 +32,7 @@ export default function ActionButton({ text, icon, className, onClick, triggerCo
|
||||
|
||||
return <button
|
||||
ref={buttonRef}
|
||||
class={`${className ?? ""} ${!noIconActionClass ? "icon-action" : "btn"} ${icon}`}
|
||||
class={`${className ?? ""} ${!noIconActionClass ? "icon-action" : "btn"} ${icon} ${frame ? "btn btn-primary" : ""}`}
|
||||
onClick={onClick}
|
||||
data-trigger-command={triggerCommand}
|
||||
/>;
|
||||
|
@ -1,4 +1,4 @@
|
||||
import type { RefObject } from "preact";
|
||||
import type { ComponentChildren, RefObject } from "preact";
|
||||
import type { CSSProperties } from "preact/compat";
|
||||
import { useMemo } from "preact/hooks";
|
||||
import { memo } from "preact/compat";
|
||||
@ -72,4 +72,12 @@ const Button = memo(({ name, buttonRef, className, text, onClick, keyboardShortc
|
||||
);
|
||||
});
|
||||
|
||||
export function ButtonGroup({ children }: { children: ComponentChildren }) {
|
||||
return (
|
||||
<div className="btn-group" role="group">
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default Button;
|
@ -1,7 +1,8 @@
|
||||
interface IconProps {
|
||||
icon?: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export default function Icon({ icon }: IconProps) {
|
||||
return <span class={icon ?? "bx bx-empty"}></span>
|
||||
export default function Icon({ icon, className }: IconProps) {
|
||||
return <span class={`${icon ?? "bx bx-empty"} ${className ?? ""}`}></span>
|
||||
}
|
@ -1,25 +1,35 @@
|
||||
import { useEffect, useState } from "preact/hooks";
|
||||
import { useEffect, useRef, useState } from "preact/hooks";
|
||||
import link from "../../services/link";
|
||||
import RawHtml from "./RawHtml";
|
||||
import { useImperativeSearchHighlighlighting } from "./hooks";
|
||||
|
||||
interface NoteLinkOpts {
|
||||
className?: string;
|
||||
notePath: string | string[];
|
||||
showNotePath?: boolean;
|
||||
showNoteIcon?: boolean;
|
||||
style?: Record<string, string | number>;
|
||||
noPreview?: boolean;
|
||||
noTnLink?: boolean;
|
||||
highlightedTokens?: string[] | null | undefined;
|
||||
}
|
||||
|
||||
export default function NoteLink({ notePath, showNotePath, showNoteIcon, style, noPreview, noTnLink }: NoteLinkOpts) {
|
||||
export default function NoteLink({ className, notePath, showNotePath, showNoteIcon, style, noPreview, noTnLink, highlightedTokens }: NoteLinkOpts) {
|
||||
const stringifiedNotePath = Array.isArray(notePath) ? notePath.join("/") : notePath;
|
||||
const ref = useRef<HTMLSpanElement>(null);
|
||||
const [ jqueryEl, setJqueryEl ] = useState<JQuery<HTMLElement>>();
|
||||
const highlightSearch = useImperativeSearchHighlighlighting(highlightedTokens);
|
||||
|
||||
useEffect(() => {
|
||||
link.createLink(stringifiedNotePath, { showNotePath, showNoteIcon })
|
||||
.then(setJqueryEl);
|
||||
}, [ stringifiedNotePath, showNotePath ]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!ref.current || !jqueryEl) return;
|
||||
ref.current.replaceChildren(jqueryEl[0]);
|
||||
highlightSearch(ref.current);
|
||||
}, [ jqueryEl ]);
|
||||
|
||||
if (style) {
|
||||
jqueryEl?.css(style);
|
||||
}
|
||||
@ -33,6 +43,10 @@ export default function NoteLink({ notePath, showNotePath, showNoteIcon, style,
|
||||
$linkEl?.addClass("tn-link");
|
||||
}
|
||||
|
||||
return <RawHtml html={jqueryEl} />
|
||||
if (className) {
|
||||
$linkEl?.addClass(className);
|
||||
}
|
||||
|
||||
return <span ref={ref} />
|
||||
|
||||
}
|
@ -1,4 +1,4 @@
|
||||
import type { CSSProperties } from "preact/compat";
|
||||
import type { CSSProperties, RefObject } from "preact/compat";
|
||||
|
||||
type HTMLElementLike = string | HTMLElement | JQuery<HTMLElement>;
|
||||
|
||||
@ -9,12 +9,12 @@ interface RawHtmlProps {
|
||||
onClick?: (e: MouseEvent) => void;
|
||||
}
|
||||
|
||||
export default function RawHtml(props: RawHtmlProps) {
|
||||
return <span {...getProps(props)} />;
|
||||
export default function RawHtml({containerRef, ...props}: RawHtmlProps & { containerRef?: RefObject<HTMLSpanElement>}) {
|
||||
return <span ref={containerRef} {...getProps(props)} />;
|
||||
}
|
||||
|
||||
export function RawHtmlBlock(props: RawHtmlProps) {
|
||||
return <div {...getProps(props)} />
|
||||
export function RawHtmlBlock({containerRef, ...props}: RawHtmlProps & { containerRef?: RefObject<HTMLDivElement>}) {
|
||||
return <div ref={containerRef} {...getProps(props)} />
|
||||
}
|
||||
|
||||
function getProps({ className, html, style, onClick }: RawHtmlProps) {
|
||||
|
204
apps/client/src/widgets/react/TouchBar.tsx
Normal file
204
apps/client/src/widgets/react/TouchBar.tsx
Normal file
@ -0,0 +1,204 @@
|
||||
import { useContext, useEffect, useMemo, useState } from "preact/hooks";
|
||||
import { ParentComponent } from "./react_utils";
|
||||
import { ComponentChildren, createContext } from "preact";
|
||||
import { TouchBarItem } from "../../components/touch_bar";
|
||||
import { dynamicRequire, isElectron, isMac } from "../../services/utils";
|
||||
|
||||
interface TouchBarProps {
|
||||
children: ComponentChildren;
|
||||
}
|
||||
|
||||
interface LabelProps {
|
||||
label: string;
|
||||
}
|
||||
|
||||
interface SliderProps {
|
||||
label: string;
|
||||
value: number;
|
||||
minValue: number;
|
||||
maxValue: number;
|
||||
onChange: (newValue: number) => void;
|
||||
}
|
||||
|
||||
interface ButtonProps {
|
||||
label?: string;
|
||||
icon?: string;
|
||||
click: () => void;
|
||||
enabled?: boolean;
|
||||
}
|
||||
|
||||
interface SpacerProps {
|
||||
size: "flexible" | "large" | "small";
|
||||
}
|
||||
|
||||
interface SegmentedControlProps {
|
||||
mode: "single" | "buttons";
|
||||
segments: {
|
||||
label?: string;
|
||||
icon?: string;
|
||||
onClick?: () => void;
|
||||
}[];
|
||||
selectedIndex?: number;
|
||||
onChange?: (selectedIndex: number, isSelected: boolean) => void;
|
||||
}
|
||||
|
||||
interface TouchBarContextApi {
|
||||
addItem(item: TouchBarItem): void;
|
||||
TouchBar: typeof Electron.TouchBar;
|
||||
nativeImage: typeof Electron.nativeImage;
|
||||
}
|
||||
|
||||
const TouchBarContext = createContext<TouchBarContextApi | null>(null);
|
||||
|
||||
export default function TouchBar({ children }: TouchBarProps) {
|
||||
if (!isElectron() || !isMac()) {
|
||||
return;
|
||||
}
|
||||
|
||||
const [ isFocused, setIsFocused ] = useState(false);
|
||||
const parentComponent = useContext(ParentComponent);
|
||||
const remote = dynamicRequire("@electron/remote") as typeof import("@electron/remote");
|
||||
const items: TouchBarItem[] = [];
|
||||
|
||||
const api: TouchBarContextApi = {
|
||||
TouchBar: remote.TouchBar,
|
||||
nativeImage: remote.nativeImage,
|
||||
addItem: (item) => {
|
||||
items.push(item);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const el = parentComponent?.$widget[0];
|
||||
if (!el) return;
|
||||
|
||||
function onFocusGained() {
|
||||
setIsFocused(true);
|
||||
}
|
||||
|
||||
function onFocusLost() {
|
||||
setIsFocused(false);
|
||||
}
|
||||
|
||||
el.addEventListener("focusin", onFocusGained);
|
||||
el.addEventListener("focusout", onFocusLost);
|
||||
return () => {
|
||||
el.removeEventListener("focusin", onFocusGained);
|
||||
el.removeEventListener("focusout", onFocusLost);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (isFocused) {
|
||||
remote.getCurrentWindow().setTouchBar(new remote.TouchBar({ items }));
|
||||
}
|
||||
});
|
||||
|
||||
return (
|
||||
<TouchBarContext.Provider value={api}>
|
||||
{children}
|
||||
</TouchBarContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function TouchBarLabel({ label }: LabelProps) {
|
||||
const api = useContext(TouchBarContext);
|
||||
|
||||
if (api) {
|
||||
const item = new api.TouchBar.TouchBarLabel({
|
||||
label
|
||||
});
|
||||
api.addItem(item);
|
||||
}
|
||||
|
||||
return <></>;
|
||||
}
|
||||
|
||||
export function TouchBarSlider({ label, value, minValue, maxValue, onChange }: SliderProps) {
|
||||
const api = useContext(TouchBarContext);
|
||||
|
||||
if (api) {
|
||||
const item = new api.TouchBar.TouchBarSlider({
|
||||
label,
|
||||
value, minValue, maxValue,
|
||||
change: onChange
|
||||
});
|
||||
api.addItem(item);
|
||||
}
|
||||
|
||||
return <></>;
|
||||
}
|
||||
|
||||
export function TouchBarButton({ label, icon, click, enabled }: ButtonProps) {
|
||||
const api = useContext(TouchBarContext);
|
||||
const item = useMemo(() => {
|
||||
if (!api) return null;
|
||||
return new api.TouchBar.TouchBarButton({
|
||||
label, click, enabled,
|
||||
icon: icon ? buildIcon(api.nativeImage, icon) : undefined
|
||||
});
|
||||
}, [ label, icon ]);
|
||||
|
||||
if (item && api) {
|
||||
api.addItem(item);
|
||||
}
|
||||
|
||||
return <></>;
|
||||
}
|
||||
|
||||
export function TouchBarSegmentedControl({ mode, segments, selectedIndex, onChange }: SegmentedControlProps) {
|
||||
const api = useContext(TouchBarContext);
|
||||
|
||||
if (api) {
|
||||
const processedSegments: Electron.SegmentedControlSegment[] = segments.map(({icon, ...restProps}) => ({
|
||||
...restProps,
|
||||
icon: icon ? buildIcon(api.nativeImage, icon) : undefined
|
||||
}));
|
||||
const item = new api.TouchBar.TouchBarSegmentedControl({
|
||||
mode, selectedIndex,
|
||||
segments: processedSegments,
|
||||
change: (selectedIndex, isSelected) => {
|
||||
if (segments[selectedIndex].onClick) {
|
||||
segments[selectedIndex].onClick();
|
||||
} else if (onChange) {
|
||||
onChange(selectedIndex, isSelected);
|
||||
}
|
||||
}
|
||||
});
|
||||
api.addItem(item);
|
||||
}
|
||||
|
||||
return <></>;
|
||||
}
|
||||
|
||||
export function TouchBarSpacer({ size }: SpacerProps) {
|
||||
const api = useContext(TouchBarContext);
|
||||
|
||||
if (api) {
|
||||
const item = new api.TouchBar.TouchBarSpacer({
|
||||
size
|
||||
});
|
||||
api.addItem(item);
|
||||
}
|
||||
|
||||
return <></>;
|
||||
}
|
||||
|
||||
function buildIcon(nativeImage: typeof Electron.nativeImage, name: string) {
|
||||
const sourceImage = nativeImage.createFromNamedImage(name, [-1, 0, 1]);
|
||||
const { width, height } = sourceImage.getSize();
|
||||
const newImage = nativeImage.createEmpty();
|
||||
newImage.addRepresentation({
|
||||
scaleFactor: 1,
|
||||
width: width / 2,
|
||||
height: height / 2,
|
||||
buffer: sourceImage.resize({ height: height / 2 }).toBitmap()
|
||||
});
|
||||
newImage.addRepresentation({
|
||||
scaleFactor: 2,
|
||||
width: width,
|
||||
height: height,
|
||||
buffer: sourceImage.toBitmap()
|
||||
});
|
||||
return newImage;
|
||||
}
|
@ -1,10 +1,10 @@
|
||||
import { useCallback, useContext, useDebugValue, useEffect, useLayoutEffect, useMemo, useRef, useState } from "preact/hooks";
|
||||
import { EventData, EventNames } from "../../components/app_context";
|
||||
import { Inputs, MutableRef, useCallback, useContext, useDebugValue, useEffect, useLayoutEffect, useMemo, useRef, useState } from "preact/hooks";
|
||||
import { CommandListenerData, EventData, EventNames } from "../../components/app_context";
|
||||
import { ParentComponent } from "./react_utils";
|
||||
import SpacedUpdate from "../../services/spaced_update";
|
||||
import { KeyboardActionNames, OptionNames } from "@triliumnext/commons";
|
||||
import { FilterLabelsByType, KeyboardActionNames, OptionNames, RelationNames } from "@triliumnext/commons";
|
||||
import options, { type OptionValue } from "../../services/options";
|
||||
import utils, { reloadFrontendApp } from "../../services/utils";
|
||||
import utils, { escapeRegExp, reloadFrontendApp } from "../../services/utils";
|
||||
import NoteContext from "../../components/note_context";
|
||||
import BasicWidget, { ReactWrappedWidget } from "../basic_widget";
|
||||
import FNote from "../../entities/fnote";
|
||||
@ -15,6 +15,10 @@ import { RefObject, VNode } from "preact";
|
||||
import { Tooltip } from "bootstrap";
|
||||
import { CSSProperties } from "preact/compat";
|
||||
import keyboard_actions from "../../services/keyboard_actions";
|
||||
import Mark from "mark.js";
|
||||
import { DragData } from "../note_tree";
|
||||
import Component from "../../components/component";
|
||||
import toast, { ToastOptions } from "../../services/toast";
|
||||
|
||||
export function useTriliumEvent<T extends EventNames>(eventName: T, handler: (data: EventData<T>) => void) {
|
||||
const parentComponent = useContext(ParentComponent);
|
||||
@ -51,21 +55,16 @@ export function useTriliumEvents<T extends EventNames>(eventNames: T[], handler:
|
||||
|
||||
export function useSpacedUpdate(callback: () => void | Promise<void>, interval = 1000) {
|
||||
const callbackRef = useRef(callback);
|
||||
const spacedUpdateRef = useRef<SpacedUpdate>();
|
||||
const spacedUpdateRef = useRef<SpacedUpdate>(new SpacedUpdate(
|
||||
() => callbackRef.current(),
|
||||
interval
|
||||
));
|
||||
|
||||
// Update callback ref when it changes
|
||||
useEffect(() => {
|
||||
callbackRef.current = callback;
|
||||
}, [callback]);
|
||||
|
||||
// Create SpacedUpdate instance only once
|
||||
if (!spacedUpdateRef.current) {
|
||||
spacedUpdateRef.current = new SpacedUpdate(
|
||||
() => callbackRef.current(),
|
||||
interval
|
||||
);
|
||||
}
|
||||
|
||||
// Update interval if it changes
|
||||
useEffect(() => {
|
||||
spacedUpdateRef.current?.setUpdateInterval(interval);
|
||||
@ -259,7 +258,7 @@ export function useNoteProperty<T extends keyof FNote>(note: FNote | null | unde
|
||||
return note?.[property];
|
||||
}
|
||||
|
||||
export function useNoteRelation(note: FNote | undefined | null, relationName: string): [string | null | undefined, (newValue: string) => void] {
|
||||
export function useNoteRelation(note: FNote | undefined | null, relationName: RelationNames): [string | null | undefined, (newValue: string) => void] {
|
||||
const [ relationValue, setRelationValue ] = useState<string | null | undefined>(note?.getRelationValue(relationName));
|
||||
|
||||
useEffect(() => setRelationValue(note?.getRelationValue(relationName) ?? null), [ note ]);
|
||||
@ -292,14 +291,18 @@ export function useNoteRelation(note: FNote | undefined | null, relationName: st
|
||||
* @param labelName the name of the label to read/write.
|
||||
* @returns an array where the first element is the getter and the second element is the setter. The setter has a special behaviour for convenience: if the value is undefined, the label is created without a value (e.g. a tag), if the value is null then the label is removed.
|
||||
*/
|
||||
export function useNoteLabel(note: FNote | undefined | null, labelName: string): [string | null | undefined, (newValue: string | null | undefined) => void] {
|
||||
const [ labelValue, setLabelValue ] = useState<string | null | undefined>(note?.getLabelValue(labelName));
|
||||
export function useNoteLabel(note: FNote | undefined | null, labelName: FilterLabelsByType<string>): [string | null | undefined, (newValue: string | null | undefined) => void] {
|
||||
const [ , setLabelValue ] = useState<string | null | undefined>();
|
||||
|
||||
useEffect(() => setLabelValue(note?.getLabelValue(labelName) ?? null), [ note ]);
|
||||
useTriliumEvent("entitiesReloaded", ({ loadResults }) => {
|
||||
for (const attr of loadResults.getAttributeRows()) {
|
||||
if (attr.type === "label" && attr.name === labelName && attributes.isAffecting(attr, note)) {
|
||||
setLabelValue(attr.value ?? null);
|
||||
if (!attr.isDeleted) {
|
||||
setLabelValue(attr.value);
|
||||
} else {
|
||||
setLabelValue(null);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
@ -317,12 +320,17 @@ export function useNoteLabel(note: FNote | undefined | null, labelName: string):
|
||||
useDebugValue(labelName);
|
||||
|
||||
return [
|
||||
labelValue,
|
||||
note?.getLabelValue(labelName),
|
||||
setter
|
||||
] as const;
|
||||
}
|
||||
|
||||
export function useNoteLabelBoolean(note: FNote | undefined | null, labelName: string): [ boolean, (newValue: boolean) => void] {
|
||||
export function useNoteLabelWithDefault(note: FNote | undefined | null, labelName: FilterLabelsByType<string>, defaultValue: string): [string, (newValue: string | null | undefined) => void] {
|
||||
const [ labelValue, setLabelValue ] = useNoteLabel(note, labelName);
|
||||
return [ labelValue ?? defaultValue, setLabelValue];
|
||||
}
|
||||
|
||||
export function useNoteLabelBoolean(note: FNote | undefined | null, labelName: FilterLabelsByType<boolean>): [ boolean, (newValue: boolean) => void] {
|
||||
const [ labelValue, setLabelValue ] = useState<boolean>(!!note?.hasLabel(labelName));
|
||||
|
||||
useEffect(() => setLabelValue(!!note?.hasLabel(labelName)), [ note ]);
|
||||
@ -350,7 +358,17 @@ export function useNoteLabelBoolean(note: FNote | undefined | null, labelName: s
|
||||
return [ labelValue, setter ] as const;
|
||||
}
|
||||
|
||||
export function useNoteBlob(note: FNote | null | undefined): [ FBlob | null | undefined ] {
|
||||
export function useNoteLabelInt(note: FNote | undefined | null, labelName: FilterLabelsByType<number>): [ number | undefined, (newValue: number) => void] {
|
||||
//@ts-expect-error `useNoteLabel` only accepts string properties but we need to be able to read number ones.
|
||||
const [ value, setValue ] = useNoteLabel(note, labelName);
|
||||
useDebugValue(labelName);
|
||||
return [
|
||||
(value ? parseInt(value, 10) : undefined),
|
||||
(newValue) => setValue(String(newValue))
|
||||
]
|
||||
}
|
||||
|
||||
export function useNoteBlob(note: FNote | null | undefined): FBlob | null | undefined {
|
||||
const [ blob, setBlob ] = useState<FBlob | null>();
|
||||
|
||||
function refresh() {
|
||||
@ -359,14 +377,23 @@ export function useNoteBlob(note: FNote | null | undefined): [ FBlob | null | un
|
||||
|
||||
useEffect(refresh, [ note?.noteId ]);
|
||||
useTriliumEvent("entitiesReloaded", ({ loadResults }) => {
|
||||
if (note && loadResults.hasRevisionForNote(note.noteId)) {
|
||||
if (!note) return;
|
||||
|
||||
// Check if the note was deleted.
|
||||
if (loadResults.getEntityRow("notes", note.noteId)?.isDeleted) {
|
||||
setBlob(null);
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if a revision occurred.
|
||||
if (loadResults.hasRevisionForNote(note.noteId)) {
|
||||
refresh();
|
||||
}
|
||||
});
|
||||
|
||||
useDebugValue(note?.noteId);
|
||||
|
||||
return [ blob ] as const;
|
||||
return blob;
|
||||
}
|
||||
|
||||
export function useLegacyWidget<T extends BasicWidget>(widgetFactory: () => T, { noteContext, containerClassName, containerStyle }: {
|
||||
@ -548,3 +575,121 @@ export function useSyncedRef<T>(externalRef?: RefObject<T>, initialValue: T | nu
|
||||
|
||||
return ref;
|
||||
}
|
||||
|
||||
export function useImperativeSearchHighlighlighting(highlightedTokens: string[] | null | undefined) {
|
||||
const mark = useRef<Mark>();
|
||||
const highlightRegex = useMemo(() => {
|
||||
if (!highlightedTokens?.length) return null;
|
||||
const regex = highlightedTokens.map((token) => escapeRegExp(token)).join("|");
|
||||
return new RegExp(regex, "gi")
|
||||
}, [ highlightedTokens ]);
|
||||
|
||||
return (el: HTMLElement | null | undefined) => {
|
||||
if (!el || !highlightRegex) return;
|
||||
|
||||
if (!mark.current) {
|
||||
mark.current = new Mark(el);
|
||||
}
|
||||
|
||||
mark.current.unmark();
|
||||
mark.current.markRegExp(highlightRegex, {
|
||||
element: "span",
|
||||
className: "ck-find-result"
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
export function useNoteTreeDrag(containerRef: MutableRef<HTMLElement | null | undefined>, { dragEnabled, dragNotEnabledMessage, callback }: {
|
||||
dragEnabled: boolean,
|
||||
dragNotEnabledMessage: Omit<ToastOptions, "id" | "closeAfter">;
|
||||
callback: (data: DragData[], e: DragEvent) => void
|
||||
}) {
|
||||
useEffect(() => {
|
||||
const container = containerRef.current;
|
||||
if (!container) return;
|
||||
|
||||
function onDragEnter(e: DragEvent) {
|
||||
if (!dragEnabled) {
|
||||
toast.showPersistent({
|
||||
...dragNotEnabledMessage,
|
||||
id: "drag-not-enabled",
|
||||
closeAfter: 5000
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function onDragOver(e: DragEvent) {
|
||||
e.preventDefault();
|
||||
}
|
||||
|
||||
function onDrop(e: DragEvent) {
|
||||
toast.closePersistent("drag-not-enabled");
|
||||
if (!dragEnabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
const data = e.dataTransfer?.getData('text');
|
||||
if (!data) {
|
||||
return;
|
||||
}
|
||||
|
||||
const parsedData = JSON.parse(data) as DragData[];
|
||||
if (!parsedData.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
callback(parsedData, e);
|
||||
}
|
||||
|
||||
function onDragLeave() {
|
||||
toast.closePersistent("drag-not-enabled");
|
||||
}
|
||||
|
||||
container.addEventListener("dragenter", onDragEnter);
|
||||
container.addEventListener("dragover", onDragOver);
|
||||
container.addEventListener("drop", onDrop);
|
||||
container.addEventListener("dragleave", onDragLeave)
|
||||
|
||||
return () => {
|
||||
container.removeEventListener("dragenter", onDragEnter);
|
||||
container.removeEventListener("dragover", onDragOver);
|
||||
container.removeEventListener("drop", onDrop);
|
||||
container.removeEventListener("dragleave", onDragLeave);
|
||||
};
|
||||
}, [ containerRef, callback ]);
|
||||
}
|
||||
|
||||
export function useTouchBar(
|
||||
factory: (context: CommandListenerData<"buildTouchBar"> & { parentComponent: Component | null }) => void,
|
||||
inputs: Inputs
|
||||
) {
|
||||
const parentComponent = useContext(ParentComponent);
|
||||
|
||||
useLegacyImperativeHandlers({
|
||||
buildTouchBarCommand(context: CommandListenerData<"buildTouchBar">) {
|
||||
return factory({
|
||||
...context,
|
||||
parentComponent
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
parentComponent?.triggerCommand("refreshTouchBar");
|
||||
}, inputs);
|
||||
}
|
||||
|
||||
export function useResizeObserver(ref: RefObject<HTMLElement>, callback: () => void) {
|
||||
const resizeObserver = useRef<ResizeObserver>(null);
|
||||
useEffect(() => {
|
||||
resizeObserver.current?.disconnect();
|
||||
const observer = new ResizeObserver(callback);
|
||||
resizeObserver.current = observer;
|
||||
|
||||
if (ref.current) {
|
||||
observer.observe(ref.current);
|
||||
}
|
||||
|
||||
return () => observer.disconnect();
|
||||
}, [ callback, ref ]);
|
||||
}
|
||||
|
@ -24,11 +24,11 @@ export function refToJQuerySelector<T extends HTMLElement>(ref: RefObject<T> | n
|
||||
* @param el the JSX element to render.
|
||||
* @returns the rendered wrapped DOM element.
|
||||
*/
|
||||
export function renderReactWidget(parentComponent: Component, el: JSX.Element) {
|
||||
export function renderReactWidget(parentComponent: Component | null, el: JSX.Element) {
|
||||
return renderReactWidgetAtElement(parentComponent, el, new DocumentFragment()).children();
|
||||
}
|
||||
|
||||
export function renderReactWidgetAtElement(parentComponent: Component, el: JSX.Element, container: Element | DocumentFragment) {
|
||||
export function renderReactWidgetAtElement(parentComponent: Component | null, el: JSX.Element, container: Element | DocumentFragment) {
|
||||
render((
|
||||
<ParentComponent.Provider value={parentComponent}>
|
||||
{el}
|
||||
|
@ -1,6 +1,5 @@
|
||||
import { useContext, useMemo } from "preact/hooks";
|
||||
import { t } from "../../services/i18n";
|
||||
import { ViewTypeOptions } from "../../services/note_list_renderer";
|
||||
import FormSelect, { FormSelectWithGroups } from "../react/FormSelect";
|
||||
import { TabContext } from "./ribbon-interface";
|
||||
import { mapToKeyValueArray } from "../../services/utils";
|
||||
@ -12,6 +11,7 @@ import FNote from "../../entities/fnote";
|
||||
import FormCheckbox from "../react/FormCheckbox";
|
||||
import FormTextBox from "../react/FormTextBox";
|
||||
import { ComponentChildren } from "preact";
|
||||
import { ViewTypeOptions } from "../collections/interface";
|
||||
|
||||
const VIEW_TYPE_MAPPINGS: Record<ViewTypeOptions, string> = {
|
||||
grid: t("book_properties.grid"),
|
||||
@ -24,7 +24,7 @@ const VIEW_TYPE_MAPPINGS: Record<ViewTypeOptions, string> = {
|
||||
|
||||
export default function CollectionPropertiesTab({ note }: TabContext) {
|
||||
const [ viewType, setViewType ] = useNoteLabel(note, "viewType");
|
||||
const viewTypeWithDefault = viewType ?? "grid";
|
||||
const viewTypeWithDefault = (viewType ?? "grid") as ViewTypeOptions;
|
||||
const properties = bookPropertiesConfig[viewTypeWithDefault].properties;
|
||||
|
||||
return (
|
||||
@ -32,7 +32,7 @@ export default function CollectionPropertiesTab({ note }: TabContext) {
|
||||
{note && (
|
||||
<>
|
||||
<CollectionTypeSwitcher viewType={viewTypeWithDefault} setViewType={setViewType} />
|
||||
<BookProperties note={note} properties={properties} />
|
||||
<BookProperties viewType={viewTypeWithDefault} note={note} properties={properties} />
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
@ -54,15 +54,25 @@ function CollectionTypeSwitcher({ viewType, setViewType }: { viewType: string, s
|
||||
)
|
||||
}
|
||||
|
||||
function BookProperties({ note, properties }: { note: FNote, properties: BookProperty[] }) {
|
||||
function BookProperties({ viewType, note, properties }: { viewType: ViewTypeOptions, note: FNote, properties: BookProperty[] }) {
|
||||
return (
|
||||
<div className="book-properties-container">
|
||||
<>
|
||||
{properties.map(property => (
|
||||
<div className={`type-${property}`}>
|
||||
{mapPropertyView({ note, property })}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{viewType !== "list" && viewType !== "grid" && (
|
||||
<CheckboxPropertyView
|
||||
note={note} property={{
|
||||
bindToLabel: "includeArchived",
|
||||
label: t("book_properties.include_archived_notes"),
|
||||
type: "checkbox"
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@ -108,6 +118,7 @@ function CheckboxPropertyView({ note, property }: { note: FNote, property: Check
|
||||
}
|
||||
|
||||
function NumberPropertyView({ note, property }: { note: FNote, property: NumberProperty }) {
|
||||
//@ts-expect-error Interop with text box which takes in string values even for numbers.
|
||||
const [ value, setValue ] = useNoteLabel(note, property.bindToLabel);
|
||||
|
||||
return (
|
||||
@ -130,7 +141,7 @@ function ComboBoxPropertyView({ note, property }: { note: FNote, property: Combo
|
||||
<FormSelectWithGroups
|
||||
values={property.options}
|
||||
keyProperty="value" titleProperty="label"
|
||||
currentValue={value ?? ""} onChange={setValue}
|
||||
currentValue={value ?? property.defaultValue} onChange={setValue}
|
||||
/>
|
||||
</LabelledEntry>
|
||||
)
|
||||
|
@ -12,7 +12,7 @@ import FNote from "../../entities/fnote";
|
||||
export default function FilePropertiesTab({ note }: { note?: FNote | null }) {
|
||||
const [ originalFileName ] = useNoteLabel(note, "originalFileName");
|
||||
const canAccessProtectedNote = !note?.isProtected || protected_session_holder.isProtectedSessionAvailable();
|
||||
const [ blob ] = useNoteBlob(note);
|
||||
const blob = useNoteBlob(note);
|
||||
|
||||
return (
|
||||
<div className="file-properties-widget">
|
||||
|
@ -12,7 +12,7 @@ import toast from "../../services/toast";
|
||||
|
||||
export default function ImagePropertiesTab({ note, ntxId }: TabContext) {
|
||||
const [ originalFileName ] = useNoteLabel(note, "originalFileName");
|
||||
const [ blob ] = useNoteBlob(note);
|
||||
const blob = useNoteBlob(note);
|
||||
|
||||
const parentComponent = useContext(ParentComponent);
|
||||
|
||||
|
@ -1,9 +1,10 @@
|
||||
import { t } from "i18next";
|
||||
import FNote from "../../entities/fnote";
|
||||
import attributes from "../../services/attributes";
|
||||
import { ViewTypeOptions } from "../../services/note_list_renderer"
|
||||
import NoteContextAwareWidget from "../note_context_aware_widget";
|
||||
import { DEFAULT_MAP_LAYER_NAME, MAP_LAYERS, type MapLayer } from "../view_widgets/geo_view/map_layer";
|
||||
import { DEFAULT_MAP_LAYER_NAME, MAP_LAYERS, type MapLayer } from "../collections/geomap/map_layer";
|
||||
import { ViewTypeOptions } from "../collections/interface";
|
||||
import { FilterLabelsByType } from "@triliumnext/commons";
|
||||
|
||||
interface BookConfig {
|
||||
properties: BookProperty[];
|
||||
@ -12,7 +13,7 @@ interface BookConfig {
|
||||
export interface CheckBoxProperty {
|
||||
type: "checkbox",
|
||||
label: string;
|
||||
bindToLabel: string
|
||||
bindToLabel: FilterLabelsByType<boolean>
|
||||
}
|
||||
|
||||
export interface ButtonProperty {
|
||||
@ -26,7 +27,7 @@ export interface ButtonProperty {
|
||||
export interface NumberProperty {
|
||||
type: "number",
|
||||
label: string;
|
||||
bindToLabel: string;
|
||||
bindToLabel: FilterLabelsByType<number>;
|
||||
width?: number;
|
||||
min?: number;
|
||||
}
|
||||
@ -44,7 +45,7 @@ interface ComboBoxGroup {
|
||||
export interface ComboBoxProperty {
|
||||
type: "combobox",
|
||||
label: string;
|
||||
bindToLabel: string;
|
||||
bindToLabel: FilterLabelsByType<string>;
|
||||
/**
|
||||
* The default value is used when the label is not set.
|
||||
*/
|
||||
|
@ -233,7 +233,6 @@ export default function AttributeEditor({ api, note, componentId, notePath, ntxI
|
||||
useEffect(() => refresh(), [ note ]);
|
||||
useTriliumEvent("entitiesReloaded", ({ loadResults }) => {
|
||||
if (loadResults.getAttributeRows(componentId).find((attr) => attributes.isAffecting(attr, note))) {
|
||||
console.log("Trigger due to entities reloaded");
|
||||
refresh();
|
||||
}
|
||||
});
|
||||
|
@ -179,6 +179,13 @@
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.note-info-id {
|
||||
font-variant: none;
|
||||
font-family: var(--monospace-font-family);
|
||||
font-size: 0.8em;
|
||||
vertical-align: middle !important;
|
||||
}
|
||||
/* #endregion */
|
||||
|
||||
/* #region Similar Notes */
|
||||
@ -336,31 +343,26 @@
|
||||
.book-properties-widget {
|
||||
padding: 12px 12px 6px 12px;
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.book-properties-widget > * {
|
||||
margin-right: 15px;
|
||||
}
|
||||
|
||||
.book-properties-container {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 15px;
|
||||
overflow: hidden;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.book-properties-container > div {
|
||||
margin-right: 15px;
|
||||
.book-properties-widget > * {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.book-properties-container > .type-number > label {
|
||||
.book-properties-widget > .type-number > label {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
}
|
||||
|
||||
.book-properties-container input[type="checkbox"] {
|
||||
.book-properties-widget input[type="checkbox"] {
|
||||
margin-right: 5px;
|
||||
}
|
||||
|
||||
.book-properties-container label {
|
||||
.book-properties-widget label {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
|
@ -1,9 +1,10 @@
|
||||
import { useEffect, useRef, useState } from "preact/hooks";
|
||||
import { useEffect, useState } from "preact/hooks";
|
||||
import { t } from "../services/i18n";
|
||||
import Alert from "./react/Alert";
|
||||
import { useNoteContext, useNoteProperty, useTriliumEvent } from "./react/hooks";
|
||||
import { useNoteContext, useTriliumEvent } from "./react/hooks";
|
||||
import "./search_result.css";
|
||||
import NoteListRenderer from "../services/note_list_renderer";
|
||||
import NoteList from "./collections/NoteList";
|
||||
// import NoteListRenderer from "../services/note_list_renderer";
|
||||
|
||||
enum SearchResultState {
|
||||
NO_RESULTS,
|
||||
@ -14,26 +15,18 @@ enum SearchResultState {
|
||||
export default function SearchResult() {
|
||||
const { note, ntxId } = useNoteContext();
|
||||
const [ state, setState ] = useState<SearchResultState>();
|
||||
const searchContainerRef = useRef<HTMLDivElement>(null);
|
||||
const [ highlightedTokens, setHighlightedTokens ] = useState<string[]>();
|
||||
|
||||
function refresh() {
|
||||
searchContainerRef.current?.replaceChildren();
|
||||
|
||||
if (note?.type !== "search") {
|
||||
setState(undefined);
|
||||
} else if (!note?.searchResultsLoaded) {
|
||||
setState(SearchResultState.NOT_EXECUTED);
|
||||
} else if (note.getChildNoteIds().length === 0) {
|
||||
setState(SearchResultState.NO_RESULTS);
|
||||
} else if (searchContainerRef.current) {
|
||||
} else {
|
||||
setState(SearchResultState.GOT_RESULTS);
|
||||
|
||||
const noteListRenderer = new NoteListRenderer({
|
||||
$parent: $(searchContainerRef.current),
|
||||
parentNote: note,
|
||||
showNotePath: true
|
||||
});
|
||||
noteListRenderer.renderList();
|
||||
setHighlightedTokens(note.highlightedTokens);
|
||||
}
|
||||
}
|
||||
|
||||
@ -59,7 +52,9 @@ export default function SearchResult() {
|
||||
<Alert type="info" className="search-no-results">{t("search_result.no_notes_found")}</Alert>
|
||||
)}
|
||||
|
||||
<div ref={searchContainerRef} className="search-result-widget-content" />
|
||||
{state === SearchResultState.GOT_RESULTS && (
|
||||
<NoteList note={note} highlightedTokens={highlightedTokens} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
@ -14,9 +14,10 @@ export default function SqlResults() {
|
||||
setResults(results);
|
||||
})
|
||||
|
||||
const isEnabled = note?.mime === "text/x-sqlite;schema=trilium";
|
||||
return (
|
||||
<div className="sql-result-widget">
|
||||
{note?.mime === "text/x-sqlite;schema=trilium" && (
|
||||
<div className={`sql-result-widget ${!isEnabled ? "hidden-ext" : ""}`}>
|
||||
{isEnabled && (
|
||||
results?.length === 1 && Array.isArray(results[0]) && results[0].length === 0 ? (
|
||||
<Alert type="info">
|
||||
{t("sql_result.no_rows")}
|
||||
|
@ -5,6 +5,7 @@ import options from "../services/options.js";
|
||||
import syncService from "../services/sync.js";
|
||||
import { escapeQuotes } from "../services/utils.js";
|
||||
import { Tooltip } from "bootstrap";
|
||||
import { WebSocketMessage } from "@triliumnext/commons";
|
||||
|
||||
const TPL = /*html*/`
|
||||
<div class="sync-status-widget launcher-button">
|
||||
@ -117,8 +118,7 @@ export default class SyncStatusWidget extends BasicWidget {
|
||||
this.$widget.find(`.sync-status-${className}`).show();
|
||||
}
|
||||
|
||||
// TriliumNextTODO: Use Type Message from "services/ws.ts"
|
||||
processMessage(message: { type: string; lastSyncedPush: number; data: { lastSyncedPush: number } }) {
|
||||
processMessage(message: WebSocketMessage) {
|
||||
if (message.type === "sync-pull-in-progress") {
|
||||
this.syncState = "in-progress";
|
||||
this.lastSyncedPush = message.lastSyncedPush;
|
||||
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
x
Reference in New Issue
Block a user