Merge dde5db14428fa3b7de9e6ed27a0ac7ad2e129329 into 17f3ffd00cb120ebe5d634e8dad97f85fc391144

This commit is contained in:
Lucas 2026-01-22 20:59:08 +02:00 committed by GitHub
commit b488a37c08
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
13 changed files with 1124 additions and 96 deletions

View File

@ -1,4 +1,5 @@
import { NoteType } from "@triliumnext/commons";
import FNote from "../entities/fnote";
import { ViewTypeOptions } from "../widgets/collections/interface";
@ -24,6 +25,7 @@ export const byNoteType: Record<Exclude<NoteType, "book">, string | null> = {
export const byBookType: Record<ViewTypeOptions, string | null> = {
list: "mULW0Q3VojwY",
grid: "8QqnMzx393bx",
gallery: null,
calendar: "xWbu3jpNWapp",
table: "2FvYrpmOXm29",
geoMap: "81SGnPGMk7Xc",
@ -39,6 +41,6 @@ export function getHelpUrlForNote(note: FNote | null | undefined) {
} else if (note?.hasLabel("textSnippet")) {
return "pwc194wlRzcH";
} else if (note && note.type === "book") {
return byBookType[note.getAttributeValue("label", "viewType") as ViewTypeOptions ?? ""]
return byBookType[note.getAttributeValue("label", "viewType") as ViewTypeOptions ?? ""];
}
}

View File

@ -801,7 +801,8 @@
"board": "Board",
"presentation": "Presentation",
"include_archived_notes": "Show archived notes",
"hide_child_notes": "Hide child notes in tree"
"hide_child_notes": "Hide child notes in tree",
"gallery": "Gallery"
},
"edited_notes": {
"no_edited_notes_found": "No edited notes on this day yet...",
@ -2267,5 +2268,27 @@
"pages_other": "{{count}} pages",
"pages_alt": "Page {{pageNumber}}",
"pages_loading": "Loading..."
},
"gallery": {
"gallery_created": "Gallery Created",
"new_gallery": "Create New Gallery",
"select_all": "Select All",
"selected": "selected",
"clear_selection": "Clear Selection",
"delete_selected": "Delete Selected",
"delete_multiple": "Delete {{count}} items",
"items_selected": "{{count}} item selected",
"items_selected_other": "{{count}} items selected",
"enter_gallery_name": "Enter new gallery name",
"gallery_creation_failed": "Failed to create new gallery.",
"default_gallery_name": "New Gallery",
"no_image_files": "No image files found.",
"back_to_parent": "Back To Parent",
"image_count": "{{count}} image",
"image_count_other": "{{count}} images",
"gallery_count": "{{count}} gallery",
"gallery_count_other": "{{count}} galleries",
"loading_more": "Loading more images... ({{loaded}} of {{total}})",
"empty_gallery": "This gallery is empty. Upload images to make them appear."
}
}

View File

