mirror of
https://github.com/zadam/trilium.git
synced 2025-12-04 22:44:25 +01:00
feat(gallery): add gallery note type and implement gallery widget with image management features
This commit is contained in:
parent
2e431b1135
commit
15171e3dee
@ -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;
|
||||
|
||||
@ -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 },
|
||||
|
||||
@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@ -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
|
||||
}
|
||||
};
|
||||
|
||||
367
apps/client/src/widgets/type_widgets/Gallery.css
Normal file
367
apps/client/src/widgets/type_widgets/Gallery.css
Normal 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);
|
||||
}
|
||||
624
apps/client/src/widgets/type_widgets/Gallery.tsx
Normal file
624
apps/client/src/widgets/type_widgets/Gallery.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@ -4414,6 +4414,7 @@ components:
|
||||
- contentWidget
|
||||
- mindMap
|
||||
- geoMap
|
||||
- gallery
|
||||
|
||||
Branch:
|
||||
type: object
|
||||
|
||||
@ -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];
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user