mirror of
https://github.com/zadam/trilium.git
synced 2025-10-20 07:08:55 +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 />)
|
||||
@ -187,4 +187,4 @@ function FilePropertiesWrapper() {
|
||||
{note?.type === "file" && <FilePropertiesTab note={note} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
@ -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;
|
||||
}
|
||||
|
||||
@ -155,4 +155,4 @@ ws.subscribeToMessages(async (message) => {
|
||||
|
||||
toastService.showPersistent(toast);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
@ -129,7 +129,7 @@ function NoteIconList({ note }: { note: FNote }) {
|
||||
class="icon-list"
|
||||
onClick={(e) => {
|
||||
const clickedTarget = e.target as HTMLElement;
|
||||
|
||||
|
||||
if (!clickedTarget.classList.contains("bx")) {
|
||||
return;
|
||||
}
|
||||
@ -152,9 +152,9 @@ function NoteIconList({ note }: { note: FNote }) {
|
||||
for (const label of getIconLabels(note)) {
|
||||
attributes.removeAttributeById(note.noteId, label.attributeId);
|
||||
}
|
||||
}}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{(iconData?.icons ?? []).map(({className, name}) => (
|
||||
@ -181,4 +181,4 @@ function getIconLabels(note: FNote) {
|
||||
return note.getOwnedLabels()
|
||||
.filter((label) => ["workspaceIconClass", "iconClass"]
|
||||
.includes(label.name));
|
||||
}
|
||||
}
|
||||
|
@ -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,18 +11,19 @@ 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[]>();
|
||||
|
||||
|
||||
useStaticTooltip(buttonRef, {
|
||||
title: keyboardShortcut?.length ? `${text} (${keyboardShortcut?.join(",")})` : text,
|
||||
placement: titlePosition ?? "bottom",
|
||||
fallbackPlacements: [ titlePosition ?? "bottom" ]
|
||||
});
|
||||
|
||||
|
||||
useEffect(() => {
|
||||
if (triggerCommand) {
|
||||
keyboard_actions.getAction(triggerCommand, true).then(action => setKeyboardShortcut(action?.effectiveShortcuts));
|
||||
@ -31,8 +32,8 @@ export default function ActionButton({ text, icon, className, onClick, triggerCo
|
||||
|
||||
return <button
|
||||
ref={buttonRef}
|
||||
class={`${className ?? ""} ${!noIconActionClass ? "icon-action" : "btn"} ${icon}`}
|
||||
onClick={onClick}
|
||||
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 default Button;
|
||||
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) {
|
||||
@ -38,4 +38,4 @@ export function getHtml(html: string | HTMLElement | JQuery<HTMLElement>) {
|
||||
return {
|
||||
__html: html as string
|
||||
};
|
||||
}
|
||||
}
|
||||
|
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);
|
||||
@ -27,7 +31,7 @@ export function useTriliumEvent<T extends EventNames>(eventName: T, handler: (da
|
||||
|
||||
export function useTriliumEvents<T extends EventNames>(eventNames: T[], handler: (data: EventData<T>, eventName: T) => void) {
|
||||
const parentComponent = useContext(ParentComponent);
|
||||
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const handlers: ({ eventName: T, callback: (data: EventData<T>) => void })[] = [];
|
||||
for (const eventName of eventNames) {
|
||||
@ -35,11 +39,11 @@ export function useTriliumEvents<T extends EventNames>(eventNames: T[], handler:
|
||||
handler(data, eventName);
|
||||
}})
|
||||
}
|
||||
|
||||
|
||||
for (const { eventName, callback } of handlers) {
|
||||
parentComponent?.registerHandler(eventName, callback);
|
||||
}
|
||||
|
||||
|
||||
return (() => {
|
||||
for (const { eventName, callback } of handlers) {
|
||||
parentComponent?.removeHandler(eventName, callback);
|
||||
@ -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);
|
||||
@ -76,10 +75,10 @@ export function useSpacedUpdate(callback: () => void | Promise<void>, interval =
|
||||
|
||||
/**
|
||||
* Allows a React component to read and write a Trilium option, while also watching for external changes.
|
||||
*
|
||||
*
|
||||
* Conceptually, `useTriliumOption` works just like `useState`, but the value is also automatically updated if
|
||||
* the option is changed somewhere else in the client.
|
||||
*
|
||||
*
|
||||
* @param name the name of the option to listen for.
|
||||
* @param needsRefresh whether to reload the frontend whenever the value is changed.
|
||||
* @returns an array where the first value is the current option value and the second value is the setter.
|
||||
@ -115,7 +114,7 @@ export function useTriliumOption(name: OptionNames, needsRefresh?: boolean): [st
|
||||
|
||||
/**
|
||||
* Similar to {@link useTriliumOption}, but the value is converted to and from a boolean instead of a string.
|
||||
*
|
||||
*
|
||||
* @param name the name of the option to listen for.
|
||||
* @param needsRefresh whether to reload the frontend whenever the value is changed.
|
||||
* @returns an array where the first value is the current option value and the second value is the setter.
|
||||
@ -131,7 +130,7 @@ export function useTriliumOptionBool(name: OptionNames, needsRefresh?: boolean):
|
||||
|
||||
/**
|
||||
* Similar to {@link useTriliumOption}, but the value is converted to and from a int instead of a string.
|
||||
*
|
||||
*
|
||||
* @param name the name of the option to listen for.
|
||||
* @param needsRefresh whether to reload the frontend whenever the value is changed.
|
||||
* @returns an array where the first value is the current option value and the second value is the setter.
|
||||
@ -147,7 +146,7 @@ export function useTriliumOptionInt(name: OptionNames): [number, (newValue: numb
|
||||
|
||||
/**
|
||||
* Similar to {@link useTriliumOption}, but the object value is parsed to and from a JSON instead of a string.
|
||||
*
|
||||
*
|
||||
* @param name the name of the option to listen for.
|
||||
* @returns an array where the first value is the current option value and the second value is the setter.
|
||||
*/
|
||||
@ -161,8 +160,8 @@ export function useTriliumOptionJson<T>(name: OptionNames): [ T, (newValue: T) =
|
||||
}
|
||||
|
||||
/**
|
||||
* Similar to {@link useTriliumOption}, but operates with multiple options at once.
|
||||
*
|
||||
* Similar to {@link useTriliumOption}, but operates with multiple options at once.
|
||||
*
|
||||
* @param names the name of the option to listen for.
|
||||
* @returns an array where the first value is a map where the keys are the option names and the values, and the second value is the setter which takes in the same type of map and saves them all at once.
|
||||
*/
|
||||
@ -182,10 +181,10 @@ export function useTriliumOptions<T extends OptionNames>(...names: T[]) {
|
||||
|
||||
/**
|
||||
* Generates a unique name via a random alphanumeric string of a fixed length.
|
||||
*
|
||||
*
|
||||
* <p>
|
||||
* Generally used to assign names to inputs that are unique, especially useful for widgets inside tabs.
|
||||
*
|
||||
*
|
||||
* @param prefix a prefix to add to the unique name.
|
||||
* @returns a name with the given prefix and a random alpanumeric string appended to it.
|
||||
*/
|
||||
@ -196,7 +195,7 @@ export function useUniqueName(prefix?: string) {
|
||||
export function useNoteContext() {
|
||||
const [ noteContext, setNoteContext ] = useState<NoteContext>();
|
||||
const [ notePath, setNotePath ] = useState<string | null | undefined>();
|
||||
const [ note, setNote ] = useState<FNote | null | undefined>();
|
||||
const [ note, setNote ] = useState<FNote | null | undefined>();
|
||||
const [ refreshCounter, setRefreshCounter ] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
@ -205,7 +204,7 @@ export function useNoteContext() {
|
||||
|
||||
useTriliumEvents([ "setNoteContext", "activeContextChanged", "noteSwitchedAndActivated", "noteSwitched" ], ({ noteContext }) => {
|
||||
setNoteContext(noteContext);
|
||||
setNotePath(noteContext.notePath);
|
||||
setNotePath(noteContext.notePath);
|
||||
});
|
||||
useTriliumEvent("frocaReloaded", () => {
|
||||
setNote(noteContext?.note);
|
||||
@ -235,7 +234,7 @@ export function useNoteContext() {
|
||||
|
||||
/**
|
||||
* Allows a React component to listen to obtain a property of a {@link FNote} while also automatically watching for changes, either via the user changing to a different note or the property being changed externally.
|
||||
*
|
||||
*
|
||||
* @param note the {@link FNote} whose property to obtain.
|
||||
* @param property a property of a {@link FNote} to obtain the value from (e.g. `title`, `isProtected`).
|
||||
* @param componentId optionally, constricts the refresh of the value if an update occurs externally via the component ID of a legacy widget. This can be used to avoid external data replacing fresher, user-inputted data.
|
||||
@ -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 ]);
|
||||
@ -287,19 +286,23 @@ export function useNoteRelation(note: FNote | undefined | null, relationName: st
|
||||
|
||||
/**
|
||||
* Allows a React component to read or write a note's label while also reacting to changes in value.
|
||||
*
|
||||
*
|
||||
* @param note the note whose label to read/write.
|
||||
* @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,23 +358,42 @@ 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() {
|
||||
note?.getBlob().then(setBlob);
|
||||
note?.getBlob().then(setBlob);
|
||||
}
|
||||
|
||||
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 }: {
|
||||
@ -388,7 +415,7 @@ export function useLegacyWidget<T extends BasicWidget>(widgetFactory: () => T, {
|
||||
if (noteContext && widget instanceof NoteContextAwareWidget) {
|
||||
widget.setNoteContextEvent({ noteContext });
|
||||
}
|
||||
|
||||
|
||||
const renderedWidget = widget.render();
|
||||
return [ widget, renderedWidget ];
|
||||
}, []);
|
||||
@ -415,7 +442,7 @@ export function useLegacyWidget<T extends BasicWidget>(widgetFactory: () => T, {
|
||||
|
||||
/**
|
||||
* Attaches a {@link ResizeObserver} to the given ref and reads the bounding client rect whenever it changes.
|
||||
*
|
||||
*
|
||||
* @param ref a ref to a {@link HTMLElement} to determine the size and observe the changes in size.
|
||||
* @returns the size of the element, reacting to changes.
|
||||
*/
|
||||
@ -445,7 +472,7 @@ export function useElementSize(ref: RefObject<HTMLElement>) {
|
||||
|
||||
/**
|
||||
* Obtains the inner width and height of the window, as well as reacts to changes in size.
|
||||
*
|
||||
*
|
||||
* @returns the width and height of the window.
|
||||
*/
|
||||
export function useWindowSize() {
|
||||
@ -453,7 +480,7 @@ export function useWindowSize() {
|
||||
windowWidth: window.innerWidth,
|
||||
windowHeight: window.innerHeight
|
||||
});
|
||||
|
||||
|
||||
useEffect(() => {
|
||||
function onResize() {
|
||||
setSize({
|
||||
@ -499,7 +526,7 @@ export function useTooltip(elRef: RefObject<HTMLElement>, config: Partial<Toolti
|
||||
|
||||
/**
|
||||
* Similar to {@link useTooltip}, but doesn't expose methods to imperatively hide or show the tooltip.
|
||||
*
|
||||
*
|
||||
* @param elRef the element to bind the tooltip to.
|
||||
* @param config optionally, the tooltip configuration.
|
||||
*/
|
||||
@ -547,4 +574,122 @@ export function useSyncedRef<T>(externalRef?: RefObject<T>, initialValue: T | nu
|
||||
}, [ ref, externalRef ]);
|
||||
|
||||
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}
|
||||
|
@ -23,7 +23,7 @@ import { ContentLanguagesList } from "../type_widgets/options/i18n";
|
||||
|
||||
export default function BasicPropertiesTab({ note }: TabContext) {
|
||||
return (
|
||||
<div className="basic-properties-widget">
|
||||
<div className="basic-properties-widget">
|
||||
<NoteTypeWidget note={note} />
|
||||
<ProtectedNoteSwitch note={note} />
|
||||
<EditabilitySelect note={note} />
|
||||
@ -43,7 +43,7 @@ function NoteTypeWidget({ note }: { note?: FNote | null }) {
|
||||
return mime_types.getMimeTypes().filter(mimeType => mimeType.enabled)
|
||||
}, [ codeNotesMimeTypes ]);
|
||||
const notSelectableNoteTypes = useMemo(() => NOTE_TYPES.filter((nt) => nt.reserved || nt.static).map((nt) => nt.type), []);
|
||||
|
||||
|
||||
const currentNoteType = useNoteProperty(note, "type") ?? undefined;
|
||||
const currentNoteMime = useNoteProperty(note, "mime");
|
||||
const [ modalShown, setModalShown ] = useState(false);
|
||||
@ -95,7 +95,7 @@ function NoteTypeWidget({ note }: { note?: FNote | null }) {
|
||||
checked={checked}
|
||||
badges={badges}
|
||||
onClick={() => changeNoteType(type, mime)}
|
||||
>{title}</FormListItem>
|
||||
>{title}</FormListItem>
|
||||
);
|
||||
} else {
|
||||
return (
|
||||
@ -103,7 +103,7 @@ function NoteTypeWidget({ note }: { note?: FNote | null }) {
|
||||
<FormDropdownDivider />
|
||||
<FormListItem
|
||||
checked={checked}
|
||||
disabled
|
||||
disabled
|
||||
>
|
||||
<strong>{title}</strong>
|
||||
</FormListItem>
|
||||
@ -131,7 +131,7 @@ function NoteTypeWidget({ note }: { note?: FNote | null }) {
|
||||
<CodeMimeTypesList />
|
||||
</Modal>
|
||||
</div>
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
function ProtectedNoteSwitch({ note }: { note?: FNote | null }) {
|
||||
@ -151,7 +151,7 @@ function ProtectedNoteSwitch({ note }: { note?: FNote | null }) {
|
||||
|
||||
function EditabilitySelect({ note }: { note?: FNote | null }) {
|
||||
const [ readOnly, setReadOnly ] = useNoteLabelBoolean(note, "readOnly");
|
||||
const [ autoReadOnlyDisabled, setAutoReadOnlyDisabled ] = useNoteLabelBoolean(note, "autoReadOnlyDisabled");
|
||||
const [ autoReadOnlyDisabled, setAutoReadOnlyDisabled ] = useNoteLabelBoolean(note, "autoReadOnlyDisabled");
|
||||
|
||||
const options = useMemo(() => ([
|
||||
{
|
||||
@ -208,7 +208,7 @@ function BookmarkSwitch({ note }: { note?: FNote | null }) {
|
||||
<FormToggle
|
||||
switchOnName={t("bookmark_switch.bookmark")} switchOnTooltip={t("bookmark_switch.bookmark_this_note")}
|
||||
switchOffName={t("bookmark_switch.bookmark")} switchOffTooltip={t("bookmark_switch.remove_bookmark")}
|
||||
currentValue={isBookmarked}
|
||||
currentValue={isBookmarked}
|
||||
onChange={async (shouldBookmark) => {
|
||||
if (!note) return;
|
||||
const resp = await server.put<ToggleInParentResponse>(`notes/${note.noteId}/toggle-in-parent/_lbBookmarks/${shouldBookmark}`);
|
||||
@ -260,11 +260,11 @@ function SharedSwitch({ note }: { note?: FNote | null }) {
|
||||
} else {
|
||||
if (note?.getParentBranches().length === 1 && !(await dialog.confirm(t("shared_switch.shared-branch")))) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const shareBranch = note?.getParentBranches().find((b) => b.parentNoteId === "_share");
|
||||
if (!shareBranch?.branchId) return;
|
||||
await server.remove(`branches/${shareBranch.branchId}?taskId=no-progress-reporting`);
|
||||
await server.remove(`branches/${shareBranch.branchId}?taskId=no-progress-reporting`);
|
||||
}
|
||||
|
||||
sync.syncNow(true);
|
||||
@ -330,7 +330,7 @@ function NoteLanguageSwitch({ note }: { note?: FNote | null }) {
|
||||
return locales.find(locale => typeof locale === "object" && locale.id === currentNoteLanguage) as Locale | undefined;
|
||||
}, [ currentNoteLanguage ]);
|
||||
|
||||
return (
|
||||
return (
|
||||
<div className="note-language-container">
|
||||
<span>{t("basic_properties.language")}:</span>
|
||||
|
||||
@ -350,7 +350,7 @@ function NoteLanguageSwitch({ note }: { note?: FNote | null }) {
|
||||
|
||||
<FormListItem
|
||||
onClick={() => setModalShown(true)}
|
||||
>{t("note_language.configure-languages")}</FormListItem>
|
||||
>{t("note_language.configure-languages")}</FormListItem>
|
||||
</Dropdown>
|
||||
|
||||
<HelpButton helpPage="B0lcI9xz1r8K" style={{ marginLeft: "4px" }} />
|
||||
@ -378,4 +378,4 @@ function findTypeTitle(type?: NoteType, mime?: string | null) {
|
||||
|
||||
return noteType ? noteType.title : type;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -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>
|
||||
)
|
||||
@ -146,4 +157,4 @@ function LabelledEntry({ label, children }: { label: string, children: Component
|
||||
</label>
|
||||
</>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
@ -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">
|
||||
@ -52,7 +52,7 @@ export default function FilePropertiesTab({ note }: { note?: FNote | null }) {
|
||||
<FormFileUploadButton
|
||||
icon="bx bx-folder-open"
|
||||
text={t("file_properties.upload_new_revision")}
|
||||
disabled={!canAccessProtectedNote}
|
||||
disabled={!canAccessProtectedNote}
|
||||
onChange={(fileToUpload) => {
|
||||
if (!fileToUpload) {
|
||||
return;
|
||||
@ -74,4 +74,4 @@ export default function FilePropertiesTab({ note }: { note?: FNote | null }) {
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
@ -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);
|
||||
|
||||
@ -25,12 +25,12 @@ export default function ImagePropertiesTab({ note, ntxId }: TabContext) {
|
||||
<strong>{t("image_properties.original_file_name")}:</strong>{" "}
|
||||
<span>{originalFileName ?? "?"}</span>
|
||||
</span>
|
||||
|
||||
|
||||
<span>
|
||||
<strong>{t("image_properties.file_type")}:</strong>{" "}
|
||||
<span>{note.mime}</span>
|
||||
</span>
|
||||
|
||||
|
||||
<span>
|
||||
<strong>{t("image_properties.file_size")}:</strong>{" "}
|
||||
<span>{formatSize(blob?.contentLength)}</span>
|
||||
@ -48,7 +48,7 @@ export default function ImagePropertiesTab({ note, ntxId }: TabContext) {
|
||||
<Button
|
||||
text={t("image_properties.open")}
|
||||
icon="bx bx-link-external"
|
||||
onClick={() => openNoteExternally(note.noteId, note.mime)}
|
||||
onClick={() => openNoteExternally(note.noteId, note.mime)}
|
||||
/>
|
||||
|
||||
<Button
|
||||
@ -79,4 +79,4 @@ export default function ImagePropertiesTab({ note, ntxId }: TabContext) {
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
@ -17,4 +17,4 @@ export default function NotePropertiesTab({ note }: TabContext) {
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
@ -25,4 +25,4 @@ export default function ScriptTab({ note }: TabContext) {
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
@ -57,7 +57,7 @@ export const SEARCH_OPTIONS: SearchOption[] = [
|
||||
defaultValue: "root",
|
||||
icon: "bx bx-filter-alt",
|
||||
label: t("search_definition.ancestor"),
|
||||
component: AncestorOption,
|
||||
component: AncestorOption,
|
||||
additionalAttributesToDelete: [ { type: "label", name: "ancestorDepth" } ]
|
||||
},
|
||||
{
|
||||
@ -173,7 +173,7 @@ function SearchStringOption({ note, refreshResults, error, ...restProps }: Searc
|
||||
}
|
||||
}, [ error ]);
|
||||
|
||||
return <SearchOption
|
||||
return <SearchOption
|
||||
title={t("search_string.title_column")}
|
||||
help={<>
|
||||
<strong>{t("search_string.search_syntax")}</strong> - {t("search_string.also_see")} <a href="#" data-help-page="search.html">{t("search_string.complete_help")}</a>
|
||||
@ -243,7 +243,7 @@ function AncestorOption({ note, ...restProps}: SearchOptionProps) {
|
||||
const options: { value: string | undefined; label: string }[] = [
|
||||
{ value: "", label: t("ancestor.depth_doesnt_matter") },
|
||||
{ value: "eq1", label: `${t("ancestor.depth_eq", { count: 1 })} (${t("ancestor.direct_children")})` }
|
||||
];
|
||||
];
|
||||
|
||||
for (let i=2; i<=9; i++) options.push({ value: "eq" + i, label: t("ancestor.depth_eq", { count: i }) });
|
||||
for (let i=0; i<=9; i++) options.push({ value: "gt" + i, label: t("ancestor.depth_gt", { count: i }) });
|
||||
@ -253,7 +253,7 @@ function AncestorOption({ note, ...restProps}: SearchOptionProps) {
|
||||
}, []);
|
||||
|
||||
return <SearchOption
|
||||
title={t("ancestor.label")}
|
||||
title={t("ancestor.label")}
|
||||
note={note} {...restProps}
|
||||
>
|
||||
<div style={{display: "flex", alignItems: "center"}}>
|
||||
@ -357,4 +357,4 @@ function LimitOption({ note, defaultValue, ...restProps }: SearchOptionProps) {
|
||||
currentValue={limit ?? defaultValue} onChange={setLimit}
|
||||
/>
|
||||
</SearchOption>
|
||||
}
|
||||
}
|
||||
|
@ -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.
|
||||
*/
|
||||
|
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