@ -36,6 +36,9 @@ const ViewComponents: Record<ViewTypeOptions, { normal: LazyLoadedComponent, pri
grid: {
normal: lazy(() => import("./legacy/ListOrGridView.js").then(i => i.GridView)),
},
gallery: {
normal: lazy(() => import("./gallery/index.js")),
},
geoMap: {
normal: lazy(() => import("./geomap/index.js")),
},
@ -148,13 +151,12 @@ export function useNoteViewType(note?: FNote | null): ViewTypeOptions | undefine
return note.type === "search" ? "list" : "grid";
}
return viewType as ViewTypeOptions;
}
export function useNoteIds(note: FNote | null | undefined, viewType: ViewTypeOptions | undefined, ntxId: string | null | undefined) {
const [ noteIds, setNoteIds ] = useState<string[]>([]);
const [ includeArchived ] = useNoteLabelBoolean(note, "includeArchived");
const directChildrenOnly = (viewType === "list" || viewType === "grid" || viewType === "table" || note?.type === "search");
const directChildrenOnly = (viewType === "list" || viewType === "grid" || viewType === "gallery" || viewType === "table" || note?.type === "search");
async function refreshNoteIds() {
if (!note) {
@ -169,7 +171,6 @@ export function useNoteIds(note: FNote | null | undefined, viewType: ViewTypeOpt
return await note.getChildNoteIdsWithArchiveFiltering(includeArchived);
}
return await note.getSubtreeNoteIds(includeArchived);
}
// Refresh on note switch.

View File

@ -0,0 +1,291 @@
.gallery-view {
height: 100%;
overflow: hidden;
display: flex;
flex-direction: column;
}
.gallery-toolbar {
display: flex;
align-items: center;
justify-content: space-between;
padding: 8px 12px;
background-color: var(--accented-background-color);
border-bottom: 1px solid var(--main-border-color);
gap: 12px;
flex-shrink: 0;
}
.gallery-toolbar-left,
.gallery-toolbar-right {
display: flex;
align-items: center;
gap: 8px;
}
.gallery-toolbar-status {
font-size: 14px;
color: var(--muted-text-color);
}
.gallery-toolbar-selection-count {
font-size: 14px;
font-weight: 500;
color: var(--main-text-color);
}
.gallery-view .note-list-wrapper {
height: 100%;
overflow: auto;
flex: 1;
}
.gallery-selection-bar {
display: flex;
align-items: center;
justify-content: space-between;
padding: 8px 12px;
background-color: var(--more-accented-background-color);
border-bottom: 1px solid var(--main-border-color);
font-size: 14px;
}
.gallery-container {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
gap: 12px;
padding: 12px;
}
.gallery-card {
position: relative;
cursor: pointer;
border-radius: 6px;
overflow: hidden;
background-color: var(--accented-background-color);
transition: transform 0.2s, box-shadow 0.2s;
display: flex;
flex-direction: column;
}
.gallery-card:hover {
transform: translateY(-2px);
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
}
.gallery-card.archived {
opacity: 0.5;
}
.gallery-card-selected {
box-shadow: 0 0 0 3px var(--main-text-color);
}
.gallery-card-selection-indicator {
position: absolute;
top: 8px;
right: 8px;
z-index: 10;
background-color: var(--main-text-color);
border-radius: 50%;
width: 24px;
height: 24px;
display: flex;
align-items: center;
justify-content: center;
}
.gallery-card-selection-indicator i {
color: var(--accented-background-color);
font-size: 20px;
}
.gallery-image-container {
width: 100%;
aspect-ratio: 1;
display: flex;
align-items: center;
justify-content: center;
background-color: var(--main-background-color);
overflow: hidden;
position: relative;
}
.gallery-image-container img {
width: 100%;
height: 100%;
object-fit: cover;
}
.gallery-title {
position: absolute;
bottom: 0;
left: 0;
right: 0;
padding: 10px 8px;
text-align: center;
font-weight: 500;
font-size: 12px;
color: white;
background: linear-gradient(to top, rgba(0, 0, 0, 0.8), rgba(0, 0, 0, 0.6));
word-break: break-word;
line-height: 1.4;
text-overflow: ellipsis;
overflow: hidden;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
opacity: 0;
transition: opacity 0.2s;
z-index: 5;
}
.gallery-card:hover .gallery-title,
.gallery-card-selected .gallery-title {
opacity: 1;
}
/* Folder-specific styles */
.gallery-folder-icon {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
background-color: var(--accented-background-color);
}
.gallery-folder-icon i {
font-size: 48px;
color: var(--main-text-color);
opacity: 0.6;
margin-bottom: 8px;
}
.gallery-card.gallery-folder:hover .gallery-folder-icon i {
opacity: 0.8;
}
/* Always show folder names */
.gallery-folder-icon .gallery-title {
opacity: 1;
position: static;
color: var(--main-text-color);
background: none;
padding: 0;
}
.gallery-view .gallery-toolbar .btn-delete.btn.btn-primary {
background-color: var(--dropdown-item-icon-destructive-color);
border-color: var(--dropdown-item-icon-destructive-color);
color: white;
}
.gallery-view .gallery-toolbar .btn-delete.btn.btn-primary:hover:not(.disabled) {
background-color: color-mix(in srgb, var(--dropdown-item-icon-destructive-color) 80%, black);
border-color: color-mix(in srgb, var(--dropdown-item-icon-destructive-color) 70%, black);
}
.gallery-view.gallery-drag-over {
position: relative;
}
.gallery-drop-overlay {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(var(--main-background-color-rgb), 0.95);
border: 3px dashed var(--main-border-color);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
pointer-events: none;
backdrop-filter: blur(4px);
}
.gallery-drop-message {
display: flex;
flex-direction: column;
align-items: center;
gap: 1rem;
font-size: 1.5rem;
color: var(--muted-text-color);
}
.gallery-drop-message i {
font-size: 4rem;
}
.gallery-item-count {
font-size: 0.85em;
opacity: 0.7;
}
.gallery-type-badge {
position: absolute;
top: 8px;
left: 8px;
background-color: rgba(0, 0, 0, 0.6);
border-radius: 4px;
padding: 4px 6px;
display: flex;
align-items: center;
justify-content: center;
}
.gallery-type-badge i {
font-size: 14px;
color: white;
}
.gallery-share-badge {
position: absolute;
top: 8px;
right: 8px;
background-color: rgba(0, 0, 0, 0.6);
border: none;
border-radius: 4px;
padding: 4px 6px;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
transition: background-color 0.2s ease;
}
.gallery-share-badge:hover {
background-color: rgba(0, 0, 0, 0.8);
}
.gallery-share-badge i {
font-size: 14px;
color: #4caf50;
}
.gallery-load-more {
height: 20px;
margin: 20px 0;
}
.gallery-load-more-text {
text-align: center;
color: var(--muted-text-color);
}
.gallery-empty-help {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
text-align: center;
padding: 48px 24px;
margin: 24px;
border-radius: 8px;
background-color: var(--accented-background-color);
border: 2px dashed var(--main-border-color);
color: var(--muted-text-color);
font-size: 14px;
}

View File

@ -0,0 +1,677 @@
import "./index.css";
import { ToggleInParentResponse } from "@triliumnext/commons";
import { useCallback, useEffect, useMemo, useRef, useState } from "preact/hooks";
import appContext from "../../../components/app_context";
import type FNote from "../../../entities/fnote";
import contextMenu from "../../../menus/context_menu";
import linkContextMenu from "../../../menus/link_context_menu";
import branches from "../../../services/branches";
import { copyTextWithToast } from "../../../services/clipboard_ext";
import froca from "../../../services/froca";
import { t } from "../../../services/i18n";
import link from "../../../services/link";
import noteCreateService from "../../../services/note_create";
import options from "../../../services/options";
import server from "../../../services/server";
import toast from "../../../services/toast";
import tree from "../../../services/tree";
import ActionButton from "../../react/ActionButton";
import Alert from "../../react/Alert";
import { FormFileUploadActionButton } from "../../react/FormFileUpload";
import { useTriliumEvent } from "../../react/hooks";
import type { ViewModeProps } from "../interface";
import { useFilteredNoteIds } from "../legacy/utils";
const INITIAL_LOAD = 50;
const LOAD_MORE_INCREMENT = 50;
const UPLOAD_PROGRESS_TOAST_ID = "gallery-upload-progress";
const VISUAL_NOTE_TYPES = ['image', 'canvas', 'mermaid', 'mindMap'] as const;
function isVisualType(type: string): boolean {
return (VISUAL_NOTE_TYPES as readonly string[]).includes(type);
}
function isGalleryNote(note: FNote): boolean {
return note.hasLabel('collection') && note.getLabelValue('viewType') === 'gallery';
}
function getImageSrc(note: FNote): string | undefined {
switch (note.type) {
case 'image':
return `api/images/${note.noteId}/${encodeURIComponent(note.title)}`;
case 'canvas':
return `api/images/${note.noteId}/canvas.png`;
case 'mermaid':
return `api/images/${note.noteId}/mermaid.svg`;
case 'mindMap':
return `api/images/${note.noteId}/mindmap.svg`;
default:
return undefined;
}
}
function getShareUrl(note: FNote): string {
const shareId = note.hasOwnedLabel("shareRoot")
? ""
: note.getOwnedLabelValue("shareAlias") || note.noteId;
const syncServerHost = options.get("syncServerHost");
if (syncServerHost) {
return new URL(`/share/${shareId}`, syncServerHost).href;
}
let host = location.host;
if (host.endsWith("/")) {
host = host.substring(0, host.length - 1);
}
return `${location.protocol}//${host}${location.pathname}share/${shareId}`;
}
export default function GalleryView({ note, noteIds: unfilteredNoteIds }: ViewModeProps<{}>) {
const noteIds = useFilteredNoteIds(note, unfilteredNoteIds);
const [selectedNoteIds, setSelectedNoteIds] = useState<Set<string>>(new Set());
const [lastSelectedNoteId, setLastSelectedNoteId] = useState<string | null>(null);
const [isUploading, setIsUploading] = useState(false);
const [displayCount, setDisplayCount] = useState(INITIAL_LOAD);
const loadMoreRef = useRef<HTMLDivElement>(null);
// Track how many notes actually exist in froca to trigger recalculation when new notes are loaded
const loadedNoteCount = noteIds?.filter(id => froca.notes[id]).length ?? 0;
// Memoize the filtered and sorted notes to avoid recalculating on every render
const { sortedNotes, imageCount, galleryCount } = useMemo(() => {
const allNotes = noteIds?.map(noteId => froca.notes[noteId]).filter(Boolean) || [];
const notes = allNotes
.filter(childNote => isGalleryNote(childNote) || isVisualType(childNote.type))
.sort((a, b) => {
const aIsGallery = isGalleryNote(a);
const bIsGallery = isGalleryNote(b);
if (aIsGallery && !bIsGallery) return -1;
if (!aIsGallery && bIsGallery) return 1;
return 0;
});
const imgCount = notes.filter(note => isVisualType(note.type)).length;
const galCount = notes.filter(note => isGalleryNote(note)).length;
return { sortedNotes: notes, imageCount: imgCount, galleryCount: galCount };
}, [noteIds, loadedNoteCount]);
// Only display a subset of notes for virtual scrolling
const displayedNotes = sortedNotes.slice(0, displayCount);
const hasMore = displayCount < sortedNotes.length;
// Infinite scroll with IntersectionObserver
useEffect(() => {
if (!hasMore || !loadMoreRef.current) return;
const observer = new IntersectionObserver(
(entries) => {
if (entries[0].isIntersecting) {
setDisplayCount(prev => Math.min(prev + LOAD_MORE_INCREMENT, sortedNotes.length));
}
},
{
rootMargin: '300px', // Load before user reaches the bottom
threshold: 0.1
}
);
observer.observe(loadMoreRef.current);
return () => observer.disconnect();
}, [hasMore, sortedNotes.length]);
// Reset display count when note changes
useEffect(() => {
setDisplayCount(INITIAL_LOAD);
}, [note.noteId]);
const toggleSelection = (noteId: string, isCtrlKey: boolean, isShiftKey: boolean) => {
setSelectedNoteIds(prev => {
const newSet = new Set(prev);
if (isShiftKey && lastSelectedNoteId && sortedNotes) {
const lastIndex = sortedNotes.findIndex(n => n.noteId === lastSelectedNoteId);
const currentIndex = sortedNotes.findIndex(n => n.noteId === noteId);
if (lastIndex !== -1 && currentIndex !== -1) {
const start = Math.min(lastIndex, currentIndex);
const end = Math.max(lastIndex, currentIndex);
for (let i = start; i <= end; i++) {
newSet.add(sortedNotes[i].noteId);
}
}
} else if (isCtrlKey) {
if (newSet.has(noteId)) {
newSet.delete(noteId);
} else {
newSet.add(noteId);
}
} else {
newSet.clear();
newSet.add(noteId);
}
return newSet;
});
if (!isCtrlKey || !selectedNoteIds.has(noteId)) {
setLastSelectedNoteId(noteId);
}
};
const handleSelectAll = () => {
if (sortedNotes && sortedNotes.length > 0) {
setSelectedNoteIds(new Set(sortedNotes.map(n => n.noteId)));
setLastSelectedNoteId(sortedNotes[sortedNotes.length - 1].noteId);
}
};
const clearSelection = () => {
setSelectedNoteIds(new Set());
setLastSelectedNoteId(null);
};
const handleUpload = async (files: FileList | null) => {
if (!files || files.length === 0 || !note) return;
setIsUploading(true);
const totalFiles = files.length;
let successCount = 0;
try {
for (let i = 0; i < totalFiles; i++) {
const file = files[i];
try {
toast.showPersistent({
id: UPLOAD_PROGRESS_TOAST_ID,
icon: "bx bx-upload",
message: t("import.in-progress", { progress: `${i + 1}/${totalFiles}` }),
progress: ((i + 1) / totalFiles) * 100
});
const result = await noteCreateService.createNote(note.noteId, {
title: file.name,
type: 'image',
mime: file.type || 'image/png',
content: '',
activate: false
});
if (!result?.note) {
toast.showError(t("import.failed", { message: `Failed to create note for ${file.name}` }));
continue;
}
const uploadResult = await server.upload(`images/${result.note.noteId}`, file);
if (uploadResult.uploaded) {
successCount++;
} else {
toast.showError(t("import.failed", { message: uploadResult.message || "Unknown error" }));
}
} catch (error) {
console.error(`Failed to upload ${file.name}:`, error);
toast.showError(t("import.failed", { message: file.name }));
}
}
toast.closePersistent(UPLOAD_PROGRESS_TOAST_ID);
if (successCount > 0) {
toast.showMessage(t("import.successful"));
}
} finally {
setIsUploading(false);
}
};
const handleCreateGallery = async () => {
const newTitle = await new Promise<string | null>((resolve) => {
appContext.triggerCommand("showPromptDialog", {
title: t("gallery.new_gallery"),
message: t("gallery.enter_gallery_name"),
defaultValue: t("gallery.default_gallery_name"),
callback: resolve
});
});
if (!newTitle) return;
try {
await noteCreateService.createNote(note.noteId, {
title: newTitle,
type: 'book',
content: '',
templateNoteId: '_template_gallery',
activate: false
});
toast.showMessage(t("gallery.gallery_created"));
} catch (error) {
console.error('Failed to create gallery:', error);
toast.showError(t("gallery.gallery_creation_failed"));
}
};
const deleteNotes = async (noteIdsToDelete: string[]) => {
if (noteIdsToDelete.length === 0) return;
const branchIds = noteIdsToDelete
.map(noteId =>
froca.notes[noteId]
?.getParentBranches()
.find(b => b.parentNoteId === note.noteId)
?.branchId
)
.filter((branchId): branchId is string => !!branchId);
if (branchIds.length > 0) {
await branches.deleteNotes(branchIds, false, false);
clearSelection();
}
};
const handleDeleteSelected = async () => {
if (selectedNoteIds.size === 0) return;
await deleteNotes(Array.from(selectedNoteIds));
};
const isEmpty = sortedNotes.length === 0;
return (
<div class="note-list gallery-view">
<GalleryToolbar
selectedCount={selectedNoteIds.size}
totalCount={sortedNotes?.length ?? 0}
imageCount={imageCount}
galleryCount={galleryCount}
isUploading={isUploading}
currentNote={note}
onUpload={handleUpload}
onCreateGallery={handleCreateGallery}
onSelectAll={handleSelectAll}
onClearSelection={clearSelection}
onDeleteSelected={handleDeleteSelected}
/>
{isEmpty && (
<Alert type="info" className="gallery-empty-help">
{t("gallery.empty_gallery")}
</Alert>
)}
<div class="note-list-wrapper">
<div class="gallery-container">
{displayedNotes.map(childNote => (
<GalleryCard
key={childNote.noteId}
note={childNote}
parentNote={note}
isSelected={selectedNoteIds.has(childNote.noteId)}
selectedNoteIds={selectedNoteIds}
toggleSelection={toggleSelection}
deleteNotes={deleteNotes}
/>
))}
</div>
{/* Infinite scroll trigger */}
{hasMore && (
<div
ref={loadMoreRef}
className="gallery-load-more"
>
<div className="gallery-load-more-text">
{t('gallery.loading_more', { loaded: displayedNotes.length, total: sortedNotes.length })}
</div>
</div>
)}
</div>
</div>
);
}
interface GalleryToolbarProps {
selectedCount: number;
totalCount: number;
imageCount: number;
galleryCount: number;
isUploading: boolean;
onUpload: (files: FileList | null) => void;
onCreateGallery: () => void;
onSelectAll: () => void;
onClearSelection: () => void;
onDeleteSelected: () => void;
}
function GalleryToolbar({
selectedCount,
totalCount,
imageCount,
galleryCount,
isUploading,
onUpload,
onCreateGallery,
onSelectAll,
onClearSelection,
onDeleteSelected,
currentNote
}: GalleryToolbarProps & { currentNote: FNote }) {
// Check if current note is a gallery with parents
const hasParentGallery = currentNote && currentNote.getParentNotes().some(parent => isGalleryNote(parent));
const handleGoBack = () => {
if (currentNote) {
const parentGallery = currentNote.getParentNotes().find(parent => isGalleryNote(parent));
if (parentGallery) {
appContext.tabManager.getActiveContext()?.setNote(parentGallery.noteId);
}
}
};
const handleCreateGalleryClick = () => {
if (selectedCount > 0) {
onClearSelection();
}
onCreateGallery();
};
const handleUploadChange = (files: FileList | null) => {
if (selectedCount > 0) {
onClearSelection();
}
onUpload(files);
};
return (
<div className="gallery-toolbar">
<div className="gallery-toolbar-left">
{hasParentGallery && (
<ActionButton
icon="bx bx-arrow-back"
text={t("gallery.back_to_parent")}
frame
onClick={handleGoBack}
/>
)}
<ActionButton
icon="bx bx-folder-plus"
text={t("gallery.new_gallery")}
frame
onClick={handleCreateGalleryClick}
/>
<FormFileUploadActionButton
icon="bx bx-upload"
text={t("upload_attachments.upload")}
frame
disabled={isUploading}
onChange={handleUploadChange}
multiple
accept="image/*"
/>
{galleryCount > 0 && (
<span className="gallery-toolbar-status">
{t("gallery.gallery_count", { count: galleryCount })}
</span>
)}
{imageCount > 0 && (
<span className="gallery-toolbar-status">
{t("gallery.image_count", { count: imageCount })}
</span>
)}
</div>
<div className="gallery-toolbar-right">
{selectedCount === 0 ? (
<ActionButton
icon="bx bx-select-multiple"
text={t("gallery.select_all")}
frame
onClick={onSelectAll}
disabled={totalCount === 0}
/>
) : (
<>
<span className="gallery-toolbar-selection-count">
{t("gallery.items_selected", { count: selectedCount })}
</span>
<ActionButton
icon="bx bx-x"
text={t("gallery.clear_selection")}
frame
onClick={onClearSelection}
/>
<ActionButton
icon="bx bx-trash"
text={t("gallery.delete_selected")}
frame
className="btn-delete"
onClick={onDeleteSelected}
/>
</>
)}
</div>
</div>
);
}
interface GalleryCardProps {
note: FNote;
parentNote: FNote;
isSelected: boolean;
selectedNoteIds: Set<string>;
toggleSelection: (noteId: string, isCtrlKey: boolean, isShiftKey: boolean) => void;
deleteNotes: (noteIdsToDelete: string[]) => Promise<void>;
}
function GalleryCard({ note, parentNote, isSelected, selectedNoteIds, toggleSelection, deleteNotes }: GalleryCardProps) {
const [noteTitle, setNoteTitle] = useState<string>();
const [imageSrc, setImageSrc] = useState<string>();
const [isShared, setIsShared] = useState(() => note.isShared());
const notePath = getNotePath(parentNote, note);
const isGallery = isGalleryNote(note);
// Track loaded children to trigger recalculation when notes are loaded into froca
const loadedChildCount = (note.children || []).filter(id => froca.notes[id]).length;
const childCount = useMemo(() => {
if (!isGallery) {
return 0;
}
const childNoteIds = note.children || [];
return childNoteIds.filter(childId => {
const child = froca.notes[childId];
if (!child) return false;
return isVisualType(child.type) || isGalleryNote(child);
}).length;
}, [isGallery, note.children, loadedChildCount]);
const refreshData = useCallback(() => {
tree.getNoteTitle(note.noteId, parentNote.noteId).then(setNoteTitle);
setImageSrc(getImageSrc(note));
setIsShared(note.isShared());
}, [note, parentNote.noteId]);
useEffect(() => {
refreshData();
}, [refreshData]);
useTriliumEvent("entitiesReloaded", ({ loadResults }) => {
if (loadResults.isNoteReloaded(note.noteId)) {
refreshData();
}
});
const handleRename = async () => {
const newTitle = await new Promise<string | null>((resolve) => {
appContext.triggerCommand("showPromptDialog", {
title: t("rename_note.rename_note"),
message: t("rename_note.rename_note_title_to"),
defaultValue: note.title,
callback: resolve
});
});
if (newTitle && newTitle !== note.title) {
await server.put(`notes/${note.noteId}/title`, { title: newTitle });
setNoteTitle(newTitle);
}
};
const handleToggleShare = async (noteToShare: FNote) => {
const shouldShare = !noteToShare.isShared();
const resp = await server.put<ToggleInParentResponse>(`notes/${noteToShare.noteId}/toggle-in-parent/_share/${shouldShare}`);
if (!resp.success && "message" in resp) {
toast.showError(resp.message);
} else {
setIsShared(shouldShare);
}
};
const handleClick = (e: MouseEvent) => {
if (e.ctrlKey || e.metaKey) {
e.preventDefault();
e.stopPropagation();
toggleSelection(note.noteId, true, false);
} else if (e.shiftKey) {
e.preventDefault();
e.stopPropagation();
toggleSelection(note.noteId, false, true);
} else if (selectedNoteIds.size > 0) {
e.preventDefault();
e.stopPropagation();
toggleSelection(note.noteId, false, false);
} else {
link.goToLink(e);
}
};
const handleContextMenu = (e: MouseEvent) => {
e.preventDefault();
e.stopPropagation();
if (!isSelected && selectedNoteIds.size > 0) {
toggleSelection(note.noteId, false, false);
}
const noteIdsToDelete = selectedNoteIds.size > 0 && isSelected
? Array.from(selectedNoteIds)
: [note.noteId];
const isBulkOperation = noteIdsToDelete.length > 1;
contextMenu.show({
x: e.pageX,
y: e.pageY,
items: [
{ title: t("link_context_menu.open_note_in_new_window"), command: "openNoteInNewWindow", uiIcon: "bx bx-window-open" },
{ kind: "separator" },
{
title: t("rename_note.rename_note"),
uiIcon: "bx bx-rename",
enabled: !isBulkOperation,
handler: handleRename
},
{ kind: "separator" },
{
title: isShared
? t("shared_switch.toggle-off-title")
: t("shared_switch.toggle-on-title"),
uiIcon: isShared ? "bx bx-unlink" : "bx bx-share-alt",
enabled: !isBulkOperation,
handler: () => handleToggleShare(note)
},
{ kind: "separator" },
{
title: isBulkOperation
? t("gallery.delete_multiple", { count: noteIdsToDelete.length })
: t("note_actions.delete_note"),
uiIcon: "bx bx-trash",
handler: () => deleteNotes(noteIdsToDelete)
}
],
selectMenuItemHandler: ({ command }) => {
if (command) {
linkContextMenu.handleLinkContextMenuItem(command, e, notePath);
}
}
});
};
const handleShareBadgeClick = (e: MouseEvent) => {
e.preventDefault();
e.stopPropagation();
const shareUrl = getShareUrl(note);
copyTextWithToast(shareUrl);
};
return (
<div
className={`gallery-card block-link ${note.isArchived ? "archived" : ""} ${isGallery ? "gallery-folder" : ""} ${isSelected ? "gallery-card-selected" : ""}`}
data-href={`#${notePath}`}
data-note-id={note.noteId}
onClick={(e) => handleClick(e)}
onContextMenu={(e) => handleContextMenu(e)}
>
{isSelected && (
<div className="gallery-card-selection-indicator">
<i className="bx bx-check-circle" />
</div>
)}
{isGallery ? (
<div className="gallery-image-container gallery-folder-icon">
<i className="bx bx-folder" />
{isShared && (
<button
type="button"
className="gallery-share-badge"
title={t("breadcrumb_badges.shared_copy_to_clipboard")}
onClick={handleShareBadgeClick}
>
<i className="bx bx-share-alt" />
</button>
)}
<div className="gallery-title">
{noteTitle}
{childCount > 0 && (
<span className="gallery-item-count"> ({childCount})</span>
)}
</div>
</div>
) : imageSrc ? (
<div className="gallery-image-container">
<img src={imageSrc} alt={noteTitle} loading="lazy" />
<div className="gallery-type-badge">
<i className={note.getIcon()} />
</div>
{isShared && (
<button
type="button"
className="gallery-share-badge"
title={t("breadcrumb_badges.shared_copy_to_clipboard")}
onClick={handleShareBadgeClick}
>
<i className="bx bx-share-alt" />
</button>
)}
<div className="gallery-title">{noteTitle}</div>
</div>
) : null}
</div>
);
}
function getNotePath(parentNote: FNote, childNote: FNote) {
return parentNote.type === "search" ? childNote.noteId : `${parentNote.noteId}/${childNote.noteId}`;
}

View File

@ -1,7 +1,7 @@
import FNote from "../../entities/fnote";
import type { PrintReport } from "../../print";
export const allViewTypes = ["list", "grid", "calendar", "table", "geoMap", "board", "presentation"] as const;
export const allViewTypes = ["list", "grid", "gallery", "calendar", "table", "geoMap", "board", "presentation"] as const;
export type ViewTypeOptions = typeof allViewTypes[number];
export type ViewModeMedia = "screen" | "print";

View File

@ -21,6 +21,7 @@ import { useViewType, VIEW_TYPE_MAPPINGS } from "../ribbon/CollectionPropertiesT
const ICON_MAPPINGS: Record<ViewTypeOptions, string> = {
grid: "bx bxs-grid",
list: "bx bx-list-ul",
gallery: "bx bx-images",
calendar: "bx bx-calendar",
table: "bx bx-table",
geoMap: "bx bx-map-alt",

View File

@ -3,16 +3,16 @@ import { useEffect, useRef } from "preact/hooks";
import ActionButton, { ActionButtonProps } from "./ActionButton";
import Button, { ButtonProps } from "./Button";
interface FormFileUploadProps {
name?: string;
onChange: (files: FileList | null) => void;
multiple?: boolean;
hidden?: boolean;
inputRef?: Ref<HTMLInputElement>;
accept?: string;
}
export default function FormFileUpload({ inputRef, name, onChange, multiple, hidden }: FormFileUploadProps) {
export default function FormFileUpload({ inputRef, name, onChange, multiple, hidden, accept }: FormFileUploadProps) {
// Prevent accidental reuse of a file selected in a previous instance of the upload form.
useEffect(() => {
onChange(null);
@ -26,6 +26,7 @@ export default function FormFileUpload({ inputRef, name, onChange, multiple, hid
type="file"
class="form-control-file"
multiple={multiple}
accept={accept}
onChange={e => onChange((e.target as HTMLInputElement).files)} />
</label>
);
@ -58,7 +59,7 @@ export function FormFileUploadButton({ onChange, ...buttonProps }: Omit<ButtonPr
* Similar to {@link FormFileUploadButton}, but uses an {@link ActionButton} instead of a normal {@link Button}.
* @param param the change listener for the file upload and the properties for the button.
*/
export function FormFileUploadActionButton({ onChange, ...buttonProps }: Omit<ActionButtonProps, "onClick"> & Pick<FormFileUploadProps, "onChange">) {
export function FormFileUploadActionButton({ onChange, multiple, accept, ...buttonProps }: Omit<ActionButtonProps, "onClick"> & Pick<FormFileUploadProps, "onChange" | "multiple" | "accept">) {
const inputRef = useRef<HTMLInputElement>(null);
return (
@ -70,6 +71,8 @@ export function FormFileUploadActionButton({ onChange, ...buttonProps }: Omit<Ac
<FormFileUpload
inputRef={inputRef}
hidden
multiple={multiple}
accept={accept}
onChange={onChange}
/>
</>

View File

@ -1,22 +1,24 @@
import { useContext, useMemo } from "preact/hooks";
import { t } from "../../services/i18n";
import FormSelect, { FormSelectWithGroups } from "../react/FormSelect";
import { TabContext } from "./ribbon-interface";
import { mapToKeyValueArray } from "../../services/utils";
import { useNoteLabel, useNoteLabelBoolean } from "../react/hooks";
import { bookPropertiesConfig, BookProperty, ButtonProperty, CheckBoxProperty, ComboBoxProperty, NumberProperty, SplitButtonProperty } from "./collection-properties-config";
import Button, { SplitButton } from "../react/Button";
import { ParentComponent } from "../react/react_utils";
import FNote from "../../entities/fnote";
import FormCheckbox from "../react/FormCheckbox";
import FormTextBox from "../react/FormTextBox";
import { ComponentChildren } from "preact";
import { ViewTypeOptions } from "../collections/interface";
import { useContext, useMemo } from "preact/hooks";
import FNote from "../../entities/fnote";
import { isExperimentalFeatureEnabled } from "../../services/experimental_features";
import { t } from "../../services/i18n";
import { mapToKeyValueArray } from "../../services/utils";
import { ViewTypeOptions } from "../collections/interface";
import Button, { SplitButton } from "../react/Button";
import FormCheckbox from "../react/FormCheckbox";
import FormSelect, { FormSelectWithGroups } from "../react/FormSelect";
import FormTextBox from "../react/FormTextBox";
import { useNoteLabel, useNoteLabelBoolean } from "../react/hooks";
import { ParentComponent } from "../react/react_utils";
import { bookPropertiesConfig, BookProperty, ButtonProperty, CheckBoxProperty, ComboBoxProperty, NumberProperty, SplitButtonProperty } from "./collection-properties-config";
import { TabContext } from "./ribbon-interface";
export const VIEW_TYPE_MAPPINGS: Record<ViewTypeOptions, string> = {
grid: t("book_properties.grid"),
list: t("book_properties.list"),
gallery: t("book_properties.gallery"),
calendar: t("book_properties.calendar"),
table: t("book_properties.table"),
geoMap: t("book_properties.geo-map"),
@ -50,70 +52,70 @@ export function useViewType(note: FNote | null | undefined) {
}
function CollectionTypeSwitcher({ viewType, setViewType }: { viewType: string, setViewType: (newValue: string) => void }) {
const collectionTypes = useMemo(() => mapToKeyValueArray(VIEW_TYPE_MAPPINGS), []);
const collectionTypes = useMemo(() => mapToKeyValueArray(VIEW_TYPE_MAPPINGS), []);
return (
<div style={{ display: "flex", alignItems: "baseline" }}>
<span style={{ whiteSpace: "nowrap" }}>{t("book_properties.view_type")}:&nbsp; &nbsp;</span>
<FormSelect
currentValue={viewType ?? "grid"} onChange={setViewType}
values={collectionTypes}
keyProperty="key" titleProperty="value"
/>
</div>
)
return (
<div style={{ display: "flex", alignItems: "baseline" }}>
<span style={{ whiteSpace: "nowrap" }}>{t("book_properties.view_type")}:&nbsp; &nbsp;</span>
<FormSelect
currentValue={viewType ?? "grid"} onChange={setViewType}
values={collectionTypes}
keyProperty="key" titleProperty="value"
/>
</div>
);
}
function BookProperties({ viewType, note, properties }: { viewType: ViewTypeOptions, note: FNote, properties: BookProperty[] }) {
return (
<>
{properties.map(property => (
<div className={`type-${property}`}>
{mapPropertyView({ note, property })}
</div>
))}
return (
<>
{properties.map(property => (
<div className={`type-${property}`}>
{mapPropertyView({ note, property })}
</div>
))}
<CheckboxPropertyView
note={note} property={{
bindToLabel: "includeArchived",
label: t("book_properties.include_archived_notes"),
type: "checkbox"
}}
/>
</>
)
<CheckboxPropertyView
note={note} property={{
bindToLabel: "includeArchived",
label: t("book_properties.include_archived_notes"),
type: "checkbox"
}}
/>
</>
);
}
function mapPropertyView({ note, property }: { note: FNote, property: BookProperty }) {
switch (property.type) {
case "button":
return <ButtonPropertyView note={note} property={property} />
case "split-button":
return <SplitButtonPropertyView note={note} property={property} />
case "checkbox":
return <CheckboxPropertyView note={note} property={property} />
case "number":
return <NumberPropertyView note={note} property={property} />
case "combobox":
return <ComboBoxPropertyView note={note} property={property} />
}
switch (property.type) {
case "button":
return <ButtonPropertyView note={note} property={property} />;
case "split-button":
return <SplitButtonPropertyView note={note} property={property} />;
case "checkbox":
return <CheckboxPropertyView note={note} property={property} />;
case "number":
return <NumberPropertyView note={note} property={property} />;
case "combobox":
return <ComboBoxPropertyView note={note} property={property} />;
}
}
function ButtonPropertyView({ note, property }: { note: FNote, property: ButtonProperty }) {
const parentComponent = useContext(ParentComponent);
const parentComponent = useContext(ParentComponent);
return <Button
text={property.label}
title={property.title}
icon={property.icon}
onClick={() => {
if (!parentComponent) return;
property.onClick({
note,
triggerCommand: parentComponent.triggerCommand.bind(parentComponent)
});
}}
/>
return <Button
text={property.label}
title={property.title}
icon={property.icon}
onClick={() => {
if (!parentComponent) return;
property.onClick({
note,
triggerCommand: parentComponent.triggerCommand.bind(parentComponent)
});
}}
/>;
}
function SplitButtonPropertyView({ note, property }: { note: FNote, property: SplitButtonProperty }) {
@ -131,18 +133,18 @@ function SplitButtonPropertyView({ note, property }: { note: FNote, property: Sp
onClick={() => clickContext && property.onClick(clickContext)}
>
{parentComponent && <ItemsComponent note={note} parentComponent={parentComponent} />}
</SplitButton>
</SplitButton>;
}
function CheckboxPropertyView({ note, property }: { note: FNote, property: CheckBoxProperty }) {
const [ value, setValue ] = useNoteLabelBoolean(note, property.bindToLabel);
const [ value, setValue ] = useNoteLabelBoolean(note, property.bindToLabel);
return (
<FormCheckbox
label={property.label}
currentValue={value} onChange={setValue}
/>
)
return (
<FormCheckbox
label={property.label}
currentValue={value} onChange={setValue}
/>
);
}
function NumberPropertyView({ note, property }: { note: FNote, property: NumberProperty }) {
@ -160,7 +162,7 @@ function NumberPropertyView({ note, property }: { note: FNote, property: NumberP
disabled={disabled}
/>
</LabelledEntry>
)
);
}
function ComboBoxPropertyView({ note, property }: { note: FNote, property: ComboBoxProperty }) {
@ -174,7 +176,7 @@ function ComboBoxPropertyView({ note, property }: { note: FNote, property: Combo
currentValue={value ?? property.defaultValue} onChange={setValue}
/>
</LabelledEntry>
)
);
}
function LabelledEntry({ label, children }: { label: string, children: ComponentChildren }) {
@ -186,5 +188,5 @@ function LabelledEntry({ label, children }: { label: string, children: Component
{children}
</label>
</>
)
);
}

View File

@ -1,15 +1,16 @@
import { FilterLabelsByType } from "@triliumnext/commons";
import { t } from "i18next";
import { VNode } from "preact";
import Component from "../../components/component";
import FNote from "../../entities/fnote";
import attributes from "../../services/attributes";
import NoteContextAwareWidget from "../note_context_aware_widget";
import { DEFAULT_MAP_LAYER_NAME, MAP_LAYERS, type MapLayer } from "../collections/geomap/map_layer";
import { ViewTypeOptions } from "../collections/interface";
import { FilterLabelsByType } from "@triliumnext/commons";
import { DEFAULT_THEME, getPresentationThemes } from "../collections/presentation/themes";
import { VNode } from "preact";
import { useNoteLabel } from "../react/hooks";
import NoteContextAwareWidget from "../note_context_aware_widget";
import { FormDropdownDivider, FormListItem } from "../react/FormList";
import Component from "../../components/component";
import { useNoteLabel } from "../react/hooks";
interface BookConfig {
properties: BookProperty[];
@ -78,6 +79,9 @@ export const bookPropertiesConfig: Record<ViewTypeOptions, BookConfig> = {
grid: {
properties: []
},
gallery: {
properties: []
},
list: {
properties: [
{
@ -211,7 +215,7 @@ function ListExpandDepth(context: { note: FNote, parentComponent: Component }) {
<FormDropdownDivider />
<ListExpandDepthButton label={t("book_properties.expand_all_levels")} depth="all" checked={currentDepth === "all"} {...context} />
</>
)
);
}
function ListExpandDepthButton({ label, depth, note, parentComponent, checked }: { label: string, depth: number | "all", note: FNote, parentComponent: Component, checked?: boolean }) {
@ -236,5 +240,5 @@ function buildExpandListHandler(depth: number | "all") {
await attributes.setLabel(noteId, "expanded", newValue);
triggerCommand("refreshNoteList", { noteId });
}
};
}

View File

@ -1,11 +1,13 @@
import "./Book.css";
import { useEffect, useState } from "preact/hooks";
import { t } from "../../services/i18n";
import { ViewTypeOptions } from "../collections/interface";
import Alert from "../react/Alert";
import { useNoteLabelWithDefault, useTriliumEvent } from "../react/hooks";
import RawHtml from "../react/RawHtml";
import { TypeWidgetProps } from "./type_widget";
import "./Book.css";
import { useEffect, useState } from "preact/hooks";
import { ViewTypeOptions } from "../collections/interface";
const VIEW_TYPES: ViewTypeOptions[] = [ "list", "grid", "presentation" ];
@ -32,5 +34,5 @@ export default function Book({ note }: TypeWidgetProps) {
</Alert>
)}
</>
)
);
}

View File

@ -410,6 +410,7 @@
"list-view": "List View",
"grid-view": "Grid View",
"calendar": "Calendar",
"gallery": "Gallery",
"table": "Table",
"geo-map": "Geo Map",
"start-date": "Start Date",

View File

@ -75,6 +75,27 @@ export default function buildHiddenSubtreeTemplates() {
}
]
},
{
id: "_template_gallery",
type: "book",
title: t("hidden_subtree_templates.gallery"),
icon: "bx bx-images",
attributes: [
{
name: "template",
type: "label"
},
{
name: "collection",
type: "label"
},
{
name: "viewType",
type: "label",
value: "gallery"
}
]
},
{
id: "_template_calendar",
type: "book",