feat(gallery): add gallery note type and implement gallery widget with image management features

This commit is contained in:
lzinga 2025-11-28 13:44:57 -08:00
parent 2e431b1135
commit 15171e3dee
8 changed files with 1040 additions and 4 deletions

View File

@ -27,7 +27,8 @@ const NOTE_TYPE_ICONS = {
doc: "bx bxs-file-doc",
contentWidget: "bx bxs-widget",
mindMap: "bx bx-sitemap",
aiChat: "bx bx-bot"
aiChat: "bx bx-bot",
gallery: "bx bx-image-alt"
};
/**
@ -35,7 +36,7 @@ const NOTE_TYPE_ICONS = {
* end user. Those types should be used only for checking against, they are
* not for direct use.
*/
export type NoteType = "file" | "image" | "search" | "noteMap" | "launcher" | "doc" | "contentWidget" | "text" | "relationMap" | "render" | "canvas" | "mermaid" | "book" | "webView" | "code" | "mindMap" | "aiChat";
export type NoteType = "file" | "image" | "search" | "noteMap" | "launcher" | "doc" | "contentWidget" | "text" | "relationMap" | "render" | "canvas" | "mermaid" | "book" | "webView" | "code" | "mindMap" | "aiChat" | "gallery";
export interface NotePathRecord {
isArchived: boolean;

View File

@ -34,6 +34,9 @@ export const NOTE_TYPES: NoteTypeMapping[] = [
{ type: "canvas", mime: "application/json", title: t("note_types.canvas"), icon: "bx-pen" },
{ type: "mermaid", mime: "text/mermaid", title: t("note_types.mermaid-diagram"), icon: "bx-selection" },
// Media notes
{ type: "gallery", mime: "", title: t("note_types.gallery"), icon: "bx-image-alt", reserved: false },
// Map notes
{ type: "mindMap", mime: "application/json", title: t("note_types.mind-map"), icon: "bx-sitemap" },
{ type: "noteMap", mime: "", title: t("note_types.note-map"), icon: "bxs-network-chart", static: true },

View File

@ -1673,7 +1673,8 @@
"ai-chat": "AI Chat",
"task-list": "Task List",
"new-feature": "New",
"collections": "Collections"
"collections": "Collections",
"gallery": "Gallery"
},
"protect_note": {
"toggle-on": "Protect the note",
@ -2107,5 +2108,38 @@
"clear-color": "Clear note color",
"set-color": "Set note color",
"set-custom-color": "Set custom note color"
},
"gallery": {
"no_images": "No Images",
"upload_first_image": "Upload First Image",
"image_count": "{{count}} Image",
"upload_images": "Upload Images",
"image_count_plural": "{{count}} Images",
"confirm_delete_note": "Are you sure you want to delete {{title}}",
"confirm_delete_attachment": "Are you sure you want to delete the attachment {{title}}",
"delete_note_success": "{{title}} deleted successfully",
"delete_attachment_success": "{{title}} deleted successfully",
"delete_error": "An error occurred while attempting to delete {{title}} - {{message}}",
"confirm_delete_multiple": "Are you sure you want to delete {{count}} items?",
"delete_multiple_success": "{{count}} items deleted successfully!",
"delete_multiple_error": "An error has occurred while attempting to delete {{count}} images.",
"cancel_selection": "Cancel Multi Selection",
"select": "Select Multiple",
"select_all": "Select All",
"deselect_all": "Deselect All",
"delete_selected": "Delete {{count}} Items",
"delete": "Delete",
"previous": "Previous",
"next": "Next",
"close": "close",
"goto_note": "Go To Parent Note",
"warning_delete_other_note": "This image belongs to another note, deleting it will remove it from that note!",
"cancel": "cancel",
"copy_image_link": "Image Link Copied to Clipboard",
"share_copy_failed": "Failed to copy image link to clipboard.",
"select_image": "Select Image",
"show_child_images_btn": "Show Child Images",
"hide_child_images_btn": "Hide Child Images",
"toggle_child_images_tooltip": "Toggle Child Images"
}
}

View File

@ -139,5 +139,10 @@ export const TYPE_MAPPINGS: Record<ExtendedNoteType, NoteTypeMapping> = {
view: () => import("./type_widgets/AiChat"),
className: "ai-chat-widget-container",
isFullHeight: true
},
gallery: {
view: () => import("./type_widgets/Gallery"),
className: "note-detail-gallery",
isFullHeight: true
}
};

View File

@ -0,0 +1,367 @@
.gallery-container {
padding: 1rem;
height: 100%;
overflow-y: auto;
display: flex;
flex-direction: column;
}
.gallery-toolbar {
display: flex;
justify-content: space-between;
align-items: center;
padding: 0.5rem;
margin-bottom: 1rem;
border-bottom: 1px solid var(--main-border-color);
}
.gallery-count {
color: var(--muted-text-color);
font-size: 0.875rem;
}
/* Masonry Grid Layout */
.gallery-masonry {
column-count: 4;
column-gap: 1rem;
padding: 0.5rem;
}
@media (max-width: 1400px) {
.gallery-masonry {
column-count: 3;
}
}
@media (max-width: 1000px) {
.gallery-masonry {
column-count: 2;
}
}
@media (max-width: 600px) {
.gallery-masonry {
column-count: 1;
}
}
.gallery-item {
position: relative;
break-inside: avoid;
margin-bottom: 1rem;
cursor: pointer;
border-radius: 8px;
overflow: hidden;
background: var(--main-background-color);
border: 1px solid var(--main-border-color);
transition: transform 0.2s ease, box-shadow 0.2s ease;
}
.gallery-item:hover {
transform: translateY(-4px);
box-shadow: 0 8px 16px rgba(0, 0, 0, 0.15);
}
.gallery-item img {
width: 100%;
height: auto;
display: block;
}
.gallery-item-overlay {
position: absolute;
bottom: 0;
left: 0;
right: 0;
background: linear-gradient(to top, rgba(0, 0, 0, 0.8) 0%, rgba(0, 0, 0, 0) 100%);
padding: 2rem 1rem 0.75rem;
opacity: 0;
transition: opacity 0.2s ease;
}
.gallery-item:hover .gallery-item-overlay {
opacity: 1;
}
.gallery-item-title {
color: white;
font-size: 0.875rem;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.gallery-item-type {
position: absolute;
top: 0.5rem;
right: 0.5rem;
background: rgba(0, 0, 0, 0.6);
color: white;
padding: 0.25rem 0.5rem;
border-radius: 4px;
font-size: 1rem;
}
/* Empty State */
.gallery-empty {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 4rem 2rem;
text-align: center;
color: var(--muted-text-color);
flex: 1;
}
.gallery-empty-icon {
font-size: 5rem;
opacity: 0.3;
margin-bottom: 1rem;
}
.gallery-empty p {
margin: 0.5rem 0;
}
.gallery-empty-hint {
font-size: 0.875rem;
margin-bottom: 1.5rem !important;
}
/* Lightbox */
.gallery-lightbox {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.95);
z-index: 10000;
display: flex;
align-items: center;
justify-content: center;
animation: fadeIn 0.2s ease;
}
@keyframes fadeIn {
from { opacity: 0; }
to { opacity: 1; }
}
.gallery-lightbox-content {
max-width: 90vw;
max-height: 90vh;
display: flex;
flex-direction: column;
align-items: center;
}
.gallery-lightbox-content img {
max-width: 100%;
max-height: calc(90vh - 60px);
object-fit: contain;
border-radius: 4px;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.5);
}
.gallery-lightbox-info {
display: flex;
justify-content: space-between;
align-items: center;
width: 100%;
margin-top: 1rem;
padding: 0 1rem;
color: white;
}
.gallery-lightbox-info h3 {
margin: 0;
font-size: 1.25rem;
font-weight: 500;
}
.gallery-lightbox-counter {
font-size: 0.875rem;
opacity: 0.7;
}
.gallery-lightbox-close {
position: absolute;
top: 1rem;
right: 1rem;
background: rgba(255, 255, 255, 0.1);
border: none;
color: white;
font-size: 2rem;
width: 3rem;
height: 3rem;
border-radius: 50%;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: background 0.2s ease;
z-index: 10001;
}
.gallery-lightbox-close:hover {
background: rgba(255, 255, 255, 0.2);
}
.gallery-lightbox-nav {
position: absolute;
top: 50%;
transform: translateY(-50%);
background: rgba(255, 255, 255, 0.1);
border: none;
color: white;
font-size: 2rem;
width: 3rem;
height: 3rem;
border-radius: 50%;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: background 0.2s ease;
z-index: 10001;
}
.gallery-lightbox-nav:hover {
background: rgba(255, 255, 255, 0.2);
}
.gallery-lightbox-prev {
left: 1rem;
}
.gallery-lightbox-next {
right: 1rem;
}
/* Selection Mode */
.gallery-item.selection-mode {
cursor: pointer;
}
.gallery-item.selected {
border: 3px solid var(--button-border-color);
box-shadow: 0 0 0 2px rgba(66, 133, 244, 0.3);
}
.gallery-item-checkbox {
position: absolute;
top: 0.5rem;
left: 0.5rem;
z-index: 10;
background: rgba(0, 0, 0, 0.7);
backdrop-filter: blur(4px);
border-radius: 4px;
padding: 0.5rem;
display: flex;
align-items: center;
justify-content: center;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3);
}
.gallery-item-checkbox input[type="checkbox"] {
width: 18px;
height: 18px;
cursor: pointer;
margin: 0;
accent-color: var(--button-border-color);
}
.gallery-item-actions {
display: flex;
align-items: center;
gap: 0.5rem;
}
.gallery-item-delete {
background: rgba(220, 53, 69, 0.9);
color: white;
border: none;
border-radius: 4px;
padding: 0.375rem 0.5rem;
cursor: pointer;
font-size: 1.125rem;
display: flex;
align-items: center;
justify-content: center;
transition: background 0.2s ease;
}
.gallery-item-delete:hover {
background: rgba(220, 53, 69, 1);
}
.gallery-lightbox-delete {
position: absolute;
top: 1rem;
right: 5rem;
background: rgba(220, 53, 69, 0.9);
border: none;
color: white;
font-size: 1.5rem;
width: 3rem;
height: 3rem;
border-radius: 50%;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: background 0.2s ease;
z-index: 10001;
}
.gallery-lightbox-delete:hover {
background: rgba(220, 53, 69, 1);
}
/* Toolbar Styles */
.gallery-toolbar-left {
display: flex;
gap: 0.5rem;
}
.gallery-selection-actions {
display: flex;
gap: 0.5rem;
}
.btn-danger {
background: rgba(220, 53, 69, 0.1);
color: var(--danger-color, #dc3545);
}
.btn-danger:hover:not(:disabled) {
background: rgba(220, 53, 69, 0.2);
}
.gallery-item-link {
position: absolute;
bottom: 0.75rem;
right: 0.75rem;
background: rgba(108, 117, 125, 0.9);
color: white;
border: none;
border-radius: 4px;
padding: 0.375rem 0.5rem;
cursor: pointer;
font-size: 1.125rem;
display: flex;
align-items: center;
justify-content: center;
transition: background 0.2s ease, opacity 0.2s ease;
opacity: 0;
z-index: 100;
}
.gallery-item:hover .gallery-item-link {
opacity: 1;
}
.gallery-item-link:hover {
background: rgba(108, 117, 125, 1);
}

View File

@ -0,0 +1,624 @@
import { TypeWidgetProps } from "./type_widget";
import { useContext, useEffect, useRef, useState } from "preact/hooks";
import { useTriliumEvent, useTriliumOption } from "../react/hooks";
import FNote from "../../entities/fnote";
import FAttachment from "../../entities/fattachment";
import { ParentComponent } from "../react/react_utils";
import Button from "../react/Button";
import { t } from "../../services/i18n";
import dialog from "../../services/dialog";
import server from "../../services/server";
import toast from "../../services/toast";
import appContext from "../../components/app_context";
import { formatSize } from "../../services/utils";
import branches from "../../services/branches";
import sync from "../../services/sync";
import "./Gallery.css";
interface ImageItem {
id: string;
url: string;
title: string;
type: 'note' | 'attachment';
noteId?: string; // For attachments, store the parent note ID
size?: number; // Size in bytes
}
export default function Gallery({ note }: TypeWidgetProps) {
const [images, setImages] = useState<ImageItem[]>([]);
const [selectedImage, setSelectedImage] = useState<ImageItem | null>(null);
const [selectedIndex, setSelectedIndex] = useState<number>(-1);
const [selectedImages, setSelectedImages] = useState<Set<string>>(new Set());
const [isSelectionMode, setIsSelectionMode] = useState(false);
const [totalSize, setTotalSize] = useState<number>(0);
const [isGalleryShared, setIsGalleryShared] = useState<boolean>(false);
const [syncServerHost] = useTriliumOption("syncServerHost");
const parentComponent = useContext(ParentComponent);
async function loadImages() {
const imageItems: ImageItem[] = [];
let calculatedTotalSize = 0;
// Check if the gallery note itself is shared
const galleryIsShared = note.hasAncestor("_share");
setIsGalleryShared(galleryIsShared);
// Check if we should hide child images
const hideChildImages = note.hasLabel("hideChildAttachments");
// First, check for images attached directly to this gallery note
const directAttachments = await note.getAttachments();
const directImageAttachments = directAttachments.filter(a => a.role === "image");
for (const attachment of directImageAttachments) {
const size = attachment.contentLength || 0;
calculatedTotalSize += size;
imageItems.push({
id: attachment.attachmentId,
url: `api/attachments/${attachment.attachmentId}/image/${encodeURIComponent(attachment.title)}`,
title: attachment.title,
type: 'attachment' as const,
noteId: attachment.ownerId,
size
});
}
// Only load child notes and their attachments if hideChildImages is not set
if (!hideChildImages) {
// Then check child notes
const childNotes = await note.getChildNotes();
const imageNotes = childNotes.filter(n => n.type === "image");
// Convert image notes to ImageItem format
for (const imageNote of imageNotes) {
const blob = await imageNote.getBlob();
const size = blob?.contentLength || 0;
calculatedTotalSize += size;
imageItems.push({
id: imageNote.noteId,
url: `api/images/${imageNote.noteId}/${encodeURIComponent(imageNote.title)}`,
title: imageNote.title,
type: 'note' as const,
size
});
}
// Also check for notes with image attachments
for (const childNote of childNotes) {
const attachments = await childNote.getAttachments();
const imageAttachments = attachments.filter(a => a.role === "image");
for (const attachment of imageAttachments) {
const size = attachment.contentLength || 0;
calculatedTotalSize += size;
imageItems.push({
id: attachment.attachmentId,
url: `api/attachments/${attachment.attachmentId}/image/${encodeURIComponent(attachment.title)}`,
title: attachment.title,
type: 'attachment' as const,
noteId: attachment.ownerId,
size
});
}
}
}
setImages(imageItems);
setTotalSize(calculatedTotalSize);
}
useEffect(() => {
loadImages();
}, [note]);
useTriliumEvent("entitiesReloaded", ({ loadResults }) => {
const childNoteIds = images.map(img => img.noteId).filter(Boolean);
// Reload if branches change (child notes added/removed or share status changed)
const branchRows = loadResults.getBranchRows();
if (branchRows.some(b => b.parentNoteId === note.noteId || b.noteId === note.noteId)) {
loadImages();
return;
}
// Reload if any child note changes
const noteIds = loadResults.getNoteIds();
if (noteIds.some(id => id === note.noteId || childNoteIds.includes(id))) {
loadImages();
return;
}
// Reload if attachments change for this note or its children
const attachmentRows = loadResults.getAttachmentRows();
if (attachmentRows.some(att => att.ownerId === note.noteId || childNoteIds.includes(att.ownerId))) {
loadImages();
return;
}
// Reload if attributes change (for hideChildImages label)
const attributeRows = loadResults.getAttributeRows();
if (attributeRows.some(attr => attr.noteId === note.noteId && attr.name === "hideChildAttachments")) {
loadImages();
return;
}
});
function handleUploadImages() {
parentComponent?.triggerCommand("showUploadAttachmentsDialog", { noteId: note.noteId });
}
function handleImageClick(img: ImageItem, index: number, e: MouseEvent) {
e.stopPropagation();
if (isSelectionMode) {
toggleImageSelection(img.id);
} else {
setSelectedImage(img);
setSelectedIndex(index);
}
}
function handleLinkClick(img: ImageItem, e: MouseEvent) {
e.stopPropagation();
if (img.type === 'note') {
// Navigate to the note
appContext.tabManager.getActiveContext()?.setNote(img.id);
} else if (img.noteId && img.noteId !== note.noteId) {
// For attachments on child notes, navigate to the parent note
appContext.tabManager.getActiveContext()?.setNote(img.noteId);
}
// Note: No else clause - we don't do anything for gallery note attachments
}
async function handleToggleShare() {
if (isGalleryShared) {
// Unshare the gallery
if (note.getParentBranches().length === 1 && !(await dialog.confirm(t("gallery.confirm_unshare_only_location")))) {
return;
}
const shareBranch = note.getParentBranches().find((b) => b.parentNoteId === "_share");
if (shareBranch?.branchId) {
await server.remove(`branches/${shareBranch.branchId}`);
toast.showMessage(t("gallery.unshared_success"));
sync.syncNow(true);
await loadImages();
}
} else {
// Share the gallery
await branches.cloneNoteToParentNote(note.noteId, "_share");
toast.showMessage(t("gallery.shared_success"));
sync.syncNow(true);
await loadImages();
}
}
async function handleCopyImageLink(img: ImageItem, e: MouseEvent) {
e.stopPropagation();
if (!isGalleryShared) {
toast.showError(t("gallery.not_shared"));
return;
}
let imageUrl: string;
if (img.type === 'note') {
// For image notes, get their share URL
const imageNote = note.froca.getNoteFromCache(img.id);
if (!imageNote) return;
const shareId = imageNote.getOwnedLabelValue("shareAlias") || img.id;
imageUrl = getAbsoluteUrl(`/share/api/images/${shareId}/${encodeURIComponent(img.title)}`);
} else {
// For attachments, use the attachment API
imageUrl = getAbsoluteUrl(`/share/api/attachments/${img.id}/image/${encodeURIComponent(img.title)}`);
}
try {
await navigator.clipboard.writeText(imageUrl);
toast.showMessage(t("gallery.copy_image_link"));
} catch (error) {
toast.showError(t("gallery.share_copy_failed"));
}
}
async function handleToggleHideChildImages() {
const currentValue = note.hasLabel("hideChildAttachments");
if (currentValue) {
// Remove the attribute
const attr = note.getOwnedAttributes("label", "hideChildAttachments")[0];
if (attr) {
await server.remove(`notes/${note.noteId}/attributes/${attr.attributeId}`);
toast.showMessage(t("gallery.show_child_images_btn"));
}
} else {
// Add the attribute
await server.post(`notes/${note.noteId}/attributes`, {
type: "label",
name: "hideChildAttachments",
value: ""
});
toast.showMessage(t("gallery.hide_child_images_btn"));
}
await loadImages();
}
function getShareUrl(noteId: string, syncServerHost: string | null): string | null {
const shareId = note.getOwnedLabelValue("shareAlias") || noteId;
if (syncServerHost) {
return new URL(`/share/${shareId}`, syncServerHost).href;
} else {
let host = location.host;
if (host.endsWith("/")) {
host = host.substring(0, host.length - 1);
}
return `${location.protocol}//${host}${location.pathname}share/${shareId}`;
}
}
function getAbsoluteUrl(path: string): string {
if (syncServerHost) {
return new URL(path, syncServerHost).href;
} else {
let host = location.host;
if (host.endsWith("/")) {
host = host.substring(0, host.length - 1);
}
return `${location.protocol}//${host}${location.pathname.replace(/\/$/, '')}${path}`;
}
}
function toggleImageSelection(imageId: string) {
setSelectedImages(prev => {
const newSet = new Set(prev);
if (newSet.has(imageId)) {
newSet.delete(imageId);
} else {
newSet.add(imageId);
}
return newSet;
});
}
function toggleSelectionMode() {
setIsSelectionMode(!isSelectionMode);
if (isSelectionMode) {
setSelectedImages(new Set());
}
}
function selectAll() {
setSelectedImages(new Set(images.map(img => img.id)));
}
function deselectAll() {
setSelectedImages(new Set());
}
function closeLightbox() {
setSelectedImage(null);
setSelectedIndex(-1);
}
function navigateImage(direction: 'prev' | 'next') {
if (images.length === 0) return;
let newIndex = selectedIndex;
if (direction === 'prev') {
newIndex = selectedIndex > 0 ? selectedIndex - 1 : images.length - 1;
} else {
newIndex = selectedIndex < images.length - 1 ? selectedIndex + 1 : 0;
}
setSelectedIndex(newIndex);
setSelectedImage(images[newIndex]);
}
async function handleDeleteImage(img: ImageItem, e?: MouseEvent) {
if (e) {
e.stopPropagation();
}
// Check if this image belongs to another note
const belongsToOtherNote = img.type === 'note' || (img.noteId && img.noteId !== note.noteId);
let confirmMessage = img.type === 'note'
? t("gallery.confirm_delete_note", { title: img.title })
: t("gallery.confirm_delete_attachment", { title: img.title });
// Add warning if it belongs to another note
if (belongsToOtherNote) {
confirmMessage += "\n\n" + t("gallery.warning_delete_other_note");
}
const confirmed = await dialog.confirm(confirmMessage);
if (!confirmed) {
return;
}
try {
if (img.type === 'note') {
await server.remove(`notes/${img.id}`);
toast.showMessage(t("gallery.delete_note_success", { title: img.title }));
} else {
await server.remove(`attachments/${img.id}`);
toast.showMessage(t("gallery.delete_attachment_success", { title: img.title }));
}
if (selectedImage?.id === img.id) {
closeLightbox();
}
await loadImages();
} catch (error: any) {
toast.showError(t("gallery.delete_error", { message: error.message || String(error), title: img.title }));
}
}
async function handleDeleteSelected() {
if (selectedImages.size === 0) return;
const confirmMessage = t("gallery.confirm_delete_multiple", { count: selectedImages.size });
if (!await dialog.confirm(confirmMessage)) {
return;
}
const imagesToDelete = images.filter(img => selectedImages.has(img.id));
let successCount = 0;
let errorCount = 0;
for (const img of imagesToDelete) {
try {
if (img.type === 'note') {
await server.remove(`notes/${img.id}`);
} else {
await server.remove(`attachments/${img.id}`);
}
successCount++;
} catch (error) {
console.error(`Failed to delete image ${img.id}:`, error);
errorCount++;
}
}
if (successCount > 0) {
toast.showMessage(t("gallery.delete_multiple_success", { count: successCount }));
}
if (errorCount > 0) {
toast.showError(t("gallery.delete_multiple_error", { count: errorCount }));
}
setSelectedImages(new Set());
setIsSelectionMode(false);
await loadImages();
}
function handleKeyDown(e: KeyboardEvent) {
if (!selectedImage) return;
if (e.key === 'Escape') {
closeLightbox();
} else if (e.key === 'ArrowLeft') {
navigateImage('prev');
} else if (e.key === 'ArrowRight') {
navigateImage('next');
} else if (e.key === 'Delete' || e.key === 'Backspace') {
handleDeleteImage(selectedImage);
}
}
useEffect(() => {
if (selectedImage) {
document.addEventListener('keydown', handleKeyDown);
return () => document.removeEventListener('keydown', handleKeyDown);
}
}, [selectedImage, selectedIndex]);
return (
<div className="gallery-container">
<div className="gallery-toolbar">
<div className="gallery-toolbar-left">
<Button
icon="bx bx-upload"
text={t("gallery.upload_images")}
onClick={handleUploadImages}
size="small"
/>
<Button
icon={isSelectionMode ? "bx bx-x" : "bx bx-select-multiple"}
text={isSelectionMode ? t("gallery.cancel_selection") : t("gallery.select")}
onClick={toggleSelectionMode}
size="small"
/>
<Button
icon={note.hasLabel("hideChildAttachments") ? "bx bx-show" : "bx bx-hide"}
text={note.hasLabel("hideChildAttachments") ? t("gallery.show_child_images_btn") : t("gallery.hide_child_images_btn")}
onClick={handleToggleHideChildImages}
size="small"
title={t("gallery.toggle_child_images_tooltip")}
/>
</div>
{isSelectionMode && (
<div className="gallery-selection-actions">
<Button
text={t("gallery.select_all")}
onClick={selectAll}
size="small"
/>
<Button
text={t("gallery.deselect_all")}
onClick={deselectAll}
size="small"
disabled={selectedImages.size === 0}
/>
<Button
icon="bx bx-trash"
text={t("gallery.delete_selected", { count: selectedImages.size })}
onClick={handleDeleteSelected}
size="small"
disabled={selectedImages.size === 0}
className="btn-danger"
/>
</div>
)}
<span className="gallery-count">
{images.length === 1
? t("gallery.image_count", { count: images.length })
: t("gallery.image_count_plural", { count: images.length })
}
{totalSize > 0 && ` (${formatSize(totalSize)})`}
</span>
</div>
{images.length === 0 ? (
<div className="gallery-empty">
<div className="gallery-empty-icon">
<span className="bx bx-image-alt"></span>
</div>
<p>{t("gallery.no_images")}</p>
<p className="gallery-empty-hint">{t("gallery.upload_hint")}</p>
<Button
icon="bx bx-upload"
text={t("gallery.upload_first_image")}
onClick={handleUploadImages}
primary
/>
</div>
) : (
<div className="gallery-masonry">
{images.map((img, index) => (
<div
className={`gallery-item ${selectedImages.has(img.id) ? 'selected' : ''} ${isSelectionMode ? 'selection-mode' : ''}`}
key={img.id}
onClick={(e) => handleImageClick(img, index, e)}
>
{isSelectionMode && (
<div className="gallery-item-checkbox">
<input
type="checkbox"
checked={selectedImages.has(img.id)}
onChange={() => toggleImageSelection(img.id)}
onClick={(e) => e.stopPropagation()}
aria-label={t("gallery.select_image", { title: img.title })}
/>
</div>
)}
<img src={img.url} alt={img.title} loading="lazy" />
<div className="gallery-item-overlay">
<div className="gallery-item-title">{img.title}</div>
{!isSelectionMode && (
<div className="gallery-item-actions">
{isGalleryShared && (
<button
type="button"
className="gallery-item-copy-link"
onClick={(e) => handleCopyImageLink(img, e)}
title={t("gallery.copy_image_link")}
>
<span className="bx bx-link"></span>
</button>
)}
<button
type="button"
className="gallery-item-delete"
onClick={(e) => handleDeleteImage(img, e)}
title={t("gallery.delete")}
>
<span className="bx bx-trash"></span>
</button>
</div>
)}
</div>
{!isSelectionMode && (img.type === 'note' || (img.noteId && img.noteId !== note.noteId)) && (
<button
type="button"
className="gallery-item-link"
onClick={(e) => handleLinkClick(img, e)}
title={t("gallery.goto_note")}
>
<span className="bx bx-right-arrow-alt"></span>
</button>
)}
</div>
))}
</div>
)}
{selectedImage && !isSelectionMode && (
<div className="gallery-lightbox" onClick={closeLightbox}>
<button
type="button"
className="gallery-lightbox-close"
onClick={closeLightbox}
title={t("gallery.close")}
>
<span className="bx bx-x"></span>
</button>
<button
type="button"
className="gallery-lightbox-delete"
onClick={(e) => { e.stopPropagation(); handleDeleteImage(selectedImage); }}
title={t("gallery.delete")}
>
<span className="bx bx-trash"></span>
</button>
{isGalleryShared && (
<button
type="button"
className="gallery-lightbox-copy-link"
onClick={(e) => { e.stopPropagation(); handleCopyImageLink(selectedImage, e); }}
title={t("gallery.copy_image_link")}
>
<span className="bx bx-link"></span>
</button>
)}
{images.length > 1 && (
<>
<button
type="button"
className="gallery-lightbox-nav gallery-lightbox-prev"
onClick={(e) => { e.stopPropagation(); navigateImage('prev'); }}
title={t("gallery.previous")}
>
<span className="bx bx-chevron-left"></span>
</button>
<button
type="button"
className="gallery-lightbox-nav gallery-lightbox-next"
onClick={(e) => { e.stopPropagation(); navigateImage('next'); }}
title={t("gallery.next")}
>
<span className="bx bx-chevron-right"></span>
</button>
</>
)}
<div className="gallery-lightbox-content" onClick={(e) => e.stopPropagation()}>
<img src={selectedImage.url} alt={selectedImage.title} />
<div className="gallery-lightbox-info">
<h3>{selectedImage.title}</h3>
<span className="gallery-lightbox-counter">
{selectedIndex + 1} / {images.length}
</span>
</div>
</div>
</div>
)}
</div>
);
}

View File

@ -4414,6 +4414,7 @@ components:
- contentWidget
- mindMap
- geoMap
- gallery
Branch:
type: object

View File

@ -120,7 +120,8 @@ export const ALLOWED_NOTE_TYPES = [
"webView",
"code",
"mindMap",
"aiChat"
"aiChat",
"gallery"
] as const;
export type NoteType = (typeof ALLOWED_NOTE_TYPES)[number];