Compare commits

..

40 Commits

Author SHA1 Message Date
Elian Doran
8f6912cd57 chore(electron-builder): add build settings in package.json 2025-08-14 17:04:47 +03:00
Elian Doran
d32dbf40f8 chore(electron-builder): install dep 2025-08-14 16:15:52 +03:00
Elian Doran
1dfcf960d3 fix(client): missing calendar view language support 2025-08-14 15:20:08 +03:00
Elian Doran
9bdc51a3fb feat(i18n): add Japanese language 2025-08-14 14:51:57 +03:00
Elian Doran
dbf3bcfacf Merge remote-tracking branch 'weblate/main' 2025-08-14 14:34:30 +03:00
Elian Doran
3d5b269315 chore(docs): fix file 2025-08-14 14:23:37 +03:00
Elian Doran
48f97da9cc chore(forge/rpm): rename key properly 2025-08-14 14:10:33 +03:00
Elian Doran
9c954fbd81 Create CNAME 2025-08-14 13:53:25 +03:00
Elian Doran
c6bd41654f chore(forge/rpm): add public key 2025-08-14 13:40:23 +03:00
Francis C
d65a74bb23 Translated using Weblate (Japanese)
Currently translated at 96.8% (366 of 378 strings)

Translation: Trilium Notes/Server
Translate-URL: https://hosted.weblate.org/projects/trilium/server/ja/
2025-08-14 12:30:37 +02:00
acwr47
ff08bca042 Translated using Weblate (Japanese)
Currently translated at 41.6% (646 of 1550 strings)

Translation: Trilium Notes/Client
Translate-URL: https://hosted.weblate.org/projects/trilium/client/ja/
2025-08-14 12:30:36 +02:00
Francis C
a5d3d2e3b4 Translated using Weblate (Chinese (Traditional Han script))
Currently translated at 100.0% (1550 of 1550 strings)

Translation: Trilium Notes/Client
Translate-URL: https://hosted.weblate.org/projects/trilium/client/zh_Hant/
2025-08-14 12:30:35 +02:00
Bruno MARGUERIN
496a0667ee Translated using Weblate (French)
Currently translated at 71.1% (269 of 378 strings)

Translation: Trilium Notes/Server
Translate-URL: https://hosted.weblate.org/projects/trilium/server/fr/
2025-08-14 12:30:34 +02:00
Bruno MARGUERIN
9be688b667 Translated using Weblate (French)
Currently translated at 80.7% (1252 of 1550 strings)

Translation: Trilium Notes/Client
Translate-URL: https://hosted.weblate.org/projects/trilium/client/fr/
2025-08-14 12:30:33 +02:00
Elian Doran
f3d9008c61 feat(forge): rpm signing (#6646) 2025-08-14 13:30:26 +03:00
Elian Doran
649a43c978 fix(forge): RPM signing not done on the right file 2025-08-14 12:45:18 +03:00
Elian Doran
50568704ca feat(forge): minor improvements to RPM signing 2025-08-14 12:40:19 +03:00
Elian Doran
b66b4dec83 feat(forge): proper rpm signing 2025-08-14 12:04:12 +03:00
Francis C
8d0e807435 Translated using Weblate (Japanese)
Currently translated at 96.8% (366 of 378 strings)

Translation: Trilium Notes/Server
Translate-URL: https://hosted.weblate.org/projects/trilium/server/ja/
2025-08-14 09:02:31 +00:00
acwr47
bf05ed7caf Translated using Weblate (Japanese)
Currently translated at 96.8% (366 of 378 strings)

Translation: Trilium Notes/Server
Translate-URL: https://hosted.weblate.org/projects/trilium/server/ja/
2025-08-14 09:02:29 +00:00
Elian Doran
b5080eff00 Translated using Weblate (Russian)
Currently translated at 55.6% (863 of 1550 strings)

Translation: Trilium Notes/Client
Translate-URL: https://hosted.weblate.org/projects/trilium/client/ru/
2025-08-14 09:02:28 +00:00
Francis C
c474769dd6 Translated using Weblate (Japanese)
Currently translated at 35.4% (550 of 1550 strings)

Translation: Trilium Notes/Client
Translate-URL: https://hosted.weblate.org/projects/trilium/client/ja/
2025-08-14 09:02:27 +00:00
acwr47
a6ae01da0b Translated using Weblate (Japanese)
Currently translated at 35.4% (550 of 1550 strings)

Translation: Trilium Notes/Client
Translate-URL: https://hosted.weblate.org/projects/trilium/client/ja/
2025-08-14 09:02:25 +00:00
Francis C
2bf4c44dbf Translated using Weblate (Chinese (Traditional Han script))
Currently translated at 100.0% (378 of 378 strings)

Translation: Trilium Notes/Server
Translate-URL: https://hosted.weblate.org/projects/trilium/server/zh_Hant/
2025-08-14 09:02:24 +00:00
Francis C
5ca0fbba13 Translated using Weblate (Chinese (Traditional Han script))
Currently translated at 100.0% (1550 of 1550 strings)

Translation: Trilium Notes/Client
Translate-URL: https://hosted.weblate.org/projects/trilium/client/zh_Hant/
2025-08-14 09:02:22 +00:00
Elian Doran
4cd84b2019 Translated using Weblate (Romanian)
Currently translated at 99.0% (1536 of 1550 strings)

Translation: Trilium Notes/Client
Translate-URL: https://hosted.weblate.org/projects/trilium/client/ro/
2025-08-14 09:02:19 +00:00
Marcelo Popper Costa
c502a45cf5 Translated using Weblate (Portuguese (Brazil))
Currently translated at 22.3% (346 of 1550 strings)

Translation: Trilium Notes/Client
Translate-URL: https://hosted.weblate.org/projects/trilium/client/pt_BR/
2025-08-14 09:02:17 +00:00
Francis C
9e66914306 Translated using Weblate (Chinese (Simplified Han script))
Currently translated at 100.0% (1550 of 1550 strings)

Translation: Trilium Notes/Client
Translate-URL: https://hosted.weblate.org/projects/trilium/client/zh_Hans/
2025-08-14 09:02:15 +00:00
Elian Doran
d33d27ee82 feat(forge): validate rpm signing 2025-08-14 11:45:59 +03:00
Elian Doran
e2b13573ae feat(forge): rpm signing 2025-08-14 10:43:38 +03:00
Elian Doran
ec74f5f1de feat(logs): provide an option to keep all logs (#6644) 2025-08-14 08:51:46 +03:00
Elian Doran
5dee56debc Add Traditional Chinese translation for README file & fix Docker Hub URL (#6645) 2025-08-14 08:43:08 +03:00
Francis C.
5623fc992d Update README-ZH_TW.md (tiny fix) 2025-08-14 12:04:45 +08:00
Francis C.
1d28bfc570 Update README-ZH_TW.md (tiny fix) 2025-08-14 11:25:53 +08:00
Francis C.
084327e973 Revise some words for Simplified Chinese translation 2025-08-14 11:20:32 +08:00
Francis C.
b2885efdc1 Update README-ZH_CN.md 2025-08-14 10:56:54 +08:00
Francis C.
b65a75f138 fix relative path for URLs 2025-08-14 10:27:41 +08:00
Francis C.
0ee7f50bb4 Move readme to docs folder 2025-08-14 10:15:03 +08:00
Francis C.
02ce21bc18 Add readme file translation for Traditional Chinese & fix Docker Hub URL 2025-08-14 10:12:14 +08:00
Romain DEP.
3ba487bb00 feat(logs): provide an option to keep all logs 2025-08-13 23:35:31 +02:00
55 changed files with 7797 additions and 19623 deletions

View File

@@ -162,3 +162,25 @@ runs:
echo "Found ZIP: $zip_file"
echo "Note: ZIP files are not code signed, but their contents should be"
fi
- name: Sign the RPM
if: inputs.os == 'linux'
shell: ${{ inputs.shell }}
run: |
echo -n "$GPG_SIGNING_KEY" | base64 --decode | gpg --import
# Import the key into RPM for verification
gpg --export -a > pubkey
rpm --import pubkey
rm pubkey
# Sign the RPM
rpm_file=$(find ./apps/desktop/upload -name "*.rpm" -print -quit)
rpmsign --define "_gpg_name Trilium Notes Signing Key <triliumnotes@outlook.com>" --addsign "$rpm_file"
rpm -Kv "$rpm_file"
# Validate code signing
if ! rpm -K "$rpm_file" | grep -q "digests signatures OK"; then
echo .rpm file not signed
exit 1
fi

View File

@@ -76,6 +76,7 @@ jobs:
APPLE_ID: ${{ secrets.APPLE_ID }}
APPLE_ID_PASSWORD: ${{ secrets.APPLE_ID_PASSWORD }}
WINDOWS_SIGN_EXECUTABLE: ${{ vars.WINDOWS_SIGN_EXECUTABLE }}
GPG_SIGNING_KEY: ${{ secrets.GPG_SIGN_KEY }}
- name: Publish release
uses: softprops/action-gh-release@v2.3.2

View File

@@ -58,6 +58,7 @@ jobs:
APPLE_ID: ${{ secrets.APPLE_ID }}
APPLE_ID_PASSWORD: ${{ secrets.APPLE_ID_PASSWORD }}
WINDOWS_SIGN_EXECUTABLE: ${{ vars.WINDOWS_SIGN_EXECUTABLE }}
GPG_SIGNING_KEY: ${{ secrets.GPG_SIGN_KEY }}
- name: Upload the artifact
uses: actions/upload-artifact@v4

View File

@@ -5,7 +5,7 @@
![GitHub Downloads (all assets, all releases)](https://img.shields.io/github/downloads/triliumnext/notes/total)
[![RelativeCI](https://badges.relative-ci.com/badges/Di5q7dz9daNDZ9UXi0Bp?branch=develop)](https://app.relative-ci.com/projects/Di5q7dz9daNDZ9UXi0Bp) [![Translation status](https://hosted.weblate.org/widget/trilium/svg-badge.svg)](https://hosted.weblate.org/engage/trilium/)
[English](./README.md) | [Chinese](./docs/README-ZH_CN.md) | [Russian](./docs/README.ru.md) | [Japanese](./docs/README.ja.md) | [Italian](./docs/README.it.md) | [Spanish](./docs/README.es.md)
[English](./README.md) | [Chinese (Simplified)](./docs/README-ZH_CN.md) | [Chinese (Traditional)](./docs/README-ZH_TW.md) | [Russian](./docs/README.ru.md) | [Japanese](./docs/README.ja.md) | [Italian](./docs/README.it.md) | [Spanish](./docs/README.es.md)
Trilium Notes is a free and open-source, cross-platform hierarchical note taking application with focus on building large personal knowledge bases.
@@ -110,7 +110,7 @@ See issue https://github.com/TriliumNext/Notes/issues/72 for more information on
### Server
To install TriliumNext on your own server (including via Docker from [Dockerhub](https://hub.docker.com/r/triliumnext/notes)) follow [the server installation docs](https://triliumnext.github.io/Docs/Wiki/server-installation).
To install TriliumNext on your own server (including via Docker from [Dockerhub](https://hub.docker.com/r/triliumnext/trilium)) follow [the server installation docs](https://triliumnext.github.io/Docs/Wiki/server-installation).
## 💻 Contribute

View File

@@ -55,8 +55,7 @@
"split.js": "1.6.5",
"svg-pan-zoom": "3.6.2",
"tabulator-tables": "6.3.1",
"vanilla-js-wheel-zoom": "9.0.4",
"photoswipe": "^5.4.4"
"vanilla-js-wheel-zoom": "9.0.4"
},
"devDependencies": {
"@ckeditor/ckeditor5-inspector": "5.0.0",

View File

@@ -13,8 +13,6 @@ import type ElectronRemote from "@electron/remote";
import type Electron from "electron";
import "./stylesheets/bootstrap.scss";
import "boxicons/css/boxicons.min.css";
import "./stylesheets/media-viewer.css";
import "./styles/gallery.css";
import "autocomplete.js/index_jquery.js";
await appContext.earlyInit();

View File

@@ -2,8 +2,6 @@ import { t } from "../services/i18n.js";
import utils from "../services/utils.js";
import contextMenu from "./context_menu.js";
import imageService from "../services/image.js";
import mediaViewer from "../services/media_viewer.js";
import type { MediaItem } from "../services/media_viewer.js";
const PROP_NAME = "imageContextMenuInstalled";
@@ -20,12 +18,6 @@ function setupContextMenu($image: JQuery<HTMLElement>) {
x: e.pageX,
y: e.pageY,
items: [
{
title: "View in Lightbox",
command: "viewInLightbox",
uiIcon: "bx bx-expand",
enabled: true
},
{
title: t("image_context_menu.copy_reference_to_clipboard"),
command: "copyImageReferenceToClipboard",
@@ -38,48 +30,7 @@ function setupContextMenu($image: JQuery<HTMLElement>) {
}
],
selectMenuItemHandler: async ({ command }) => {
if (command === "viewInLightbox") {
const src = $image.attr("src");
const alt = $image.attr("alt");
const title = $image.attr("title");
if (!src) {
console.error("Missing image source");
return;
}
const item: MediaItem = {
src: src,
alt: alt || "Image",
title: title || alt,
element: $image[0] as HTMLElement
};
// Try to get actual dimensions
const imgElement = $image[0] as HTMLImageElement;
if (imgElement.naturalWidth && imgElement.naturalHeight) {
item.width = imgElement.naturalWidth;
item.height = imgElement.naturalHeight;
}
mediaViewer.openSingle(item, {
bgOpacity: 0.95,
showHideOpacity: true,
pinchToClose: true,
closeOnScroll: false,
closeOnVerticalDrag: true,
wheelToZoom: true,
getThumbBoundsFn: () => {
// Get position for zoom animation
const rect = imgElement.getBoundingClientRect();
return {
x: rect.left,
y: rect.top,
w: rect.width
};
}
});
} else if (command === "copyImageReferenceToClipboard") {
if (command === "copyImageReferenceToClipboard") {
imageService.copyImageReferenceToClipboard($image);
} else if (command === "copyImageToClipboard") {
try {

View File

@@ -3,7 +3,6 @@ import noteAutocompleteService from "./services/note_autocomplete.js";
import glob from "./services/glob.js";
import "./stylesheets/bootstrap.scss";
import "boxicons/css/boxicons.min.css";
import "./stylesheets/media-viewer.css";
import "autocomplete.js/index_jquery.js";
glob.setupGlobs();

View File

@@ -1,521 +0,0 @@
/**
* CKEditor PhotoSwipe Integration
* Handles click-to-lightbox functionality for images in CKEditor content
*/
import mediaViewer from './media_viewer.js';
import galleryManager from './gallery_manager.js';
import appContext from '../components/app_context.js';
import type { MediaItem } from './media_viewer.js';
import type { GalleryItem } from './gallery_manager.js';
/**
* Configuration for CKEditor PhotoSwipe integration
*/
interface CKEditorPhotoSwipeConfig {
enableGalleryMode?: boolean;
showHints?: boolean;
hintDelay?: number;
excludeSelector?: string;
}
/**
* Integration manager for CKEditor and PhotoSwipe
*/
class CKEditorPhotoSwipeIntegration {
private static instance: CKEditorPhotoSwipeIntegration;
private config: Required<CKEditorPhotoSwipeConfig>;
private observers: Map<HTMLElement, MutationObserver> = new Map();
private processedImages: WeakSet<HTMLImageElement> = new WeakSet();
private containerGalleries: Map<HTMLElement, GalleryItem[]> = new Map();
private hintPool: HTMLElement[] = [];
private activeHints: Map<string, HTMLElement> = new Map();
private hintTimeouts: Map<string, number> = new Map();
private constructor() {
this.config = {
enableGalleryMode: true,
showHints: true,
hintDelay: 2000,
excludeSelector: '.no-lightbox, .cke_widget_element'
};
}
/**
* Get singleton instance
*/
static getInstance(): CKEditorPhotoSwipeIntegration {
if (!CKEditorPhotoSwipeIntegration.instance) {
CKEditorPhotoSwipeIntegration.instance = new CKEditorPhotoSwipeIntegration();
}
return CKEditorPhotoSwipeIntegration.instance;
}
/**
* Setup integration for a CKEditor content container
*/
setupContainer(container: HTMLElement | JQuery<HTMLElement>, config?: Partial<CKEditorPhotoSwipeConfig>): void {
const element = container instanceof $ ? container[0] : container;
if (!element) return;
// Merge configuration
if (config) {
this.config = { ...this.config, ...config };
}
// Process existing images
this.processImages(element);
// Setup mutation observer for dynamically added images
this.observeContainer(element);
// Setup gallery if enabled
if (this.config.enableGalleryMode) {
this.setupGalleryMode(element);
}
}
/**
* Process all images in a container
*/
private processImages(container: HTMLElement): void {
const images = container.querySelectorAll<HTMLImageElement>(`img:not(${this.config.excludeSelector})`);
images.forEach(img => {
if (!this.processedImages.has(img)) {
this.setupImageLightbox(img);
this.processedImages.add(img);
}
});
}
/**
* Setup lightbox for a single image
*/
private setupImageLightbox(img: HTMLImageElement): void {
// Skip if already processed or is a CKEditor widget element
if (img.closest('.cke_widget_element') || img.closest('.ck-widget')) {
return;
}
// Make image clickable and mark it as PhotoSwipe-enabled
img.style.cursor = 'zoom-in';
img.style.transition = 'opacity 0.2s';
img.classList.add('photoswipe-enabled');
img.setAttribute('data-photoswipe', 'true');
// Store event handlers for cleanup
const mouseEnterHandler = () => {
img.style.opacity = '0.9';
if (this.config.showHints) {
this.showHint(img);
}
};
const mouseLeaveHandler = () => {
img.style.opacity = '1';
this.hideHint(img);
};
// Add hover effect with cleanup tracking
img.addEventListener('mouseenter', mouseEnterHandler);
img.addEventListener('mouseleave', mouseLeaveHandler);
// Store handlers for cleanup
(img as any)._photoswipeHandlers = { mouseEnterHandler, mouseLeaveHandler };
// Add double-click handler to prevent default navigation behavior
const dblClickHandler = (e: MouseEvent) => {
// Only prevent double-click in specific contexts to avoid breaking other features
if (img.closest('.attachment-detail-wrapper') ||
img.closest('.note-detail-editable-text') ||
img.closest('.note-detail-readonly-text')) {
e.preventDefault();
e.stopPropagation();
e.stopImmediatePropagation();
// Trigger the same behavior as single click (open lightbox)
img.click();
}
};
img.addEventListener('dblclick', dblClickHandler, true); // Use capture phase to ensure we get it first
(img as any)._photoswipeHandlers.dblClickHandler = dblClickHandler;
// Add click handler
img.addEventListener('click', (e) => {
e.preventDefault();
e.stopPropagation();
// Check if we should open as gallery
const container = img.closest('.note-detail-editable-text, .note-detail-readonly-text');
if (container && this.config.enableGalleryMode) {
const gallery = this.containerGalleries.get(container as HTMLElement);
if (gallery && gallery.length > 1) {
// Find index of clicked image
const index = gallery.findIndex(item => {
const itemElement = document.querySelector(`img[src="${item.src}"]`);
return itemElement === img;
});
galleryManager.openGallery(gallery, index >= 0 ? index : 0, {
showThumbnails: true,
showCounter: true,
enableKeyboardNav: true,
loop: true
});
return;
}
}
// Open single image
this.openSingleImage(img);
});
// Add keyboard support
img.setAttribute('tabindex', '0');
img.setAttribute('role', 'button');
img.setAttribute('aria-label', 'Click to view in lightbox');
img.addEventListener('keydown', (e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
img.click();
}
});
}
/**
* Open a single image in lightbox
*/
private openSingleImage(img: HTMLImageElement): void {
const item: MediaItem = {
src: img.src,
alt: img.alt || 'Image',
title: img.title || img.alt,
element: img,
width: img.naturalWidth || undefined,
height: img.naturalHeight || undefined
};
mediaViewer.openSingle(item, {
bgOpacity: 0.95,
showHideOpacity: true,
pinchToClose: true,
closeOnScroll: false,
closeOnVerticalDrag: true,
wheelToZoom: true,
getThumbBoundsFn: () => {
const rect = img.getBoundingClientRect();
return {
x: rect.left,
y: rect.top,
w: rect.width
};
}
}, {
onClose: () => {
// Check if we're in attachment detail view and need to reset viewScope
const activeContext = appContext.tabManager.getActiveContext();
if (activeContext?.viewScope?.viewMode === 'attachments') {
// Get the note ID from the image source
const attachmentMatch = img.src.match(/\/api\/attachments\/([A-Za-z0-9_]+)\/image\//);
if (attachmentMatch) {
const currentAttachmentId = activeContext.viewScope.attachmentId;
if (currentAttachmentId === attachmentMatch[1]) {
// Actually reset the viewScope instead of just logging
try {
if (activeContext.note) {
activeContext.setNote(activeContext.note.noteId, {
viewScope: { viewMode: 'default' }
});
}
} catch (error) {
console.error('Failed to reset viewScope after PhotoSwipe close:', error);
}
}
}
}
// Restore focus to the image
img.focus();
}
});
}
/**
* Setup gallery mode for a container
*/
private setupGalleryMode(container: HTMLElement): void {
const images = container.querySelectorAll<HTMLImageElement>(`img:not(${this.config.excludeSelector})`);
if (images.length <= 1) return;
const galleryItems: GalleryItem[] = [];
images.forEach((img, index) => {
// Skip CKEditor widget elements
if (img.closest('.cke_widget_element') || img.closest('.ck-widget')) {
return;
}
const item: GalleryItem = {
src: img.src,
alt: img.alt || `Image ${index + 1}`,
title: img.title || img.alt,
element: img,
index: index,
width: img.naturalWidth || undefined,
height: img.naturalHeight || undefined
};
// Check for caption
const figure = img.closest('figure');
if (figure) {
const caption = figure.querySelector('figcaption');
if (caption) {
item.caption = caption.textContent || undefined;
}
}
galleryItems.push(item);
});
if (galleryItems.length > 0) {
this.containerGalleries.set(container, galleryItems);
}
}
/**
* Observe container for dynamic changes
*/
private observeContainer(container: HTMLElement): void {
// Disconnect existing observer if any
const existingObserver = this.observers.get(container);
if (existingObserver) {
existingObserver.disconnect();
}
const observer = new MutationObserver((mutations) => {
let hasNewImages = false;
mutations.forEach(mutation => {
if (mutation.type === 'childList') {
mutation.addedNodes.forEach(node => {
if (node.nodeType === Node.ELEMENT_NODE) {
const element = node as HTMLElement;
if (element.tagName === 'IMG') {
hasNewImages = true;
} else if (element.querySelector('img')) {
hasNewImages = true;
}
}
});
}
});
if (hasNewImages) {
// Process new images
this.processImages(container);
// Update gallery if enabled
if (this.config.enableGalleryMode) {
this.setupGalleryMode(container);
}
}
});
observer.observe(container, {
childList: true,
subtree: true
});
this.observers.set(container, observer);
}
/**
* Get or create a hint element from the pool
*/
private getHintFromPool(): HTMLElement {
let hint = this.hintPool.pop();
if (!hint) {
hint = document.createElement('div');
hint.className = 'ckeditor-image-hint';
hint.textContent = 'Click to view in lightbox';
hint.style.cssText = `
position: absolute;
background: rgba(0, 0, 0, 0.8);
color: white;
padding: 4px 8px;
border-radius: 4px;
font-size: 12px;
z-index: 1000;
pointer-events: none;
opacity: 0;
transition: opacity 0.3s;
display: none;
`;
}
return hint;
}
/**
* Return hint to pool
*/
private returnHintToPool(hint: HTMLElement): void {
hint.style.opacity = '0';
hint.style.display = 'none';
if (this.hintPool.length < 10) { // Keep max 10 hints in pool
this.hintPool.push(hint);
} else {
hint.remove();
}
}
/**
* Show hint for an image
*/
private showHint(img: HTMLImageElement): void {
// Check if hint already exists
const imgId = img.dataset.imgId || `img-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
if (!img.dataset.imgId) {
img.dataset.imgId = imgId;
}
// Clear any existing timeout
const existingTimeout = this.hintTimeouts.get(imgId);
if (existingTimeout) {
clearTimeout(existingTimeout);
this.hintTimeouts.delete(imgId);
}
let hint = this.activeHints.get(imgId);
if (hint) {
hint.style.opacity = '1';
return;
}
// Get hint from pool
hint = this.getHintFromPool();
this.activeHints.set(imgId, hint);
// Position and show hint
if (!hint.parentElement) {
document.body.appendChild(hint);
}
const imgRect = img.getBoundingClientRect();
hint.style.display = 'block';
hint.style.left = `${imgRect.left + (imgRect.width - hint.offsetWidth) / 2}px`;
hint.style.top = `${imgRect.top - hint.offsetHeight - 5}px`;
// Show hint
requestAnimationFrame(() => {
hint.style.opacity = '1';
});
// Auto-hide after delay
const timeout = window.setTimeout(() => {
this.hideHint(img);
}, this.config.hintDelay);
this.hintTimeouts.set(imgId, timeout);
}
/**
* Hide hint for an image
*/
private hideHint(img: HTMLImageElement): void {
const imgId = img.dataset.imgId;
if (!imgId) return;
// Clear timeout
const timeout = this.hintTimeouts.get(imgId);
if (timeout) {
clearTimeout(timeout);
this.hintTimeouts.delete(imgId);
}
const hint = this.activeHints.get(imgId);
if (hint) {
hint.style.opacity = '0';
this.activeHints.delete(imgId);
setTimeout(() => {
this.returnHintToPool(hint);
}, 300);
}
}
/**
* Cleanup integration for a container
*/
cleanupContainer(container: HTMLElement | JQuery<HTMLElement>): void {
const element = container instanceof $ ? container[0] : container;
if (!element) return;
// Disconnect observer
const observer = this.observers.get(element);
if (observer) {
observer.disconnect();
this.observers.delete(element);
}
// Clear gallery
this.containerGalleries.delete(element);
// Remove event handlers and hints
const images = element.querySelectorAll<HTMLImageElement>('img');
images.forEach(img => {
this.hideHint(img);
// Remove event handlers
const handlers = (img as any)._photoswipeHandlers;
if (handlers) {
img.removeEventListener('mouseenter', handlers.mouseEnterHandler);
img.removeEventListener('mouseleave', handlers.mouseLeaveHandler);
if (handlers.dblClickHandler) {
img.removeEventListener('dblclick', handlers.dblClickHandler, true);
}
delete (img as any)._photoswipeHandlers;
}
// Mark as unprocessed
this.processedImages.delete(img);
});
}
/**
* Update configuration
*/
updateConfig(config: Partial<CKEditorPhotoSwipeConfig>): void {
this.config = { ...this.config, ...config };
}
/**
* Cleanup all integrations
*/
cleanup(): void {
// Disconnect all observers
this.observers.forEach(observer => observer.disconnect());
this.observers.clear();
// Clear all galleries
this.containerGalleries.clear();
// Clear all hints
this.activeHints.forEach(hint => hint.remove());
this.activeHints.clear();
// Clear all timeouts
this.hintTimeouts.forEach(timeout => clearTimeout(timeout));
this.hintTimeouts.clear();
// Clear hint pool
this.hintPool.forEach(hint => hint.remove());
this.hintPool = [];
// Clear processed images
this.processedImages = new WeakSet();
}
}
// Export singleton instance
export default CKEditorPhotoSwipeIntegration.getInstance();

View File

@@ -1,387 +0,0 @@
/**
* Tests for Gallery Manager
*/
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import galleryManager from './gallery_manager';
import mediaViewer from './media_viewer';
import type { GalleryItem, GalleryConfig } from './gallery_manager';
import type { MediaViewerCallbacks } from './media_viewer';
// Mock media viewer
vi.mock('./media_viewer', () => ({
default: {
open: vi.fn(),
openSingle: vi.fn(),
close: vi.fn(),
next: vi.fn(),
prev: vi.fn(),
goTo: vi.fn(),
getCurrentIndex: vi.fn(() => 0),
isOpen: vi.fn(() => false),
getImageDimensions: vi.fn(() => Promise.resolve({ width: 800, height: 600 }))
}
}));
// Mock froca
vi.mock('./froca', () => ({
default: {
getNoteComplement: vi.fn()
}
}));
// Mock utils
vi.mock('./utils', () => ({
default: {
createImageSrcUrl: vi.fn((note: any) => `/api/images/${note.noteId}`),
randomString: vi.fn(() => 'test123')
}
}));
describe('GalleryManager', () => {
let mockItems: GalleryItem[];
beforeEach(() => {
// Reset mocks
vi.clearAllMocks();
// Create mock gallery items
mockItems = [
{
src: '/api/images/note1/image1.jpg',
alt: 'Image 1',
title: 'First Image',
noteId: 'note1',
index: 0,
width: 800,
height: 600
},
{
src: '/api/images/note1/image2.jpg',
alt: 'Image 2',
title: 'Second Image',
noteId: 'note1',
index: 1,
width: 1024,
height: 768
},
{
src: '/api/images/note1/image3.jpg',
alt: 'Image 3',
title: 'Third Image',
noteId: 'note1',
index: 2,
width: 1920,
height: 1080
}
];
// Setup DOM
document.body.innerHTML = '';
});
afterEach(() => {
// Cleanup
galleryManager.cleanup();
document.body.innerHTML = '';
});
describe('Gallery Creation', () => {
it('should create gallery from container with images', async () => {
// Create container with images
const container = document.createElement('div');
container.innerHTML = `
<img src="/api/images/note1/image1.jpg" alt="Image 1" />
<img src="/api/images/note1/image2.jpg" alt="Image 2" />
<img src="/api/images/note1/image3.jpg" alt="Image 3" />
`;
document.body.appendChild(container);
// Create gallery from container
const items = await galleryManager.createGalleryFromContainer(container);
expect(items).toHaveLength(3);
expect(items[0].src).toBe('/api/images/note1/image1.jpg');
expect(items[0].alt).toBe('Image 1');
expect(items[0].index).toBe(0);
});
it('should extract captions from figure elements', async () => {
const container = document.createElement('div');
container.innerHTML = `
<figure>
<img src="/api/images/note1/image1.jpg" alt="Image 1" />
<figcaption>This is a caption</figcaption>
</figure>
`;
document.body.appendChild(container);
const items = await galleryManager.createGalleryFromContainer(container);
expect(items).toHaveLength(1);
expect(items[0].caption).toBe('This is a caption');
});
it('should handle images without dimensions', async () => {
const container = document.createElement('div');
container.innerHTML = `<img src="/api/images/note1/image1.jpg" alt="Image 1" />`;
document.body.appendChild(container);
const items = await galleryManager.createGalleryFromContainer(container);
expect(items).toHaveLength(1);
expect(items[0].width).toBe(800); // From mocked getImageDimensions
expect(items[0].height).toBe(600);
expect(mediaViewer.getImageDimensions).toHaveBeenCalledWith('/api/images/note1/image1.jpg');
});
});
describe('Gallery Opening', () => {
it('should open gallery with multiple items', () => {
const callbacks: MediaViewerCallbacks = {
onOpen: vi.fn(),
onClose: vi.fn(),
onChange: vi.fn()
};
galleryManager.openGallery(mockItems, 0, {}, callbacks);
expect(mediaViewer.open).toHaveBeenCalledWith(
mockItems,
0,
expect.objectContaining({
loop: true,
allowPanToNext: true,
preload: [2, 2]
}),
expect.objectContaining({
onOpen: expect.any(Function),
onClose: expect.any(Function),
onChange: expect.any(Function)
})
);
});
it('should handle empty items array', () => {
galleryManager.openGallery([], 0);
expect(mediaViewer.open).not.toHaveBeenCalled();
});
it('should apply custom configuration', () => {
const config: GalleryConfig = {
showThumbnails: false,
autoPlay: true,
slideInterval: 5000,
loop: false
};
galleryManager.openGallery(mockItems, 0, config);
expect(mediaViewer.open).toHaveBeenCalledWith(
mockItems,
0,
expect.objectContaining({
loop: false
}),
expect.any(Object)
);
});
});
describe('Gallery Navigation', () => {
beforeEach(() => {
// Open a gallery first
galleryManager.openGallery(mockItems, 0);
});
it('should navigate to next slide', () => {
galleryManager.nextSlide();
expect(mediaViewer.next).toHaveBeenCalled();
});
it('should navigate to previous slide', () => {
galleryManager.previousSlide();
expect(mediaViewer.prev).toHaveBeenCalled();
});
it('should go to specific slide', () => {
galleryManager.goToSlide(2);
expect(mediaViewer.goTo).toHaveBeenCalledWith(2);
});
it('should not navigate to invalid slide index', () => {
const state = galleryManager.getGalleryState();
if (state) {
// Try to go to invalid index
galleryManager.goToSlide(-1);
expect(mediaViewer.goTo).not.toHaveBeenCalled();
galleryManager.goToSlide(10);
expect(mediaViewer.goTo).not.toHaveBeenCalled();
}
});
});
describe('Slideshow Functionality', () => {
beforeEach(() => {
vi.useFakeTimers();
galleryManager.openGallery(mockItems, 0, { autoPlay: false });
});
afterEach(() => {
vi.useRealTimers();
});
it('should start slideshow', () => {
const state = galleryManager.getGalleryState();
expect(state?.isPlaying).toBe(false);
galleryManager.startSlideshow();
const updatedState = galleryManager.getGalleryState();
expect(updatedState?.isPlaying).toBe(true);
});
it('should stop slideshow', () => {
galleryManager.startSlideshow();
galleryManager.stopSlideshow();
const state = galleryManager.getGalleryState();
expect(state?.isPlaying).toBe(false);
});
it('should toggle slideshow', () => {
const initialState = galleryManager.getGalleryState();
expect(initialState?.isPlaying).toBe(false);
galleryManager.toggleSlideshow();
expect(galleryManager.getGalleryState()?.isPlaying).toBe(true);
galleryManager.toggleSlideshow();
expect(galleryManager.getGalleryState()?.isPlaying).toBe(false);
});
it('should advance slides automatically in slideshow', () => {
galleryManager.startSlideshow();
// Fast-forward time
vi.advanceTimersByTime(4000); // Default interval
expect(mediaViewer.goTo).toHaveBeenCalledWith(1);
});
it('should update slideshow interval', () => {
galleryManager.startSlideshow();
galleryManager.updateSlideshowInterval(5000);
const state = galleryManager.getGalleryState();
expect(state?.config.slideInterval).toBe(5000);
});
});
describe('Gallery State', () => {
it('should track gallery state', () => {
expect(galleryManager.getGalleryState()).toBeNull();
galleryManager.openGallery(mockItems, 1);
const state = galleryManager.getGalleryState();
expect(state).not.toBeNull();
expect(state?.items).toEqual(mockItems);
expect(state?.currentIndex).toBe(1);
});
it('should check if gallery is open', () => {
expect(galleryManager.isGalleryOpen()).toBe(false);
vi.mocked(mediaViewer.isOpen).mockReturnValue(true);
galleryManager.openGallery(mockItems, 0);
expect(galleryManager.isGalleryOpen()).toBe(true);
});
});
describe('Gallery Cleanup', () => {
it('should close gallery on cleanup', () => {
galleryManager.openGallery(mockItems, 0);
galleryManager.cleanup();
expect(mediaViewer.close).toHaveBeenCalled();
expect(galleryManager.getGalleryState()).toBeNull();
});
it('should stop slideshow on close', () => {
galleryManager.openGallery(mockItems, 0, { autoPlay: true });
const state = galleryManager.getGalleryState();
expect(state?.isPlaying).toBe(true);
galleryManager.closeGallery();
expect(mediaViewer.close).toHaveBeenCalled();
});
});
describe('UI Enhancements', () => {
beforeEach(() => {
// Create PhotoSwipe container mock
const pswpElement = document.createElement('div');
pswpElement.className = 'pswp';
document.body.appendChild(pswpElement);
});
it('should add thumbnail strip when enabled', (done) => {
galleryManager.openGallery(mockItems, 0, { showThumbnails: true });
// Wait for UI setup
setTimeout(() => {
const thumbnailStrip = document.querySelector('.gallery-thumbnail-strip');
expect(thumbnailStrip).toBeTruthy();
const thumbnails = document.querySelectorAll('.gallery-thumbnail');
expect(thumbnails).toHaveLength(3);
done();
}, 150);
});
it('should add slideshow controls', (done) => {
galleryManager.openGallery(mockItems, 0);
setTimeout(() => {
const controls = document.querySelector('.gallery-slideshow-controls');
expect(controls).toBeTruthy();
const playPauseBtn = document.querySelector('.slideshow-play-pause');
expect(playPauseBtn).toBeTruthy();
done();
}, 150);
});
it('should add image counter when enabled', (done) => {
galleryManager.openGallery(mockItems, 0, { showCounter: true });
setTimeout(() => {
const counter = document.querySelector('.gallery-counter');
expect(counter).toBeTruthy();
expect(counter?.textContent).toContain('1');
expect(counter?.textContent).toContain('3');
done();
}, 150);
});
it('should add keyboard hints', (done) => {
galleryManager.openGallery(mockItems, 0);
setTimeout(() => {
const hints = document.querySelector('.gallery-keyboard-hints');
expect(hints).toBeTruthy();
expect(hints?.textContent).toContain('Navigate');
expect(hints?.textContent).toContain('ESC');
done();
}, 150);
});
});
});

View File

@@ -1,987 +0,0 @@
/**
* Gallery Manager for PhotoSwipe integration in Trilium Notes
* Handles multi-image galleries, slideshow mode, and navigation features
*/
import mediaViewer, { MediaItem, MediaViewerCallbacks, MediaViewerConfig } from './media_viewer.js';
import utils from './utils.js';
import froca from './froca.js';
import type FNote from '../entities/fnote.js';
/**
* Gallery configuration options
*/
export interface GalleryConfig {
showThumbnails?: boolean;
thumbnailHeight?: number;
autoPlay?: boolean;
slideInterval?: number; // in milliseconds
showCounter?: boolean;
enableKeyboardNav?: boolean;
enableSwipeGestures?: boolean;
preloadCount?: number;
loop?: boolean;
}
/**
* Gallery item with additional metadata
*/
export interface GalleryItem extends MediaItem {
noteId?: string;
attachmentId?: string;
caption?: string;
description?: string;
index?: number;
}
/**
* Gallery state management
*/
interface GalleryState {
items: GalleryItem[];
currentIndex: number;
isPlaying: boolean;
slideshowTimer?: number;
config: Required<GalleryConfig>;
}
/**
* GalleryManager handles multi-image galleries with slideshow and navigation features
*/
class GalleryManager {
private static instance: GalleryManager;
private currentGallery: GalleryState | null = null;
private defaultConfig: Required<GalleryConfig> = {
showThumbnails: true,
thumbnailHeight: 80,
autoPlay: false,
slideInterval: 4000,
showCounter: true,
enableKeyboardNav: true,
enableSwipeGestures: true,
preloadCount: 2,
loop: true
};
private slideshowCallbacks: Set<() => void> = new Set();
private $thumbnailStrip?: JQuery<HTMLElement>;
private $slideshowControls?: JQuery<HTMLElement>;
// Track all dynamically created elements for proper cleanup
private createdElements: Map<string, HTMLElement | JQuery<HTMLElement>> = new Map();
private setupTimeout?: number;
private constructor() {
// Cleanup on window unload
window.addEventListener('beforeunload', () => this.cleanup());
}
/**
* Get singleton instance
*/
static getInstance(): GalleryManager {
if (!GalleryManager.instance) {
GalleryManager.instance = new GalleryManager();
}
return GalleryManager.instance;
}
/**
* Create gallery from images in a note's content
*/
async createGalleryFromNote(note: FNote, config?: GalleryConfig): Promise<GalleryItem[]> {
const items: GalleryItem[] = [];
try {
// Parse note content to find images
const parser = new DOMParser();
const content = await note.getContent();
const doc = parser.parseFromString(content || '', 'text/html');
const images = doc.querySelectorAll('img');
for (let i = 0; i < images.length; i++) {
const img = images[i];
const src = img.getAttribute('src');
if (!src) continue;
// Convert relative URLs to absolute
const absoluteSrc = this.resolveImageSrc(src, note.noteId);
const item: GalleryItem = {
src: absoluteSrc,
alt: img.getAttribute('alt') || `Image ${i + 1} from ${note.title}`,
title: img.getAttribute('title') || img.getAttribute('alt') || undefined,
caption: img.getAttribute('data-caption') || undefined,
noteId: note.noteId,
index: i,
width: parseInt(img.getAttribute('width') || '0') || undefined,
height: parseInt(img.getAttribute('height') || '0') || undefined
};
// Try to get thumbnail from data attribute or create one
const thumbnailSrc = img.getAttribute('data-thumbnail');
if (thumbnailSrc) {
item.msrc = this.resolveImageSrc(thumbnailSrc, note.noteId);
}
items.push(item);
}
// Also check for image attachments
const attachmentItems = await this.getAttachmentImages(note);
items.push(...attachmentItems);
} catch (error) {
console.error('Failed to create gallery from note:', error);
}
return items;
}
/**
* Get image attachments from a note
*/
private async getAttachmentImages(note: FNote): Promise<GalleryItem[]> {
const items: GalleryItem[] = [];
try {
// Get child notes that are images
const childNotes = await note.getChildNotes();
for (const childNote of childNotes) {
if (childNote.type === 'image') {
const item: GalleryItem = {
src: utils.createImageSrcUrl(childNote),
alt: childNote.title,
title: childNote.title,
noteId: childNote.noteId,
index: items.length
};
items.push(item);
}
}
} catch (error) {
console.error('Failed to get attachment images:', error);
}
return items;
}
/**
* Create gallery from a container element with images
*/
async createGalleryFromContainer(
container: HTMLElement | JQuery<HTMLElement>,
selector: string = 'img',
config?: GalleryConfig
): Promise<GalleryItem[]> {
const $container = $(container);
const images = $container.find(selector);
const items: GalleryItem[] = [];
for (let i = 0; i < images.length; i++) {
const img = images[i] as HTMLImageElement;
const item: GalleryItem = {
src: img.src,
alt: img.alt || `Image ${i + 1}`,
title: img.title || img.alt || undefined,
element: img,
index: i,
width: img.naturalWidth || undefined,
height: img.naturalHeight || undefined
};
// Try to extract caption from nearby elements
const $img = $(img);
const $figure = $img.closest('figure');
if ($figure.length) {
const $caption = $figure.find('figcaption');
if ($caption.length) {
item.caption = $caption.text();
}
}
// Check for data attributes
item.noteId = $img.data('note-id');
item.attachmentId = $img.data('attachment-id');
items.push(item);
}
return items;
}
/**
* Open gallery with specified items
*/
openGallery(
items: GalleryItem[],
startIndex: number = 0,
config?: GalleryConfig,
callbacks?: MediaViewerCallbacks
): void {
if (!items || items.length === 0) {
console.warn('No items provided to gallery');
return;
}
// Close any existing gallery
this.closeGallery();
// Merge configuration
const finalConfig = { ...this.defaultConfig, ...config };
// Initialize gallery state
this.currentGallery = {
items,
currentIndex: startIndex,
isPlaying: finalConfig.autoPlay,
config: finalConfig
};
// Enhanced PhotoSwipe configuration for gallery
const photoSwipeConfig: Partial<MediaViewerConfig> = {
bgOpacity: 0.95,
showHideOpacity: true,
allowPanToNext: true,
spacing: 0.12,
loop: finalConfig.loop,
arrowKeys: finalConfig.enableKeyboardNav,
pinchToClose: finalConfig.enableSwipeGestures,
closeOnVerticalDrag: finalConfig.enableSwipeGestures,
preload: [finalConfig.preloadCount, finalConfig.preloadCount],
wheelToZoom: true,
// Enable mobile and accessibility enhancements
mobileA11y: {
touch: {
hapticFeedback: true,
multiTouchEnabled: true
},
a11y: {
enableKeyboardNav: finalConfig.enableKeyboardNav,
enableScreenReaderAnnouncements: true,
keyboardShortcutsEnabled: true
},
mobileUI: {
bottomSheetEnabled: true,
adaptiveToolbar: true,
swipeIndicators: true,
gestureHints: true
},
performance: {
adaptiveQuality: true,
batteryOptimization: true
}
}
};
// Enhanced callbacks
const enhancedCallbacks: MediaViewerCallbacks = {
onOpen: () => {
this.onGalleryOpen();
callbacks?.onOpen?.();
},
onClose: () => {
this.onGalleryClose();
callbacks?.onClose?.();
},
onChange: (index) => {
this.onSlideChange(index);
callbacks?.onChange?.(index);
},
onImageLoad: callbacks?.onImageLoad,
onImageError: callbacks?.onImageError
};
// Open with media viewer
mediaViewer.open(items, startIndex, photoSwipeConfig, enhancedCallbacks);
// Setup gallery UI enhancements
this.setupGalleryUI();
// Start slideshow if configured
if (finalConfig.autoPlay) {
this.startSlideshow();
}
}
/**
* Setup gallery UI enhancements
*/
private setupGalleryUI(): void {
if (!this.currentGallery) return;
// Clear any existing timeout
if (this.setupTimeout) {
clearTimeout(this.setupTimeout);
}
// Add gallery-specific UI elements to PhotoSwipe
this.setupTimeout = window.setTimeout(() => {
// Validate gallery is still open before manipulating DOM
if (!this.currentGallery || !this.isGalleryOpen()) {
return;
}
// PhotoSwipe needs a moment to initialize
const pswpElement = document.querySelector('.pswp');
if (!pswpElement) return;
// Add thumbnail strip if enabled
if (this.currentGallery.config.showThumbnails) {
this.addThumbnailStrip(pswpElement);
}
// Add slideshow controls
this.addSlideshowControls(pswpElement);
// Add image counter if enabled
if (this.currentGallery.config.showCounter) {
this.addImageCounter(pswpElement);
}
// Add keyboard hints
this.addKeyboardHints(pswpElement);
}, 100);
}
/**
* Add thumbnail strip navigation
*/
private addThumbnailStrip(container: Element): void {
if (!this.currentGallery) return;
// Create thumbnail strip container safely using DOM APIs
const stripDiv = document.createElement('div');
stripDiv.className = 'gallery-thumbnail-strip';
stripDiv.setAttribute('style', `
position: absolute;
bottom: 60px;
left: 50%;
transform: translateX(-50%);
display: flex;
gap: 8px;
padding: 10px;
background: rgba(0, 0, 0, 0.7);
border-radius: 8px;
max-width: 90%;
overflow-x: auto;
z-index: 100;
`);
// Create thumbnails safely
this.currentGallery.items.forEach((item, index) => {
const thumbDiv = document.createElement('div');
thumbDiv.className = 'gallery-thumbnail';
thumbDiv.dataset.index = index.toString();
thumbDiv.setAttribute('style', `
width: ${this.currentGallery!.config.thumbnailHeight}px;
height: ${this.currentGallery!.config.thumbnailHeight}px;
cursor: pointer;
border: 2px solid ${index === this.currentGallery!.currentIndex ? '#fff' : 'transparent'};
border-radius: 4px;
overflow: hidden;
flex-shrink: 0;
opacity: ${index === this.currentGallery!.currentIndex ? '1' : '0.6'};
transition: all 0.2s;
`);
const img = document.createElement('img');
// Sanitize src URLs
const src = this.sanitizeUrl(item.msrc || item.src);
img.src = src;
// Use textContent for safe text insertion
img.alt = this.sanitizeText(item.alt || '');
img.setAttribute('style', `
width: 100%;
height: 100%;
object-fit: cover;
`);
thumbDiv.appendChild(img);
stripDiv.appendChild(thumbDiv);
});
this.$thumbnailStrip = $(stripDiv);
$(container).append(this.$thumbnailStrip);
this.createdElements.set('thumbnailStrip', this.$thumbnailStrip);
// Handle thumbnail clicks
this.$thumbnailStrip.on('click', '.gallery-thumbnail', (e) => {
const index = parseInt($(e.currentTarget).data('index'));
this.goToSlide(index);
});
// Handle hover effect
this.$thumbnailStrip.on('mouseenter', '.gallery-thumbnail', (e) => {
if (!$(e.currentTarget).hasClass('active')) {
$(e.currentTarget).css('opacity', '0.8');
}
});
this.$thumbnailStrip.on('mouseleave', '.gallery-thumbnail', (e) => {
if (!$(e.currentTarget).hasClass('active')) {
$(e.currentTarget).css('opacity', '0.6');
}
});
}
/**
* Sanitize text content to prevent XSS
*/
private sanitizeText(text: string): string {
// Remove any HTML tags and entities
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
/**
* Sanitize URL to prevent XSS
*/
private sanitizeUrl(url: string): string {
// Only allow safe protocols
const allowedProtocols = ['http:', 'https:', 'data:'];
try {
const urlObj = new URL(url, window.location.href);
// Special validation for data URLs
if (urlObj.protocol === 'data:') {
// Only allow image MIME types for data URLs
const allowedImageTypes = [
'data:image/jpeg',
'data:image/jpg',
'data:image/png',
'data:image/gif',
'data:image/webp',
'data:image/svg+xml',
'data:image/bmp'
];
// Check if data URL starts with an allowed image type
const isAllowedImage = allowedImageTypes.some(type =>
url.toLowerCase().startsWith(type)
);
if (!isAllowedImage) {
console.warn('Rejected non-image data URL:', url.substring(0, 50));
return '';
}
// Additional check for base64 encoding
if (!url.includes(';base64,') && !url.includes(';charset=')) {
console.warn('Rejected data URL with invalid encoding');
return '';
}
} else if (!allowedProtocols.includes(urlObj.protocol)) {
return '';
}
return urlObj.href;
} catch {
// If URL parsing fails, check if it's a relative path
if (url.startsWith('/') || url.startsWith('api/')) {
return url;
}
return '';
}
}
/**
* Add slideshow controls
*/
private addSlideshowControls(container: Element): void {
if (!this.currentGallery) return;
const controlsHtml = `
<div class="gallery-slideshow-controls" style="
position: absolute;
top: 20px;
right: 20px;
display: flex;
gap: 10px;
z-index: 100;
">
<button class="slideshow-play-pause" style="
background: rgba(255, 255, 255, 0.9);
border: none;
border-radius: 4px;
width: 44px;
height: 44px;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
font-size: 20px;
" aria-label="${this.currentGallery.isPlaying ? 'Pause slideshow' : 'Play slideshow'}">
<i class="bx ${this.currentGallery.isPlaying ? 'bx-pause' : 'bx-play'}"></i>
</button>
<button class="slideshow-settings" style="
background: rgba(255, 255, 255, 0.9);
border: none;
border-radius: 4px;
width: 44px;
height: 44px;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
font-size: 20px;
" aria-label="Slideshow settings">
<i class="bx bx-cog"></i>
</button>
</div>
<div class="slideshow-interval-selector" style="
position: absolute;
top: 80px;
right: 20px;
background: rgba(0, 0, 0, 0.8);
color: white;
padding: 10px;
border-radius: 4px;
display: none;
z-index: 101;
">
<label style="display: block; margin-bottom: 5px;">Slide interval:</label>
<select class="interval-select" style="
background: rgba(255, 255, 255, 0.1);
color: white;
border: 1px solid rgba(255, 255, 255, 0.3);
padding: 4px;
border-radius: 3px;
">
<option value="3000">3 seconds</option>
<option value="4000" selected>4 seconds</option>
<option value="5000">5 seconds</option>
<option value="7000">7 seconds</option>
<option value="10000">10 seconds</option>
</select>
</div>
`;
this.$slideshowControls = $(controlsHtml);
$(container).append(this.$slideshowControls);
this.createdElements.set('slideshowControls', this.$slideshowControls);
// Handle play/pause button
this.$slideshowControls.find('.slideshow-play-pause').on('click', () => {
this.toggleSlideshow();
});
// Handle settings button
this.$slideshowControls.find('.slideshow-settings').on('click', () => {
const $selector = this.$slideshowControls?.find('.slideshow-interval-selector');
$selector?.toggle();
});
// Handle interval change
this.$slideshowControls.find('.interval-select').on('change', (e) => {
const interval = parseInt($(e.target).val() as string);
this.updateSlideshowInterval(interval);
});
}
/**
* Add image counter
*/
private addImageCounter(container: Element): void {
if (!this.currentGallery) return;
// Create counter element safely
const counterDiv = document.createElement('div');
counterDiv.className = 'gallery-counter';
counterDiv.setAttribute('style', `
position: absolute;
top: 20px;
left: 20px;
background: rgba(0, 0, 0, 0.7);
color: white;
padding: 8px 12px;
border-radius: 4px;
font-size: 14px;
z-index: 100;
`);
const currentSpan = document.createElement('span');
currentSpan.className = 'current-index';
currentSpan.textContent = String(this.currentGallery.currentIndex + 1);
const separatorSpan = document.createElement('span');
separatorSpan.textContent = ' / ';
const totalSpan = document.createElement('span');
totalSpan.className = 'total-count';
totalSpan.textContent = String(this.currentGallery.items.length);
counterDiv.appendChild(currentSpan);
counterDiv.appendChild(separatorSpan);
counterDiv.appendChild(totalSpan);
container.appendChild(counterDiv);
this.createdElements.set('counter', counterDiv);
}
/**
* Add keyboard hints overlay
*/
private addKeyboardHints(container: Element): void {
// Create hints element safely
const hintsDiv = document.createElement('div');
hintsDiv.className = 'gallery-keyboard-hints';
hintsDiv.setAttribute('style', `
position: absolute;
bottom: 20px;
left: 20px;
background: rgba(0, 0, 0, 0.7);
color: white;
padding: 8px 12px;
border-radius: 4px;
font-size: 12px;
opacity: 0;
transition: opacity 0.3s;
z-index: 100;
`);
// Create hint items
const hints = [
{ key: '←/→', action: 'Navigate' },
{ key: 'Space', action: 'Play/Pause' },
{ key: 'ESC', action: 'Close' }
];
hints.forEach(hint => {
const hintItem = document.createElement('div');
const kbd = document.createElement('kbd');
kbd.style.cssText = 'background: rgba(255,255,255,0.2); padding: 2px 4px; border-radius: 2px;';
kbd.textContent = hint.key;
hintItem.appendChild(kbd);
hintItem.appendChild(document.createTextNode(' ' + hint.action));
hintsDiv.appendChild(hintItem);
});
container.appendChild(hintsDiv);
this.createdElements.set('keyboardHints', hintsDiv);
const $hints = $(hintsDiv);
// Show hints on hover with scoped selector
const handleMouseEnter = () => {
if (this.currentGallery) {
$hints.css('opacity', '0.6');
}
};
const handleMouseLeave = () => {
$hints.css('opacity', '0');
};
$(container).on('mouseenter.galleryHints', handleMouseEnter);
$(container).on('mouseleave.galleryHints', handleMouseLeave);
// Track cleanup callback
this.slideshowCallbacks.add(() => {
$(container).off('.galleryHints');
});
}
/**
* Handle gallery open event
*/
private onGalleryOpen(): void {
// Add keyboard listener for slideshow control
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === ' ') {
e.preventDefault();
this.toggleSlideshow();
}
};
document.addEventListener('keydown', handleKeyDown);
this.slideshowCallbacks.add(() => {
document.removeEventListener('keydown', handleKeyDown);
});
}
/**
* Handle gallery close event
*/
private onGalleryClose(): void {
this.stopSlideshow();
// Clear setup timeout if exists
if (this.setupTimeout) {
clearTimeout(this.setupTimeout);
this.setupTimeout = undefined;
}
// Cleanup event listeners
this.slideshowCallbacks.forEach(callback => callback());
this.slideshowCallbacks.clear();
// Remove all tracked UI elements
this.createdElements.forEach((element, key) => {
if (element instanceof HTMLElement) {
element.remove();
} else if (element instanceof $) {
element.remove();
}
});
this.createdElements.clear();
// Clear jQuery references
this.$thumbnailStrip = undefined;
this.$slideshowControls = undefined;
// Clear state
this.currentGallery = null;
}
/**
* Handle slide change event
*/
private onSlideChange(index: number): void {
if (!this.currentGallery) return;
this.currentGallery.currentIndex = index;
// Update thumbnail highlighting
if (this.$thumbnailStrip) {
this.$thumbnailStrip.find('.gallery-thumbnail').each((i, el) => {
const $thumb = $(el);
if (i === index) {
$thumb.css({
'border-color': '#fff',
'opacity': '1'
});
// Scroll thumbnail into view
const thumbLeft = $thumb.position().left;
const thumbWidth = $thumb.outerWidth() || 0;
const stripWidth = this.$thumbnailStrip!.width() || 0;
const scrollLeft = this.$thumbnailStrip!.scrollLeft() || 0;
if (thumbLeft < 0) {
this.$thumbnailStrip!.scrollLeft(scrollLeft + thumbLeft - 10);
} else if (thumbLeft + thumbWidth > stripWidth) {
this.$thumbnailStrip!.scrollLeft(scrollLeft + (thumbLeft + thumbWidth - stripWidth) + 10);
}
} else {
$thumb.css({
'border-color': 'transparent',
'opacity': '0.6'
});
}
});
}
// Update counter using tracked element
const counterElement = this.createdElements.get('counter');
if (counterElement instanceof HTMLElement) {
const currentIndexElement = counterElement.querySelector('.current-index');
if (currentIndexElement) {
currentIndexElement.textContent = String(index + 1);
}
}
}
/**
* Start slideshow
*/
startSlideshow(): void {
if (!this.currentGallery || this.currentGallery.isPlaying) return;
// Validate gallery state before starting slideshow
if (!this.isGalleryOpen() || this.currentGallery.items.length === 0) {
console.warn('Cannot start slideshow: gallery not ready');
return;
}
// Ensure PhotoSwipe is ready
if (!mediaViewer.isOpen()) {
console.warn('Cannot start slideshow: PhotoSwipe not ready');
return;
}
this.currentGallery.isPlaying = true;
// Update button icon
this.$slideshowControls?.find('.slideshow-play-pause i')
.removeClass('bx-play')
.addClass('bx-pause');
// Start timer
this.scheduleNextSlide();
}
/**
* Stop slideshow
*/
stopSlideshow(): void {
if (!this.currentGallery) return;
this.currentGallery.isPlaying = false;
// Clear timer
if (this.currentGallery.slideshowTimer) {
clearTimeout(this.currentGallery.slideshowTimer);
this.currentGallery.slideshowTimer = undefined;
}
// Update button icon
this.$slideshowControls?.find('.slideshow-play-pause i')
.removeClass('bx-pause')
.addClass('bx-play');
}
/**
* Toggle slideshow play/pause
*/
toggleSlideshow(): void {
if (!this.currentGallery) return;
if (this.currentGallery.isPlaying) {
this.stopSlideshow();
} else {
this.startSlideshow();
}
}
/**
* Schedule next slide in slideshow
*/
private scheduleNextSlide(): void {
if (!this.currentGallery || !this.currentGallery.isPlaying) return;
// Clear any existing timer
if (this.currentGallery.slideshowTimer) {
clearTimeout(this.currentGallery.slideshowTimer);
}
this.currentGallery.slideshowTimer = window.setTimeout(() => {
if (!this.currentGallery || !this.currentGallery.isPlaying) return;
// Go to next slide
const nextIndex = (this.currentGallery.currentIndex + 1) % this.currentGallery.items.length;
this.goToSlide(nextIndex);
// Schedule next transition
this.scheduleNextSlide();
}, this.currentGallery.config.slideInterval);
}
/**
* Update slideshow interval
*/
updateSlideshowInterval(interval: number): void {
if (!this.currentGallery) return;
this.currentGallery.config.slideInterval = interval;
// Restart slideshow with new interval if playing
if (this.currentGallery.isPlaying) {
this.stopSlideshow();
this.startSlideshow();
}
}
/**
* Go to specific slide
*/
goToSlide(index: number): void {
if (!this.currentGallery) return;
if (index >= 0 && index < this.currentGallery.items.length) {
mediaViewer.goTo(index);
}
}
/**
* Navigate to next slide
*/
nextSlide(): void {
mediaViewer.next();
}
/**
* Navigate to previous slide
*/
previousSlide(): void {
mediaViewer.prev();
}
/**
* Close gallery
*/
closeGallery(): void {
mediaViewer.close();
}
/**
* Check if gallery is open
*/
isGalleryOpen(): boolean {
return this.currentGallery !== null && mediaViewer.isOpen();
}
/**
* Get current gallery state
*/
getGalleryState(): GalleryState | null {
return this.currentGallery;
}
/**
* Resolve image source URL
*/
private resolveImageSrc(src: string, noteId: string): string {
// Handle different image source formats
if (src.startsWith('http://') || src.startsWith('https://')) {
return src;
}
if (src.startsWith('api/images/')) {
return `/${src}`;
}
if (src.startsWith('/')) {
return src;
}
// Assume it's a note ID or attachment reference
return `/api/images/${noteId}/${src}`;
}
/**
* Cleanup resources
*/
cleanup(): void {
// Clear any pending timeouts
if (this.setupTimeout) {
clearTimeout(this.setupTimeout);
this.setupTimeout = undefined;
}
this.closeGallery();
// Ensure all elements are removed
this.createdElements.forEach((element) => {
if (element instanceof HTMLElement) {
element.remove();
} else if (element instanceof $) {
element.remove();
}
});
this.createdElements.clear();
this.slideshowCallbacks.clear();
this.currentGallery = null;
}
}
// Export singleton instance
export default GalleryManager.getInstance();

View File

@@ -1,597 +0,0 @@
/**
* Image Annotations Module for PhotoSwipe
* Provides ability to add, display, and manage annotations on images
*/
import froca from './froca.js';
import server from './server.js';
import type FNote from '../entities/fnote.js';
import type FAttribute from '../entities/fattribute.js';
import { ImageValidator, withErrorBoundary, ImageError, ImageErrorType } from './image_error_handler.js';
/**
* Annotation position and data
*/
export interface ImageAnnotation {
id: string;
noteId: string;
x: number; // Percentage from left (0-100)
y: number; // Percentage from top (0-100)
text: string;
author?: string;
created: Date;
modified?: Date;
color?: string;
icon?: string;
type?: 'comment' | 'marker' | 'region';
width?: number; // For region type
height?: number; // For region type
}
/**
* Annotation configuration
*/
export interface AnnotationConfig {
enableAnnotations: boolean;
showByDefault: boolean;
allowEditing: boolean;
defaultColor: string;
defaultIcon: string;
}
/**
* ImageAnnotationsService manages image annotations using Trilium's attribute system
*/
class ImageAnnotationsService {
private static instance: ImageAnnotationsService;
private activeAnnotations: Map<string, ImageAnnotation[]> = new Map();
private annotationElements: Map<string, HTMLElement> = new Map();
private isEditMode: boolean = false;
private selectedAnnotation: ImageAnnotation | null = null;
private config: AnnotationConfig = {
enableAnnotations: true,
showByDefault: true,
allowEditing: true,
defaultColor: '#ffeb3b',
defaultIcon: 'bx-comment'
};
// Annotation attribute prefix in Trilium
private readonly ANNOTATION_PREFIX = 'imageAnnotation';
private constructor() {}
static getInstance(): ImageAnnotationsService {
if (!ImageAnnotationsService.instance) {
ImageAnnotationsService.instance = new ImageAnnotationsService();
}
return ImageAnnotationsService.instance;
}
/**
* Load annotations for an image note
*/
async loadAnnotations(noteId: string): Promise<ImageAnnotation[]> {
return await withErrorBoundary(async () => {
// Validate note ID
if (!noteId || typeof noteId !== 'string') {
throw new ImageError(
ImageErrorType.INVALID_INPUT,
'Invalid note ID provided'
);
}
const note = await froca.getNote(noteId);
if (!note) return [];
const attributes = note.getAttributes();
const annotations: ImageAnnotation[] = [];
// Parse annotation attributes
for (const attr of attributes) {
if (attr.name.startsWith(this.ANNOTATION_PREFIX)) {
try {
const annotationData = JSON.parse(attr.value);
annotations.push({
...annotationData,
id: attr.attributeId,
noteId: noteId,
created: new Date(annotationData.created),
modified: annotationData.modified ? new Date(annotationData.modified) : undefined
});
} catch (error) {
console.error('Failed to parse annotation:', error);
}
}
}
// Sort by creation date
annotations.sort((a, b) => a.created.getTime() - b.created.getTime());
this.activeAnnotations.set(noteId, annotations);
return annotations;
}) || [];
}
/**
* Save a new annotation
*/
async saveAnnotation(annotation: Omit<ImageAnnotation, 'id' | 'created'>): Promise<ImageAnnotation> {
return await withErrorBoundary(async () => {
// Validate annotation data
if (!annotation.text || !annotation.noteId) {
throw new ImageError(
ImageErrorType.INVALID_INPUT,
'Invalid annotation data'
);
}
// Sanitize text
annotation.text = this.sanitizeText(annotation.text);
const note = await froca.getNote(annotation.noteId);
if (!note) {
throw new Error('Note not found');
}
const newAnnotation: ImageAnnotation = {
...annotation,
id: this.generateId(),
created: new Date()
};
// Save as note attribute
const attributeName = `${this.ANNOTATION_PREFIX}_${newAnnotation.id}`;
const attributeValue = JSON.stringify({
x: newAnnotation.x,
y: newAnnotation.y,
text: newAnnotation.text,
author: newAnnotation.author,
created: newAnnotation.created.toISOString(),
color: newAnnotation.color,
icon: newAnnotation.icon,
type: newAnnotation.type,
width: newAnnotation.width,
height: newAnnotation.height
});
await server.put(`notes/${annotation.noteId}/attributes`, {
attributes: [{
type: 'label',
name: attributeName,
value: attributeValue
}]
});
// Update cache
const annotations = this.activeAnnotations.get(annotation.noteId) || [];
annotations.push(newAnnotation);
this.activeAnnotations.set(annotation.noteId, annotations);
return newAnnotation;
}) as Promise<ImageAnnotation>;
}
/**
* Update an existing annotation
*/
async updateAnnotation(annotation: ImageAnnotation): Promise<void> {
try {
const note = await froca.getNote(annotation.noteId);
if (!note) {
throw new Error('Note not found');
}
annotation.modified = new Date();
// Update attribute
const attributeName = `${this.ANNOTATION_PREFIX}_${annotation.id}`;
const attributeValue = JSON.stringify({
x: annotation.x,
y: annotation.y,
text: annotation.text,
author: annotation.author,
created: annotation.created.toISOString(),
modified: annotation.modified.toISOString(),
color: annotation.color,
icon: annotation.icon,
type: annotation.type,
width: annotation.width,
height: annotation.height
});
// Find and update the attribute
const attributes = note.getAttributes();
const attr = attributes.find(a => a.name === attributeName);
if (attr) {
await server.put(`notes/${annotation.noteId}/attributes/${attr.attributeId}`, {
value: attributeValue
});
}
// Update cache
const annotations = this.activeAnnotations.get(annotation.noteId) || [];
const index = annotations.findIndex(a => a.id === annotation.id);
if (index !== -1) {
annotations[index] = annotation;
this.activeAnnotations.set(annotation.noteId, annotations);
}
} catch (error) {
console.error('Failed to update annotation:', error);
throw error;
}
}
/**
* Delete an annotation
*/
async deleteAnnotation(noteId: string, annotationId: string): Promise<void> {
try {
const note = await froca.getNote(noteId);
if (!note) return;
const attributeName = `${this.ANNOTATION_PREFIX}_${annotationId}`;
const attributes = note.getAttributes();
const attr = attributes.find(a => a.name === attributeName);
if (attr) {
await server.remove(`notes/${noteId}/attributes/${attr.attributeId}`);
}
// Update cache
const annotations = this.activeAnnotations.get(noteId) || [];
const filtered = annotations.filter(a => a.id !== annotationId);
this.activeAnnotations.set(noteId, filtered);
// Remove element if exists
const element = this.annotationElements.get(annotationId);
if (element) {
element.remove();
this.annotationElements.delete(annotationId);
}
} catch (error) {
console.error('Failed to delete annotation:', error);
throw error;
}
}
/**
* Render annotations on an image container
*/
renderAnnotations(container: HTMLElement, noteId: string, imageElement: HTMLImageElement): void {
const annotations = this.activeAnnotations.get(noteId) || [];
// Clear existing annotation elements
this.clearAnnotationElements();
// Create annotation overlay container
const overlay = this.createOverlayContainer(container, imageElement);
// Render each annotation
annotations.forEach(annotation => {
const element = this.createAnnotationElement(annotation, overlay);
this.annotationElements.set(annotation.id, element);
});
// Add click handler for creating new annotations
if (this.config.allowEditing && this.isEditMode) {
this.setupAnnotationCreation(overlay, noteId);
}
// Add ARIA attributes for accessibility
overlay.setAttribute('role', 'img');
overlay.setAttribute('aria-label', 'Image with annotations');
}
/**
* Create overlay container for annotations
*/
private createOverlayContainer(container: HTMLElement, imageElement: HTMLImageElement): HTMLElement {
let overlay = container.querySelector('.annotation-overlay') as HTMLElement;
if (!overlay) {
overlay = document.createElement('div');
overlay.className = 'annotation-overlay';
overlay.style.cssText = `
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
pointer-events: ${this.isEditMode ? 'auto' : 'none'};
z-index: 10;
`;
// Position overlay over the image
const rect = imageElement.getBoundingClientRect();
const containerRect = container.getBoundingClientRect();
overlay.style.top = `${rect.top - containerRect.top}px`;
overlay.style.left = `${rect.left - containerRect.left}px`;
overlay.style.width = `${rect.width}px`;
overlay.style.height = `${rect.height}px`;
container.appendChild(overlay);
}
return overlay;
}
/**
* Create annotation element
*/
private createAnnotationElement(annotation: ImageAnnotation, container: HTMLElement): HTMLElement {
const element = document.createElement('div');
element.className = `annotation-marker annotation-${annotation.type || 'comment'}`;
element.dataset.annotationId = annotation.id;
// Position based on percentage
element.style.cssText = `
position: absolute;
left: ${annotation.x}%;
top: ${annotation.y}%;
transform: translate(-50%, -50%);
cursor: pointer;
z-index: 20;
pointer-events: auto;
`;
// Create marker based on type
if (annotation.type === 'region') {
// Region annotation
element.style.cssText += `
width: ${annotation.width || 20}%;
height: ${annotation.height || 20}%;
border: 2px solid ${annotation.color || this.config.defaultColor};
background: ${annotation.color || this.config.defaultColor}33;
border-radius: 4px;
`;
} else {
// Point annotation
const marker = document.createElement('div');
marker.style.cssText = `
width: 24px;
height: 24px;
background: ${annotation.color || this.config.defaultColor};
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
box-shadow: 0 2px 4px rgba(0,0,0,0.2);
`;
const icon = document.createElement('i');
icon.className = `bx ${annotation.icon || this.config.defaultIcon}`;
icon.style.cssText = `
color: #333;
font-size: 14px;
`;
marker.appendChild(icon);
element.appendChild(marker);
// Add ARIA attributes for accessibility
element.setAttribute('role', 'button');
element.setAttribute('aria-label', `Annotation: ${this.sanitizeText(annotation.text)}`);
element.setAttribute('tabindex', '0');
}
// Add tooltip
const tooltip = document.createElement('div');
tooltip.className = 'annotation-tooltip';
tooltip.style.cssText = `
position: absolute;
bottom: 100%;
left: 50%;
transform: translateX(-50%);
background: rgba(0,0,0,0.9);
color: white;
padding: 8px 12px;
border-radius: 4px;
font-size: 12px;
white-space: nowrap;
max-width: 200px;
pointer-events: none;
opacity: 0;
transition: opacity 0.2s;
margin-bottom: 8px;
`;
// Use textContent to prevent XSS
tooltip.textContent = this.sanitizeText(annotation.text);
element.appendChild(tooltip);
// Show tooltip on hover
element.addEventListener('mouseenter', () => {
tooltip.style.opacity = '1';
});
element.addEventListener('mouseleave', () => {
tooltip.style.opacity = '0';
});
// Handle click for editing
element.addEventListener('click', (e) => {
e.stopPropagation();
this.selectAnnotation(annotation);
});
container.appendChild(element);
return element;
}
/**
* Setup annotation creation on click
*/
private setupAnnotationCreation(overlay: HTMLElement, noteId: string): void {
overlay.addEventListener('click', async (e) => {
if (!this.isEditMode) return;
const rect = overlay.getBoundingClientRect();
const x = ((e.clientX - rect.left) / rect.width) * 100;
const y = ((e.clientY - rect.top) / rect.height) * 100;
// Show annotation creation dialog
const text = prompt('Enter annotation text:');
if (text) {
await this.saveAnnotation({
noteId,
x,
y,
text,
author: 'current_user', // TODO: Get from session
type: 'comment'
});
// Reload annotations
await this.loadAnnotations(noteId);
// Re-render
const imageElement = overlay.parentElement?.querySelector('img') as HTMLImageElement;
if (imageElement && overlay.parentElement) {
this.renderAnnotations(overlay.parentElement, noteId, imageElement);
}
}
});
}
/**
* Select an annotation for editing
*/
private selectAnnotation(annotation: ImageAnnotation): void {
this.selectedAnnotation = annotation;
// Highlight selected annotation
this.annotationElements.forEach((element, id) => {
if (id === annotation.id) {
element.classList.add('selected');
element.style.outline = '2px solid #2196F3';
} else {
element.classList.remove('selected');
element.style.outline = 'none';
}
});
// Show edit options
if (this.isEditMode) {
this.showEditDialog(annotation);
}
}
/**
* Show edit dialog for annotation
*/
private showEditDialog(annotation: ImageAnnotation): void {
// Simple implementation - could be replaced with a proper modal
const newText = prompt('Edit annotation:', annotation.text);
if (newText !== null) {
annotation.text = newText;
this.updateAnnotation(annotation);
// Update tooltip with sanitized text
const element = this.annotationElements.get(annotation.id);
if (element) {
const tooltip = element.querySelector('.annotation-tooltip');
if (tooltip) {
// Use textContent to prevent XSS
tooltip.textContent = this.sanitizeText(newText);
}
}
}
}
/**
* Toggle edit mode
*/
toggleEditMode(): void {
this.isEditMode = !this.isEditMode;
// Update overlay pointer events
document.querySelectorAll('.annotation-overlay').forEach(overlay => {
(overlay as HTMLElement).style.pointerEvents = this.isEditMode ? 'auto' : 'none';
});
}
/**
* Clear all annotation elements
*/
private clearAnnotationElements(): void {
this.annotationElements.forEach(element => element.remove());
this.annotationElements.clear();
}
/**
* Generate unique ID
*/
private generateId(): string {
return `ann_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
}
/**
* Export annotations as JSON
*/
exportAnnotations(noteId: string): string {
const annotations = this.activeAnnotations.get(noteId) || [];
return JSON.stringify(annotations, null, 2);
}
/**
* Import annotations from JSON
*/
async importAnnotations(noteId: string, json: string): Promise<void> {
try {
const annotations = JSON.parse(json) as ImageAnnotation[];
for (const annotation of annotations) {
await this.saveAnnotation({
noteId,
x: annotation.x,
y: annotation.y,
text: annotation.text,
author: annotation.author,
color: annotation.color,
icon: annotation.icon,
type: annotation.type,
width: annotation.width,
height: annotation.height
});
}
await this.loadAnnotations(noteId);
} catch (error) {
console.error('Failed to import annotations:', error);
throw error;
}
}
/**
* Sanitize text to prevent XSS
*/
private sanitizeText(text: string): string {
if (!text) return '';
// Remove any HTML tags and dangerous characters
const div = document.createElement('div');
div.textContent = text;
// Additional validation
const sanitized = div.textContent || '';
// Remove any remaining special characters that could be dangerous
return sanitized
.replace(/<script[^>]*>.*?<\/script>/gi, '')
.replace(/<iframe[^>]*>.*?<\/iframe>/gi, '')
.replace(/javascript:/gi, '')
.replace(/on\w+\s*=/gi, '');
}
/**
* Cleanup resources
*/
cleanup(): void {
this.clearAnnotationElements();
this.activeAnnotations.clear();
this.selectedAnnotation = null;
this.isEditMode = false;
}
}
export default ImageAnnotationsService.getInstance();

View File

@@ -1,877 +0,0 @@
/**
* Image Comparison Module for Trilium Notes
* Provides side-by-side and overlay comparison modes for images
*/
import mediaViewer from './media_viewer.js';
import utils from './utils.js';
/**
* Comparison mode types
*/
export type ComparisonMode = 'side-by-side' | 'overlay' | 'swipe' | 'difference';
/**
* Image comparison configuration
*/
export interface ComparisonConfig {
mode: ComparisonMode;
syncZoom: boolean;
syncPan: boolean;
showLabels: boolean;
swipePosition?: number; // For swipe mode (0-100)
opacity?: number; // For overlay mode (0-1)
highlightDifferences?: boolean; // For difference mode
}
/**
* Comparison state
*/
interface ComparisonState {
leftImage: ComparisonImage;
rightImage: ComparisonImage;
config: ComparisonConfig;
container?: HTMLElement;
isActive: boolean;
}
/**
* Image data for comparison
*/
export interface ComparisonImage {
src: string;
title?: string;
noteId?: string;
width?: number;
height?: number;
}
/**
* ImageComparisonService provides various comparison modes for images
*/
class ImageComparisonService {
private static instance: ImageComparisonService;
private currentComparison: ComparisonState | null = null;
private comparisonContainer?: HTMLElement;
private leftCanvas?: HTMLCanvasElement;
private rightCanvas?: HTMLCanvasElement;
private leftContext?: CanvasRenderingContext2D;
private rightContext?: CanvasRenderingContext2D;
private swipeHandle?: HTMLElement;
private isDraggingSwipe: boolean = false;
private currentZoom: number = 1;
private panX: number = 0;
private panY: number = 0;
private defaultConfig: ComparisonConfig = {
mode: 'side-by-side',
syncZoom: true,
syncPan: true,
showLabels: true,
swipePosition: 50,
opacity: 0.5,
highlightDifferences: false
};
private constructor() {}
static getInstance(): ImageComparisonService {
if (!ImageComparisonService.instance) {
ImageComparisonService.instance = new ImageComparisonService();
}
return ImageComparisonService.instance;
}
/**
* Start image comparison
*/
async startComparison(
leftImage: ComparisonImage,
rightImage: ComparisonImage,
container: HTMLElement,
config?: Partial<ComparisonConfig>
): Promise<void> {
try {
// Close any existing comparison
this.closeComparison();
// Merge configuration
const finalConfig = { ...this.defaultConfig, ...config };
// Initialize state
this.currentComparison = {
leftImage,
rightImage,
config: finalConfig,
container,
isActive: true
};
// Load images
await this.loadImages(leftImage, rightImage);
// Create comparison UI based on mode
switch (finalConfig.mode) {
case 'side-by-side':
await this.createSideBySideComparison(container);
break;
case 'overlay':
await this.createOverlayComparison(container);
break;
case 'swipe':
await this.createSwipeComparison(container);
break;
case 'difference':
await this.createDifferenceComparison(container);
break;
}
// Add controls
this.addComparisonControls(container);
} catch (error) {
console.error('Failed to start image comparison:', error);
this.closeComparison();
throw error;
}
}
/**
* Load images and get dimensions
*/
private async loadImages(leftImage: ComparisonImage, rightImage: ComparisonImage): Promise<void> {
const loadImage = (src: string): Promise<HTMLImageElement> => {
return new Promise((resolve, reject) => {
const img = new Image();
img.onload = () => resolve(img);
img.onerror = () => reject(new Error(`Failed to load image: ${src}`));
img.src = src;
});
};
const [leftImg, rightImg] = await Promise.all([
loadImage(leftImage.src),
loadImage(rightImage.src)
]);
// Update dimensions
leftImage.width = leftImg.naturalWidth;
leftImage.height = leftImg.naturalHeight;
rightImage.width = rightImg.naturalWidth;
rightImage.height = rightImg.naturalHeight;
}
/**
* Create side-by-side comparison
*/
private async createSideBySideComparison(container: HTMLElement): Promise<void> {
if (!this.currentComparison) return;
// Clear container
container.innerHTML = '';
container.style.cssText = `
display: flex;
width: 100%;
height: 100%;
position: relative;
background: #1a1a1a;
`;
// Create left panel
const leftPanel = document.createElement('div');
leftPanel.className = 'comparison-panel comparison-left';
leftPanel.style.cssText = `
flex: 1;
position: relative;
overflow: hidden;
border-right: 2px solid #333;
`;
// Create right panel
const rightPanel = document.createElement('div');
rightPanel.className = 'comparison-panel comparison-right';
rightPanel.style.cssText = `
flex: 1;
position: relative;
overflow: hidden;
`;
// Add images
const leftImg = await this.createImageElement(this.currentComparison.leftImage);
const rightImg = await this.createImageElement(this.currentComparison.rightImage);
leftPanel.appendChild(leftImg);
rightPanel.appendChild(rightImg);
// Add labels if enabled
if (this.currentComparison.config.showLabels) {
this.addImageLabel(leftPanel, this.currentComparison.leftImage.title || 'Image 1');
this.addImageLabel(rightPanel, this.currentComparison.rightImage.title || 'Image 2');
}
container.appendChild(leftPanel);
container.appendChild(rightPanel);
// Setup synchronized zoom and pan if enabled
if (this.currentComparison.config.syncZoom || this.currentComparison.config.syncPan) {
this.setupSynchronizedControls(leftPanel, rightPanel);
}
}
/**
* Create overlay comparison
*/
private async createOverlayComparison(container: HTMLElement): Promise<void> {
if (!this.currentComparison) return;
container.innerHTML = '';
container.style.cssText = `
position: relative;
width: 100%;
height: 100%;
background: #1a1a1a;
overflow: hidden;
`;
// Create base image
const baseImg = await this.createImageElement(this.currentComparison.leftImage);
baseImg.style.position = 'absolute';
baseImg.style.zIndex = '1';
// Create overlay image
const overlayImg = await this.createImageElement(this.currentComparison.rightImage);
overlayImg.style.position = 'absolute';
overlayImg.style.zIndex = '2';
overlayImg.style.opacity = String(this.currentComparison.config.opacity || 0.5);
container.appendChild(baseImg);
container.appendChild(overlayImg);
// Add opacity slider
this.addOpacityControl(container, overlayImg);
// Add labels
if (this.currentComparison.config.showLabels) {
const labelContainer = document.createElement('div');
labelContainer.style.cssText = `
position: absolute;
top: 10px;
left: 10px;
z-index: 10;
display: flex;
gap: 10px;
`;
const baseLabel = this.createLabel(this.currentComparison.leftImage.title || 'Base', '#4CAF50');
const overlayLabel = this.createLabel(this.currentComparison.rightImage.title || 'Overlay', '#2196F3');
labelContainer.appendChild(baseLabel);
labelContainer.appendChild(overlayLabel);
container.appendChild(labelContainer);
}
}
/**
* Create swipe comparison
*/
private async createSwipeComparison(container: HTMLElement): Promise<void> {
if (!this.currentComparison) return;
container.innerHTML = '';
container.style.cssText = `
position: relative;
width: 100%;
height: 100%;
background: #1a1a1a;
overflow: hidden;
cursor: ew-resize;
`;
// Create images
const leftImg = await this.createImageElement(this.currentComparison.leftImage);
const rightImg = await this.createImageElement(this.currentComparison.rightImage);
leftImg.style.position = 'absolute';
leftImg.style.zIndex = '1';
// Create clipping container for right image
const clipContainer = document.createElement('div');
clipContainer.className = 'swipe-clip-container';
clipContainer.style.cssText = `
position: absolute;
top: 0;
left: 0;
width: ${this.currentComparison.config.swipePosition}%;
height: 100%;
overflow: hidden;
z-index: 2;
`;
rightImg.style.position = 'absolute';
clipContainer.appendChild(rightImg);
// Create swipe handle
this.swipeHandle = document.createElement('div');
this.swipeHandle.className = 'swipe-handle';
this.swipeHandle.style.cssText = `
position: absolute;
top: 0;
left: ${this.currentComparison.config.swipePosition}%;
width: 4px;
height: 100%;
background: white;
cursor: ew-resize;
z-index: 3;
transform: translateX(-50%);
box-shadow: 0 0 10px rgba(0,0,0,0.5);
`;
// Add handle icon
const handleIcon = document.createElement('div');
handleIcon.style.cssText = `
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
width: 40px;
height: 40px;
background: white;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
box-shadow: 0 2px 10px rgba(0,0,0,0.3);
`;
handleIcon.innerHTML = '<i class="bx bx-move-horizontal" style="font-size: 24px; color: #333;"></i>';
this.swipeHandle.appendChild(handleIcon);
container.appendChild(leftImg);
container.appendChild(clipContainer);
container.appendChild(this.swipeHandle);
// Setup swipe interaction
this.setupSwipeInteraction(container, clipContainer);
// Add labels
if (this.currentComparison.config.showLabels) {
this.addSwipeLabels(container);
}
}
/**
* Create difference comparison using canvas
*/
private async createDifferenceComparison(container: HTMLElement): Promise<void> {
if (!this.currentComparison) return;
container.innerHTML = '';
container.style.cssText = `
position: relative;
width: 100%;
height: 100%;
background: #1a1a1a;
overflow: hidden;
`;
// Create canvas for difference visualization
const canvas = document.createElement('canvas');
canvas.className = 'difference-canvas';
const ctx = canvas.getContext('2d');
if (!ctx) {
throw new Error('Failed to get canvas context');
}
// Load images
const leftImg = new Image();
const rightImg = new Image();
await Promise.all([
new Promise((resolve) => {
leftImg.onload = resolve;
leftImg.src = this.currentComparison!.leftImage.src;
}),
new Promise((resolve) => {
rightImg.onload = resolve;
rightImg.src = this.currentComparison!.rightImage.src;
})
]);
// Set canvas size
const maxWidth = Math.max(leftImg.width, rightImg.width);
const maxHeight = Math.max(leftImg.height, rightImg.height);
canvas.width = maxWidth;
canvas.height = maxHeight;
// Calculate difference
this.calculateImageDifference(ctx, leftImg, rightImg, maxWidth, maxHeight);
// Style canvas
canvas.style.cssText = `
max-width: 100%;
max-height: 100%;
object-fit: contain;
`;
container.appendChild(canvas);
// Add difference statistics
this.addDifferenceStatistics(container, ctx, maxWidth, maxHeight);
}
/**
* Calculate and visualize image difference
*/
private calculateImageDifference(
ctx: CanvasRenderingContext2D,
leftImg: HTMLImageElement,
rightImg: HTMLImageElement,
width: number,
height: number
): void {
// Draw left image
ctx.drawImage(leftImg, 0, 0, width, height);
const leftData = ctx.getImageData(0, 0, width, height);
// Draw right image
ctx.clearRect(0, 0, width, height);
ctx.drawImage(rightImg, 0, 0, width, height);
const rightData = ctx.getImageData(0, 0, width, height);
// Calculate difference
const diffData = ctx.createImageData(width, height);
let totalDiff = 0;
for (let i = 0; i < leftData.data.length; i += 4) {
const rDiff = Math.abs(leftData.data[i] - rightData.data[i]);
const gDiff = Math.abs(leftData.data[i + 1] - rightData.data[i + 1]);
const bDiff = Math.abs(leftData.data[i + 2] - rightData.data[i + 2]);
const avgDiff = (rDiff + gDiff + bDiff) / 3;
totalDiff += avgDiff;
if (this.currentComparison?.config.highlightDifferences && avgDiff > 30) {
// Highlight differences in red
diffData.data[i] = 255; // Red
diffData.data[i + 1] = 0; // Green
diffData.data[i + 2] = 0; // Blue
diffData.data[i + 3] = Math.min(255, avgDiff * 2); // Alpha based on difference
} else {
// Show original image with reduced opacity for non-different areas
diffData.data[i] = leftData.data[i];
diffData.data[i + 1] = leftData.data[i + 1];
diffData.data[i + 2] = leftData.data[i + 2];
diffData.data[i + 3] = avgDiff > 10 ? 255 : 128;
}
}
ctx.putImageData(diffData, 0, 0);
}
/**
* Add difference statistics overlay
*/
private addDifferenceStatistics(
container: HTMLElement,
ctx: CanvasRenderingContext2D,
width: number,
height: number
): void {
const imageData = ctx.getImageData(0, 0, width, height);
let changedPixels = 0;
const threshold = 30;
for (let i = 0; i < imageData.data.length; i += 4) {
const r = imageData.data[i];
const g = imageData.data[i + 1];
const b = imageData.data[i + 2];
if (r > threshold || g > threshold || b > threshold) {
changedPixels++;
}
}
const totalPixels = width * height;
const changePercentage = ((changedPixels / totalPixels) * 100).toFixed(2);
const statsDiv = document.createElement('div');
statsDiv.className = 'difference-stats';
statsDiv.style.cssText = `
position: absolute;
top: 10px;
right: 10px;
background: rgba(0, 0, 0, 0.8);
color: white;
padding: 10px;
border-radius: 4px;
font-size: 12px;
z-index: 10;
`;
statsDiv.innerHTML = `
<div><strong>Difference Analysis</strong></div>
<div>Changed pixels: ${changedPixels.toLocaleString()}</div>
<div>Total pixels: ${totalPixels.toLocaleString()}</div>
<div>Difference: ${changePercentage}%</div>
`;
container.appendChild(statsDiv);
}
/**
* Create image element
*/
private async createImageElement(image: ComparisonImage): Promise<HTMLImageElement> {
const img = document.createElement('img');
img.src = image.src;
img.alt = image.title || 'Comparison image';
img.style.cssText = `
width: 100%;
height: 100%;
object-fit: contain;
`;
await new Promise((resolve, reject) => {
img.onload = resolve;
img.onerror = reject;
});
return img;
}
/**
* Add image label
*/
private addImageLabel(container: HTMLElement, text: string): void {
const label = document.createElement('div');
label.className = 'image-label';
label.style.cssText = `
position: absolute;
top: 10px;
left: 10px;
background: rgba(0, 0, 0, 0.7);
color: white;
padding: 6px 10px;
border-radius: 4px;
font-size: 12px;
z-index: 10;
`;
label.textContent = text;
container.appendChild(label);
}
/**
* Create label element
*/
private createLabel(text: string, color: string): HTMLElement {
const label = document.createElement('div');
label.style.cssText = `
background: ${color};
color: white;
padding: 4px 8px;
border-radius: 3px;
font-size: 12px;
`;
label.textContent = text;
return label;
}
/**
* Add swipe labels
*/
private addSwipeLabels(container: HTMLElement): void {
if (!this.currentComparison) return;
const leftLabel = document.createElement('div');
leftLabel.style.cssText = `
position: absolute;
top: 10px;
left: 10px;
background: rgba(76, 175, 80, 0.9);
color: white;
padding: 6px 10px;
border-radius: 4px;
font-size: 12px;
z-index: 10;
`;
leftLabel.textContent = this.currentComparison.leftImage.title || 'Left';
const rightLabel = document.createElement('div');
rightLabel.style.cssText = `
position: absolute;
top: 10px;
right: 10px;
background: rgba(33, 150, 243, 0.9);
color: white;
padding: 6px 10px;
border-radius: 4px;
font-size: 12px;
z-index: 10;
`;
rightLabel.textContent = this.currentComparison.rightImage.title || 'Right';
container.appendChild(leftLabel);
container.appendChild(rightLabel);
}
/**
* Setup swipe interaction
*/
private setupSwipeInteraction(container: HTMLElement, clipContainer: HTMLElement): void {
if (!this.swipeHandle) return;
let startX = 0;
let startPosition = this.currentComparison?.config.swipePosition || 50;
const handleMouseMove = (e: MouseEvent) => {
if (!this.isDraggingSwipe) return;
const rect = container.getBoundingClientRect();
const x = e.clientX - rect.left;
const percentage = Math.max(0, Math.min(100, (x / rect.width) * 100));
clipContainer.style.width = `${percentage}%`;
if (this.swipeHandle) {
this.swipeHandle.style.left = `${percentage}%`;
}
if (this.currentComparison) {
this.currentComparison.config.swipePosition = percentage;
}
};
const handleMouseUp = () => {
this.isDraggingSwipe = false;
document.removeEventListener('mousemove', handleMouseMove);
document.removeEventListener('mouseup', handleMouseUp);
container.style.cursor = 'default';
};
this.swipeHandle.addEventListener('mousedown', (e) => {
this.isDraggingSwipe = true;
startX = e.clientX;
startPosition = this.currentComparison?.config.swipePosition || 50;
container.style.cursor = 'ew-resize';
document.addEventListener('mousemove', handleMouseMove);
document.addEventListener('mouseup', handleMouseUp);
});
// Also allow dragging anywhere in the container
container.addEventListener('mousedown', (e) => {
if (e.target === this.swipeHandle || (e.target as HTMLElement).parentElement === this.swipeHandle) {
return;
}
const rect = container.getBoundingClientRect();
const x = e.clientX - rect.left;
const percentage = (x / rect.width) * 100;
clipContainer.style.width = `${percentage}%`;
if (this.swipeHandle) {
this.swipeHandle.style.left = `${percentage}%`;
}
if (this.currentComparison) {
this.currentComparison.config.swipePosition = percentage;
}
});
}
/**
* Add opacity control for overlay mode
*/
private addOpacityControl(container: HTMLElement, overlayImg: HTMLImageElement): void {
const control = document.createElement('div');
control.className = 'opacity-control';
control.style.cssText = `
position: absolute;
bottom: 20px;
left: 50%;
transform: translateX(-50%);
background: rgba(0, 0, 0, 0.8);
padding: 10px 20px;
border-radius: 4px;
z-index: 10;
display: flex;
align-items: center;
gap: 10px;
`;
const label = document.createElement('label');
label.textContent = 'Opacity:';
label.style.color = 'white';
label.style.fontSize = '12px';
const slider = document.createElement('input');
slider.type = 'range';
slider.min = '0';
slider.max = '100';
slider.value = String((this.currentComparison?.config.opacity || 0.5) * 100);
slider.style.width = '150px';
const value = document.createElement('span');
value.textContent = `${slider.value}%`;
value.style.color = 'white';
value.style.fontSize = '12px';
value.style.minWidth = '35px';
slider.addEventListener('input', () => {
const opacity = parseInt(slider.value) / 100;
overlayImg.style.opacity = String(opacity);
value.textContent = `${slider.value}%`;
if (this.currentComparison) {
this.currentComparison.config.opacity = opacity;
}
});
control.appendChild(label);
control.appendChild(slider);
control.appendChild(value);
container.appendChild(control);
}
/**
* Setup synchronized controls for side-by-side mode
*/
private setupSynchronizedControls(leftPanel: HTMLElement, rightPanel: HTMLElement): void {
const leftImg = leftPanel.querySelector('img') as HTMLImageElement;
const rightImg = rightPanel.querySelector('img') as HTMLImageElement;
if (!leftImg || !rightImg) return;
// Synchronize scroll
if (this.currentComparison?.config.syncPan) {
leftPanel.addEventListener('scroll', () => {
rightPanel.scrollLeft = leftPanel.scrollLeft;
rightPanel.scrollTop = leftPanel.scrollTop;
});
rightPanel.addEventListener('scroll', () => {
leftPanel.scrollLeft = rightPanel.scrollLeft;
leftPanel.scrollTop = rightPanel.scrollTop;
});
}
// Synchronize zoom with wheel events
if (this.currentComparison?.config.syncZoom) {
const handleWheel = (e: WheelEvent) => {
e.preventDefault();
const delta = e.deltaY < 0 ? 1.1 : 0.9;
this.currentZoom = Math.max(0.5, Math.min(5, this.currentZoom * delta));
leftImg.style.transform = `scale(${this.currentZoom})`;
rightImg.style.transform = `scale(${this.currentZoom})`;
};
leftPanel.addEventListener('wheel', handleWheel);
rightPanel.addEventListener('wheel', handleWheel);
}
}
/**
* Add comparison controls toolbar
*/
private addComparisonControls(container: HTMLElement): void {
const toolbar = document.createElement('div');
toolbar.className = 'comparison-toolbar';
toolbar.style.cssText = `
position: absolute;
top: 10px;
right: 10px;
background: rgba(0, 0, 0, 0.8);
border-radius: 4px;
padding: 8px;
display: flex;
gap: 8px;
z-index: 100;
`;
// Mode switcher
const modes: ComparisonMode[] = ['side-by-side', 'overlay', 'swipe', 'difference'];
modes.forEach(mode => {
const btn = document.createElement('button');
btn.className = `mode-btn mode-${mode}`;
btn.style.cssText = `
background: ${this.currentComparison?.config.mode === mode ? '#2196F3' : 'rgba(255,255,255,0.1)'};
color: white;
border: none;
padding: 6px 10px;
border-radius: 3px;
cursor: pointer;
font-size: 12px;
`;
btn.textContent = mode.replace('-', ' ').replace(/\b\w/g, l => l.toUpperCase());
btn.addEventListener('click', async () => {
if (this.currentComparison && this.currentComparison.container) {
this.currentComparison.config.mode = mode;
await this.startComparison(
this.currentComparison.leftImage,
this.currentComparison.rightImage,
this.currentComparison.container,
this.currentComparison.config
);
}
});
toolbar.appendChild(btn);
});
// Close button
const closeBtn = document.createElement('button');
closeBtn.style.cssText = `
background: rgba(255,0,0,0.5);
color: white;
border: none;
padding: 6px 10px;
border-radius: 3px;
cursor: pointer;
font-size: 12px;
margin-left: 10px;
`;
closeBtn.textContent = 'Close';
closeBtn.addEventListener('click', () => this.closeComparison());
toolbar.appendChild(closeBtn);
container.appendChild(toolbar);
}
/**
* Close comparison
*/
closeComparison(): void {
if (this.currentComparison?.container) {
this.currentComparison.container.innerHTML = '';
}
this.currentComparison = null;
this.comparisonContainer = undefined;
this.leftCanvas = undefined;
this.rightCanvas = undefined;
this.leftContext = undefined;
this.rightContext = undefined;
this.swipeHandle = undefined;
this.isDraggingSwipe = false;
this.currentZoom = 1;
this.panX = 0;
this.panY = 0;
}
/**
* Check if comparison is active
*/
isComparisonActive(): boolean {
return this.currentComparison?.isActive || false;
}
/**
* Get current comparison state
*/
getComparisonState(): ComparisonState | null {
return this.currentComparison;
}
}
export default ImageComparisonService.getInstance();

View File

@@ -1,874 +0,0 @@
/**
* Basic Image Editor Module for Trilium Notes
* Provides non-destructive image editing capabilities
*/
import server from './server.js';
import toastService from './toast.js';
import { ImageValidator, withErrorBoundary, MemoryMonitor, ImageError, ImageErrorType } from './image_error_handler.js';
/**
* Edit operation types
*/
export type EditOperation =
| 'rotate'
| 'crop'
| 'brightness'
| 'contrast'
| 'saturation'
| 'blur'
| 'sharpen';
/**
* Edit history entry
*/
export interface EditHistoryEntry {
operation: EditOperation;
params: any;
timestamp: Date;
}
/**
* Crop area definition
*/
export interface CropArea {
x: number;
y: number;
width: number;
height: number;
}
/**
* Editor state
*/
interface EditorState {
originalImage: HTMLImageElement | null;
currentImage: HTMLImageElement | null;
canvas: HTMLCanvasElement;
context: CanvasRenderingContext2D;
history: EditHistoryEntry[];
historyIndex: number;
isEditing: boolean;
}
/**
* Filter parameters
*/
export interface FilterParams {
brightness?: number; // -100 to 100
contrast?: number; // -100 to 100
saturation?: number; // -100 to 100
blur?: number; // 0 to 20
sharpen?: number; // 0 to 100
}
/**
* ImageEditorService provides basic image editing capabilities
*/
class ImageEditorService {
private static instance: ImageEditorService;
private editorState: EditorState;
private tempCanvas: HTMLCanvasElement;
private tempContext: CanvasRenderingContext2D;
private cropOverlay?: HTMLElement;
private cropHandles?: HTMLElement[];
private cropArea: CropArea | null = null;
private isDraggingCrop: boolean = false;
private dragStartX: number = 0;
private dragStartY: number = 0;
private currentFilters: FilterParams = {};
// Canvas size limits for security and memory management
private readonly MAX_CANVAS_SIZE = 8192; // Maximum width/height
private readonly MAX_CANVAS_AREA = 50000000; // 50 megapixels
private constructor() {
// Initialize canvases
this.editorState = {
originalImage: null,
currentImage: null,
canvas: document.createElement('canvas'),
context: null as any,
history: [],
historyIndex: -1,
isEditing: false
};
const ctx = this.editorState.canvas.getContext('2d');
if (!ctx) {
throw new Error('Failed to get canvas context');
}
this.editorState.context = ctx;
this.tempCanvas = document.createElement('canvas');
const tempCtx = this.tempCanvas.getContext('2d');
if (!tempCtx) {
throw new Error('Failed to get temp canvas context');
}
this.tempContext = tempCtx;
}
static getInstance(): ImageEditorService {
if (!ImageEditorService.instance) {
ImageEditorService.instance = new ImageEditorService();
}
return ImageEditorService.instance;
}
/**
* Start editing an image
*/
async startEditing(src: string | HTMLImageElement): Promise<HTMLCanvasElement> {
return await withErrorBoundary(async () => {
// Validate input
if (typeof src === 'string') {
ImageValidator.validateUrl(src);
}
// Load image
let img: HTMLImageElement;
if (typeof src === 'string') {
img = await this.loadImage(src);
} else {
img = src;
}
// Validate image dimensions
ImageValidator.validateDimensions(img.naturalWidth, img.naturalHeight);
// Check memory availability
const estimatedMemory = MemoryMonitor.estimateImageMemory(img.naturalWidth, img.naturalHeight);
if (!MemoryMonitor.checkMemoryAvailable(estimatedMemory)) {
throw new ImageError(
ImageErrorType.MEMORY_ERROR,
'Insufficient memory to process image',
{ estimatedMemory }
);
}
if (img.naturalWidth > this.MAX_CANVAS_SIZE ||
img.naturalHeight > this.MAX_CANVAS_SIZE ||
img.naturalWidth * img.naturalHeight > this.MAX_CANVAS_AREA) {
// Scale down if too large
const scale = Math.min(
this.MAX_CANVAS_SIZE / Math.max(img.naturalWidth, img.naturalHeight),
Math.sqrt(this.MAX_CANVAS_AREA / (img.naturalWidth * img.naturalHeight))
);
const scaledWidth = Math.floor(img.naturalWidth * scale);
const scaledHeight = Math.floor(img.naturalHeight * scale);
console.warn(`Image too large (${img.naturalWidth}x${img.naturalHeight}), scaling to ${scaledWidth}x${scaledHeight}`);
// Create scaled image
const scaledCanvas = document.createElement('canvas');
scaledCanvas.width = scaledWidth;
scaledCanvas.height = scaledHeight;
const scaledCtx = scaledCanvas.getContext('2d');
if (!scaledCtx) throw new Error('Failed to get scaled canvas context');
scaledCtx.drawImage(img, 0, 0, scaledWidth, scaledHeight);
// Create new image from scaled canvas
const scaledImg = new Image();
scaledImg.src = scaledCanvas.toDataURL();
await new Promise(resolve => scaledImg.onload = resolve);
img = scaledImg;
// Clean up scaled canvas
scaledCanvas.width = 0;
scaledCanvas.height = 0;
}
// Store original
this.editorState.originalImage = img;
this.editorState.currentImage = img;
this.editorState.isEditing = true;
this.editorState.history = [];
this.editorState.historyIndex = -1;
this.currentFilters = {};
// Setup canvas with validated dimensions
this.editorState.canvas.width = img.naturalWidth;
this.editorState.canvas.height = img.naturalHeight;
this.editorState.context.drawImage(img, 0, 0);
return this.editorState.canvas;
}, (error) => {
this.stopEditing();
throw error;
}) || this.editorState.canvas;
}
/**
* Rotate image by degrees (90, 180, 270)
*/
rotate(degrees: 90 | 180 | 270 | -90): void {
if (!this.editorState.isEditing) return;
const { canvas, context } = this.editorState;
const { width, height } = canvas;
// Setup temp canvas
if (degrees === 90 || degrees === -90 || degrees === 270) {
this.tempCanvas.width = height;
this.tempCanvas.height = width;
} else {
this.tempCanvas.width = width;
this.tempCanvas.height = height;
}
// Clear temp canvas
this.tempContext.clearRect(0, 0, this.tempCanvas.width, this.tempCanvas.height);
// Rotate
this.tempContext.save();
if (degrees === 90) {
this.tempContext.translate(height, 0);
this.tempContext.rotate(Math.PI / 2);
} else if (degrees === 180) {
this.tempContext.translate(width, height);
this.tempContext.rotate(Math.PI);
} else if (degrees === 270 || degrees === -90) {
this.tempContext.translate(0, width);
this.tempContext.rotate(-Math.PI / 2);
}
this.tempContext.drawImage(canvas, 0, 0);
this.tempContext.restore();
// Copy back to main canvas
canvas.width = this.tempCanvas.width;
canvas.height = this.tempCanvas.height;
context.drawImage(this.tempCanvas, 0, 0);
// Add to history
this.addToHistory('rotate', { degrees });
}
/**
* Start crop selection
*/
startCrop(container: HTMLElement): void {
if (!this.editorState.isEditing) return;
// Create crop overlay
this.cropOverlay = document.createElement('div');
this.cropOverlay.className = 'crop-overlay';
this.cropOverlay.style.cssText = `
position: absolute;
border: 2px dashed #fff;
background: rgba(0, 0, 0, 0.3);
cursor: move;
z-index: 1000;
`;
// Create resize handles
this.cropHandles = [];
const handlePositions = ['nw', 'n', 'ne', 'e', 'se', 's', 'sw', 'w'];
handlePositions.forEach(pos => {
const handle = document.createElement('div');
handle.className = `crop-handle crop-handle-${pos}`;
handle.dataset.position = pos;
handle.style.cssText = `
position: absolute;
width: 10px;
height: 10px;
background: white;
border: 1px solid #333;
z-index: 1001;
`;
// Position handles
switch (pos) {
case 'nw':
handle.style.top = '-5px';
handle.style.left = '-5px';
handle.style.cursor = 'nw-resize';
break;
case 'n':
handle.style.top = '-5px';
handle.style.left = '50%';
handle.style.transform = 'translateX(-50%)';
handle.style.cursor = 'n-resize';
break;
case 'ne':
handle.style.top = '-5px';
handle.style.right = '-5px';
handle.style.cursor = 'ne-resize';
break;
case 'e':
handle.style.top = '50%';
handle.style.right = '-5px';
handle.style.transform = 'translateY(-50%)';
handle.style.cursor = 'e-resize';
break;
case 'se':
handle.style.bottom = '-5px';
handle.style.right = '-5px';
handle.style.cursor = 'se-resize';
break;
case 's':
handle.style.bottom = '-5px';
handle.style.left = '50%';
handle.style.transform = 'translateX(-50%)';
handle.style.cursor = 's-resize';
break;
case 'sw':
handle.style.bottom = '-5px';
handle.style.left = '-5px';
handle.style.cursor = 'sw-resize';
break;
case 'w':
handle.style.top = '50%';
handle.style.left = '-5px';
handle.style.transform = 'translateY(-50%)';
handle.style.cursor = 'w-resize';
break;
}
this.cropOverlay.appendChild(handle);
this.cropHandles!.push(handle);
});
// Set initial crop area (80% of image)
const canvasRect = this.editorState.canvas.getBoundingClientRect();
const initialSize = Math.min(canvasRect.width, canvasRect.height) * 0.8;
const initialX = (canvasRect.width - initialSize) / 2;
const initialY = (canvasRect.height - initialSize) / 2;
this.cropArea = {
x: initialX,
y: initialY,
width: initialSize,
height: initialSize
};
this.updateCropOverlay();
container.appendChild(this.cropOverlay);
// Setup drag handlers
this.setupCropHandlers();
}
/**
* Setup crop interaction handlers
*/
private setupCropHandlers(): void {
if (!this.cropOverlay) return;
// Drag to move
this.cropOverlay.addEventListener('mousedown', (e) => {
if ((e.target as HTMLElement).classList.contains('crop-handle')) return;
this.isDraggingCrop = true;
this.dragStartX = e.clientX;
this.dragStartY = e.clientY;
const handleMove = (e: MouseEvent) => {
if (!this.isDraggingCrop || !this.cropArea) return;
const deltaX = e.clientX - this.dragStartX;
const deltaY = e.clientY - this.dragStartY;
this.cropArea.x += deltaX;
this.cropArea.y += deltaY;
this.dragStartX = e.clientX;
this.dragStartY = e.clientY;
this.updateCropOverlay();
};
const handleUp = () => {
this.isDraggingCrop = false;
document.removeEventListener('mousemove', handleMove);
document.removeEventListener('mouseup', handleUp);
};
document.addEventListener('mousemove', handleMove);
document.addEventListener('mouseup', handleUp);
});
// Resize handles
this.cropHandles?.forEach(handle => {
handle.addEventListener('mousedown', (e) => {
e.stopPropagation();
const position = handle.dataset.position!;
const startX = e.clientX;
const startY = e.clientY;
const startCrop = { ...this.cropArea! };
const handleResize = (e: MouseEvent) => {
if (!this.cropArea) return;
const deltaX = e.clientX - startX;
const deltaY = e.clientY - startY;
switch (position) {
case 'nw':
this.cropArea.x = startCrop.x + deltaX;
this.cropArea.y = startCrop.y + deltaY;
this.cropArea.width = startCrop.width - deltaX;
this.cropArea.height = startCrop.height - deltaY;
break;
case 'n':
this.cropArea.y = startCrop.y + deltaY;
this.cropArea.height = startCrop.height - deltaY;
break;
case 'ne':
this.cropArea.y = startCrop.y + deltaY;
this.cropArea.width = startCrop.width + deltaX;
this.cropArea.height = startCrop.height - deltaY;
break;
case 'e':
this.cropArea.width = startCrop.width + deltaX;
break;
case 'se':
this.cropArea.width = startCrop.width + deltaX;
this.cropArea.height = startCrop.height + deltaY;
break;
case 's':
this.cropArea.height = startCrop.height + deltaY;
break;
case 'sw':
this.cropArea.x = startCrop.x + deltaX;
this.cropArea.width = startCrop.width - deltaX;
this.cropArea.height = startCrop.height + deltaY;
break;
case 'w':
this.cropArea.x = startCrop.x + deltaX;
this.cropArea.width = startCrop.width - deltaX;
break;
}
// Ensure minimum size
this.cropArea.width = Math.max(50, this.cropArea.width);
this.cropArea.height = Math.max(50, this.cropArea.height);
this.updateCropOverlay();
};
const handleUp = () => {
document.removeEventListener('mousemove', handleResize);
document.removeEventListener('mouseup', handleUp);
};
document.addEventListener('mousemove', handleResize);
document.addEventListener('mouseup', handleUp);
});
});
}
/**
* Update crop overlay position
*/
private updateCropOverlay(): void {
if (!this.cropOverlay || !this.cropArea) return;
this.cropOverlay.style.left = `${this.cropArea.x}px`;
this.cropOverlay.style.top = `${this.cropArea.y}px`;
this.cropOverlay.style.width = `${this.cropArea.width}px`;
this.cropOverlay.style.height = `${this.cropArea.height}px`;
}
/**
* Apply crop
*/
applyCrop(): void {
if (!this.editorState.isEditing || !this.cropArea) return;
const { canvas, context } = this.editorState;
const canvasRect = canvas.getBoundingClientRect();
// Convert crop area from screen to canvas coordinates
const scaleX = canvas.width / canvasRect.width;
const scaleY = canvas.height / canvasRect.height;
const cropX = this.cropArea.x * scaleX;
const cropY = this.cropArea.y * scaleY;
const cropWidth = this.cropArea.width * scaleX;
const cropHeight = this.cropArea.height * scaleY;
// Get cropped image data
const imageData = context.getImageData(cropX, cropY, cropWidth, cropHeight);
// Resize canvas and put cropped image
canvas.width = cropWidth;
canvas.height = cropHeight;
context.putImageData(imageData, 0, 0);
// Clean up crop overlay
this.cancelCrop();
// Add to history
this.addToHistory('crop', {
x: cropX,
y: cropY,
width: cropWidth,
height: cropHeight
});
}
/**
* Cancel crop
*/
cancelCrop(): void {
if (this.cropOverlay) {
this.cropOverlay.remove();
this.cropOverlay = undefined;
}
this.cropHandles = undefined;
this.cropArea = null;
}
/**
* Apply brightness adjustment
*/
applyBrightness(value: number): void {
if (!this.editorState.isEditing) return;
this.currentFilters.brightness = value;
this.applyFilters();
}
/**
* Apply contrast adjustment
*/
applyContrast(value: number): void {
if (!this.editorState.isEditing) return;
this.currentFilters.contrast = value;
this.applyFilters();
}
/**
* Apply saturation adjustment
*/
applySaturation(value: number): void {
if (!this.editorState.isEditing) return;
this.currentFilters.saturation = value;
this.applyFilters();
}
/**
* Apply all filters
*/
private applyFilters(): void {
const { canvas, context, originalImage } = this.editorState;
if (!originalImage) return;
// Clear canvas and redraw original
context.clearRect(0, 0, canvas.width, canvas.height);
context.drawImage(originalImage, 0, 0, canvas.width, canvas.height);
// Get image data
const imageData = context.getImageData(0, 0, canvas.width, canvas.height);
const data = imageData.data;
// Apply brightness
if (this.currentFilters.brightness) {
const brightness = this.currentFilters.brightness * 2.55; // Convert to 0-255 range
for (let i = 0; i < data.length; i += 4) {
data[i] = Math.min(255, Math.max(0, data[i] + brightness));
data[i + 1] = Math.min(255, Math.max(0, data[i + 1] + brightness));
data[i + 2] = Math.min(255, Math.max(0, data[i + 2] + brightness));
}
}
// Apply contrast
if (this.currentFilters.contrast) {
const factor = (259 * (this.currentFilters.contrast + 255)) / (255 * (259 - this.currentFilters.contrast));
for (let i = 0; i < data.length; i += 4) {
data[i] = Math.min(255, Math.max(0, factor * (data[i] - 128) + 128));
data[i + 1] = Math.min(255, Math.max(0, factor * (data[i + 1] - 128) + 128));
data[i + 2] = Math.min(255, Math.max(0, factor * (data[i + 2] - 128) + 128));
}
}
// Apply saturation
if (this.currentFilters.saturation) {
const saturation = this.currentFilters.saturation / 100;
for (let i = 0; i < data.length; i += 4) {
const gray = 0.2989 * data[i] + 0.5870 * data[i + 1] + 0.1140 * data[i + 2];
data[i] = Math.min(255, Math.max(0, gray + saturation * (data[i] - gray)));
data[i + 1] = Math.min(255, Math.max(0, gray + saturation * (data[i + 1] - gray)));
data[i + 2] = Math.min(255, Math.max(0, gray + saturation * (data[i + 2] - gray)));
}
}
// Put modified image data back
context.putImageData(imageData, 0, 0);
}
/**
* Apply blur effect
*/
applyBlur(radius: number): void {
if (!this.editorState.isEditing) return;
const { canvas, context } = this.editorState;
// Use CSS filter for performance
context.filter = `blur(${radius}px)`;
context.drawImage(canvas, 0, 0);
context.filter = 'none';
this.addToHistory('blur', { radius });
}
/**
* Apply sharpen effect
*/
applySharpen(amount: number): void {
if (!this.editorState.isEditing) return;
const { canvas, context } = this.editorState;
const imageData = context.getImageData(0, 0, canvas.width, canvas.height);
const data = imageData.data;
const width = canvas.width;
const height = canvas.height;
// Create copy of original data
const original = new Uint8ClampedArray(data);
// Sharpen kernel
const kernel = [
0, -1, 0,
-1, 5 + amount / 25, -1,
0, -1, 0
];
// Apply convolution
for (let y = 1; y < height - 1; y++) {
for (let x = 1; x < width - 1; x++) {
const idx = (y * width + x) * 4;
for (let c = 0; c < 3; c++) {
let sum = 0;
for (let ky = -1; ky <= 1; ky++) {
for (let kx = -1; kx <= 1; kx++) {
const kidx = ((y + ky) * width + (x + kx)) * 4;
sum += original[kidx + c] * kernel[(ky + 1) * 3 + (kx + 1)];
}
}
data[idx + c] = Math.min(255, Math.max(0, sum));
}
}
}
context.putImageData(imageData, 0, 0);
this.addToHistory('sharpen', { amount });
}
/**
* Undo last operation
*/
undo(): void {
if (!this.editorState.isEditing || this.editorState.historyIndex <= 0) return;
this.editorState.historyIndex--;
this.replayHistory();
}
/**
* Redo operation
*/
redo(): void {
if (!this.editorState.isEditing ||
this.editorState.historyIndex >= this.editorState.history.length - 1) return;
this.editorState.historyIndex++;
this.replayHistory();
}
/**
* Replay history up to current index
*/
private replayHistory(): void {
const { canvas, context, originalImage, history, historyIndex } = this.editorState;
if (!originalImage) return;
// Reset to original
canvas.width = originalImage.naturalWidth;
canvas.height = originalImage.naturalHeight;
context.drawImage(originalImage, 0, 0);
// Replay operations
for (let i = 0; i <= historyIndex; i++) {
const entry = history[i];
// Apply operation based on entry
// Note: This is simplified - actual implementation would need to store and replay exact operations
}
}
/**
* Add operation to history
*/
private addToHistory(operation: EditOperation, params: any): void {
// Remove any operations after current index
this.editorState.history = this.editorState.history.slice(0, this.editorState.historyIndex + 1);
// Add new operation
this.editorState.history.push({
operation,
params,
timestamp: new Date()
});
this.editorState.historyIndex++;
// Limit history size
if (this.editorState.history.length > 50) {
this.editorState.history.shift();
this.editorState.historyIndex--;
}
}
/**
* Save edited image
*/
async saveImage(noteId?: string): Promise<Blob> {
if (!this.editorState.isEditing) {
throw new ImageError(
ImageErrorType.INVALID_INPUT,
'No image being edited'
);
}
return new Promise((resolve, reject) => {
this.editorState.canvas.toBlob((blob) => {
if (blob) {
resolve(blob);
if (noteId) {
// Optionally save to server
this.saveToServer(noteId, blob);
}
} else {
reject(new Error('Failed to create blob'));
}
}, 'image/png');
});
}
/**
* Save edited image to server
*/
private async saveToServer(noteId: string, blob: Blob): Promise<void> {
try {
const formData = new FormData();
formData.append('image', blob, 'edited.png');
await server.upload(`notes/${noteId}/image`, formData);
toastService.showMessage('Image saved successfully');
} catch (error) {
console.error('Failed to save image:', error);
toastService.showError('Failed to save image');
}
}
/**
* Reset to original image
*/
reset(): void {
if (!this.editorState.isEditing || !this.editorState.originalImage) return;
const { canvas, context, originalImage } = this.editorState;
canvas.width = originalImage.naturalWidth;
canvas.height = originalImage.naturalHeight;
context.drawImage(originalImage, 0, 0);
this.currentFilters = {};
this.editorState.history = [];
this.editorState.historyIndex = -1;
}
/**
* Stop editing and clean up resources
*/
stopEditing(): void {
this.cancelCrop();
// Request garbage collection after cleanup
MemoryMonitor.requestGarbageCollection();
// Clean up canvas memory
if (this.editorState.canvas) {
this.editorState.context.clearRect(0, 0, this.editorState.canvas.width, this.editorState.canvas.height);
this.editorState.canvas.width = 0;
this.editorState.canvas.height = 0;
}
if (this.tempCanvas) {
this.tempContext.clearRect(0, 0, this.tempCanvas.width, this.tempCanvas.height);
this.tempCanvas.width = 0;
this.tempCanvas.height = 0;
}
// Release image references
if (this.editorState.originalImage) {
this.editorState.originalImage.src = '';
}
if (this.editorState.currentImage) {
this.editorState.currentImage.src = '';
}
this.editorState.isEditing = false;
this.editorState.originalImage = null;
this.editorState.currentImage = null;
this.editorState.history = [];
this.editorState.historyIndex = -1;
this.currentFilters = {};
}
/**
* Load image from URL
*/
private loadImage(src: string): Promise<HTMLImageElement> {
return new Promise((resolve, reject) => {
const img = new Image();
img.crossOrigin = 'anonymous';
img.onload = () => resolve(img);
img.onerror = () => reject(new Error(`Failed to load image: ${src}`));
img.src = src;
});
}
/**
* Check if can undo
*/
canUndo(): boolean {
return this.editorState.historyIndex > 0;
}
/**
* Check if can redo
*/
canRedo(): boolean {
return this.editorState.historyIndex < this.editorState.history.length - 1;
}
/**
* Get current canvas
*/
getCanvas(): HTMLCanvasElement {
return this.editorState.canvas;
}
/**
* Check if editing
*/
isEditing(): boolean {
return this.editorState.isEditing;
}
}
export default ImageEditorService.getInstance();

View File

@@ -1,369 +0,0 @@
/**
* Error Handler for Image Processing Operations
* Provides error boundaries and validation for image-related operations
*/
import toastService from './toast.js';
/**
* Error types for image operations
*/
export enum ImageErrorType {
INVALID_INPUT = 'INVALID_INPUT',
SIZE_LIMIT_EXCEEDED = 'SIZE_LIMIT_EXCEEDED',
MEMORY_ERROR = 'MEMORY_ERROR',
PROCESSING_ERROR = 'PROCESSING_ERROR',
NETWORK_ERROR = 'NETWORK_ERROR',
SECURITY_ERROR = 'SECURITY_ERROR'
}
/**
* Custom error class for image operations
*/
export class ImageError extends Error {
constructor(
public type: ImageErrorType,
message: string,
public details?: any
) {
super(message);
this.name = 'ImageError';
}
}
/**
* Input validation utilities
*/
export class ImageValidator {
private static readonly MAX_FILE_SIZE = 100 * 1024 * 1024; // 100MB
private static readonly ALLOWED_MIME_TYPES = [
'image/jpeg',
'image/jpg',
'image/png',
'image/gif',
'image/webp',
'image/svg+xml',
'image/bmp'
];
private static readonly MAX_DIMENSION = 16384;
private static readonly MAX_AREA = 100000000; // 100 megapixels
/**
* Validate file input
*/
static validateFile(file: File): void {
// Check file size
if (file.size > this.MAX_FILE_SIZE) {
throw new ImageError(
ImageErrorType.SIZE_LIMIT_EXCEEDED,
`File size exceeds maximum allowed size of ${this.MAX_FILE_SIZE / 1024 / 1024}MB`,
{ fileSize: file.size, maxSize: this.MAX_FILE_SIZE }
);
}
// Check MIME type
if (!this.ALLOWED_MIME_TYPES.includes(file.type)) {
throw new ImageError(
ImageErrorType.INVALID_INPUT,
`File type ${file.type} is not supported`,
{ fileType: file.type, allowedTypes: this.ALLOWED_MIME_TYPES }
);
}
}
/**
* Validate image dimensions
*/
static validateDimensions(width: number, height: number): void {
if (width <= 0 || height <= 0) {
throw new ImageError(
ImageErrorType.INVALID_INPUT,
'Invalid image dimensions',
{ width, height }
);
}
if (width > this.MAX_DIMENSION || height > this.MAX_DIMENSION) {
throw new ImageError(
ImageErrorType.SIZE_LIMIT_EXCEEDED,
`Image dimensions exceed maximum allowed size of ${this.MAX_DIMENSION}px`,
{ width, height, maxDimension: this.MAX_DIMENSION }
);
}
if (width * height > this.MAX_AREA) {
throw new ImageError(
ImageErrorType.SIZE_LIMIT_EXCEEDED,
`Image area exceeds maximum allowed area of ${this.MAX_AREA / 1000000} megapixels`,
{ area: width * height, maxArea: this.MAX_AREA }
);
}
}
/**
* Validate URL
*/
static validateUrl(url: string): void {
try {
const parsedUrl = new URL(url);
// Check protocol
if (!['http:', 'https:', 'data:', 'blob:'].includes(parsedUrl.protocol)) {
throw new ImageError(
ImageErrorType.SECURITY_ERROR,
`Unsupported protocol: ${parsedUrl.protocol}`,
{ url, protocol: parsedUrl.protocol }
);
}
// Additional security checks for data URLs
if (parsedUrl.protocol === 'data:') {
const [header] = url.split(',');
if (!header.includes('image/')) {
throw new ImageError(
ImageErrorType.INVALID_INPUT,
'Data URL does not contain image data',
{ url: url.substring(0, 100) }
);
}
}
} catch (error) {
if (error instanceof ImageError) {
throw error;
}
throw new ImageError(
ImageErrorType.INVALID_INPUT,
'Invalid URL format',
{ url, originalError: error }
);
}
}
/**
* Sanitize filename
*/
static sanitizeFilename(filename: string): string {
// Remove path traversal attempts
filename = filename.replace(/\.\./g, '');
filename = filename.replace(/[\/\\]/g, '_');
// Remove special characters except dots and dashes
filename = filename.replace(/[^a-zA-Z0-9._-]/g, '_');
// Limit length
if (filename.length > 255) {
const ext = filename.split('.').pop();
filename = filename.substring(0, 250) + '.' + ext;
}
return filename;
}
}
/**
* Error boundary wrapper for async operations
*/
export async function withErrorBoundary<T>(
operation: () => Promise<T>,
errorHandler?: (error: Error) => void
): Promise<T | null> {
try {
return await operation();
} catch (error) {
const imageError = error instanceof ImageError
? error
: new ImageError(
ImageErrorType.PROCESSING_ERROR,
error instanceof Error ? error.message : 'Unknown error occurred',
{ originalError: error }
);
// Log error
console.error('[Image Error]', imageError.type, imageError.message, imageError.details);
// Show user-friendly message
switch (imageError.type) {
case ImageErrorType.SIZE_LIMIT_EXCEEDED:
toastService.showError('Image is too large to process');
break;
case ImageErrorType.INVALID_INPUT:
toastService.showError('Invalid image or input provided');
break;
case ImageErrorType.MEMORY_ERROR:
toastService.showError('Not enough memory to process image');
break;
case ImageErrorType.SECURITY_ERROR:
toastService.showError('Security violation detected');
break;
case ImageErrorType.NETWORK_ERROR:
toastService.showError('Network error occurred');
break;
default:
toastService.showError('Failed to process image');
}
// Call custom error handler if provided
if (errorHandler) {
errorHandler(imageError);
}
return null;
}
}
/**
* Memory monitoring utilities
*/
export class MemoryMonitor {
private static readonly WARNING_THRESHOLD = 0.8; // 80% of available memory
/**
* Check if memory is available for operation
*/
static checkMemoryAvailable(estimatedBytes: number): boolean {
if ('memory' in performance && (performance as any).memory) {
const memory = (performance as any).memory;
const used = memory.usedJSHeapSize;
const limit = memory.jsHeapSizeLimit;
const available = limit - used;
if (estimatedBytes > available * this.WARNING_THRESHOLD) {
console.warn(`Memory warning: Estimated ${estimatedBytes} bytes needed, ${available} bytes available`);
return false;
}
}
return true;
}
/**
* Estimate memory needed for image
*/
static estimateImageMemory(width: number, height: number, channels: number = 4): number {
// Each pixel uses 4 bytes (RGBA) or specified channels
return width * height * channels;
}
/**
* Force garbage collection if available
*/
static requestGarbageCollection(): void {
if (typeof (globalThis as any).gc === 'function') {
(globalThis as any).gc();
}
}
}
/**
* Web Worker support for heavy operations
*/
export class ImageWorkerPool {
private workers: Worker[] = [];
private taskQueue: Array<{
data: any;
resolve: (value: any) => void;
reject: (error: any) => void;
}> = [];
private busyWorkers = new Set<Worker>();
constructor(
private workerScript: string,
private poolSize: number = navigator.hardwareConcurrency || 4
) {
this.initializeWorkers();
}
private initializeWorkers(): void {
for (let i = 0; i < this.poolSize; i++) {
try {
const worker = new Worker(this.workerScript);
worker.addEventListener('message', (e) => this.handleWorkerMessage(worker, e));
worker.addEventListener('error', (e) => this.handleWorkerError(worker, e));
this.workers.push(worker);
} catch (error) {
console.error('Failed to create worker:', error);
}
}
}
private handleWorkerMessage(worker: Worker, event: MessageEvent): void {
this.busyWorkers.delete(worker);
// Process next task if available
if (this.taskQueue.length > 0) {
const task = this.taskQueue.shift()!;
this.executeTask(worker, task);
}
}
private handleWorkerError(worker: Worker, event: ErrorEvent): void {
this.busyWorkers.delete(worker);
console.error('Worker error:', event);
}
private executeTask(
worker: Worker,
task: { data: any; resolve: (value: any) => void; reject: (error: any) => void }
): void {
this.busyWorkers.add(worker);
const messageHandler = (e: MessageEvent) => {
worker.removeEventListener('message', messageHandler);
worker.removeEventListener('error', errorHandler);
this.busyWorkers.delete(worker);
task.resolve(e.data);
// Process next task
if (this.taskQueue.length > 0) {
const nextTask = this.taskQueue.shift()!;
this.executeTask(worker, nextTask);
}
};
const errorHandler = (e: ErrorEvent) => {
worker.removeEventListener('message', messageHandler);
worker.removeEventListener('error', errorHandler);
this.busyWorkers.delete(worker);
task.reject(e);
// Process next task
if (this.taskQueue.length > 0) {
const nextTask = this.taskQueue.shift()!;
this.executeTask(worker, nextTask);
}
};
worker.addEventListener('message', messageHandler);
worker.addEventListener('error', errorHandler);
worker.postMessage(task.data);
}
async process(data: any): Promise<any> {
return new Promise((resolve, reject) => {
// Find available worker
const availableWorker = this.workers.find(w => !this.busyWorkers.has(w));
if (availableWorker) {
this.executeTask(availableWorker, { data, resolve, reject });
} else {
// Queue task
this.taskQueue.push({ data, resolve, reject });
}
});
}
terminate(): void {
this.workers.forEach(worker => worker.terminate());
this.workers = [];
this.taskQueue = [];
this.busyWorkers.clear();
}
}
export default {
ImageError,
ImageErrorType,
ImageValidator,
MemoryMonitor,
ImageWorkerPool,
withErrorBoundary
};

View File

@@ -1,839 +0,0 @@
/**
* EXIF Data Viewer Module for Trilium Notes
* Extracts and displays EXIF metadata from images
*/
/**
* EXIF data structure
*/
export interface ExifData {
// Image information
make?: string;
model?: string;
software?: string;
dateTime?: Date;
dateTimeOriginal?: Date;
dateTimeDigitized?: Date;
// Camera settings
exposureTime?: string;
fNumber?: number;
exposureProgram?: string;
iso?: number;
shutterSpeedValue?: string;
apertureValue?: number;
brightnessValue?: number;
exposureBiasValue?: number;
maxApertureValue?: number;
meteringMode?: string;
flash?: string;
focalLength?: number;
focalLengthIn35mm?: number;
// Image properties
imageWidth?: number;
imageHeight?: number;
orientation?: number;
xResolution?: number;
yResolution?: number;
resolutionUnit?: string;
colorSpace?: string;
whiteBalance?: string;
// GPS information
gpsLatitude?: number;
gpsLongitude?: number;
gpsAltitude?: number;
gpsTimestamp?: Date;
gpsSpeed?: number;
gpsDirection?: number;
// Other metadata
artist?: string;
copyright?: string;
userComment?: string;
imageDescription?: string;
lensModel?: string;
lensMake?: string;
// Raw data
raw?: Record<string, any>;
}
/**
* EXIF tag definitions
*/
const EXIF_TAGS: Record<number, string> = {
0x010F: 'make',
0x0110: 'model',
0x0131: 'software',
0x0132: 'dateTime',
0x829A: 'exposureTime',
0x829D: 'fNumber',
0x8822: 'exposureProgram',
0x8827: 'iso',
0x9003: 'dateTimeOriginal',
0x9004: 'dateTimeDigitized',
0x9201: 'shutterSpeedValue',
0x9202: 'apertureValue',
0x9203: 'brightnessValue',
0x9204: 'exposureBiasValue',
0x9205: 'maxApertureValue',
0x9207: 'meteringMode',
0x9209: 'flash',
0x920A: 'focalLength',
0xA002: 'imageWidth',
0xA003: 'imageHeight',
0x0112: 'orientation',
0x011A: 'xResolution',
0x011B: 'yResolution',
0x0128: 'resolutionUnit',
0xA001: 'colorSpace',
0xA403: 'whiteBalance',
0x8298: 'copyright',
0x013B: 'artist',
0x9286: 'userComment',
0x010E: 'imageDescription',
0xA434: 'lensModel',
0xA433: 'lensMake',
0xA432: 'focalLengthIn35mm'
};
/**
* GPS tag definitions
*/
const GPS_TAGS: Record<number, string> = {
0x0001: 'gpsLatitudeRef',
0x0002: 'gpsLatitude',
0x0003: 'gpsLongitudeRef',
0x0004: 'gpsLongitude',
0x0005: 'gpsAltitudeRef',
0x0006: 'gpsAltitude',
0x0007: 'gpsTimestamp',
0x000D: 'gpsSpeed',
0x0010: 'gpsDirection'
};
/**
* ImageExifService extracts and manages EXIF metadata from images
*/
class ImageExifService {
private static instance: ImageExifService;
private exifCache: Map<string, ExifData> = new Map();
private cacheOrder: string[] = []; // Track cache insertion order for LRU
private readonly MAX_CACHE_SIZE = 50; // Maximum number of cached entries
private readonly MAX_BUFFER_SIZE = 100 * 1024 * 1024; // 100MB max buffer size
private constructor() {}
static getInstance(): ImageExifService {
if (!ImageExifService.instance) {
ImageExifService.instance = new ImageExifService();
}
return ImageExifService.instance;
}
/**
* Extract EXIF data from image URL or file
*/
async extractExifData(source: string | File | Blob): Promise<ExifData | null> {
try {
// Check cache if URL
if (typeof source === 'string' && this.exifCache.has(source)) {
// Move to end for LRU
this.updateCacheOrder(source);
return this.exifCache.get(source)!;
}
// Get array buffer with size validation
const buffer = await this.getArrayBuffer(source);
// Validate buffer size
if (buffer.byteLength > this.MAX_BUFFER_SIZE) {
console.error('Buffer size exceeds maximum allowed size');
return null;
}
// Parse EXIF data
const exifData = this.parseExifData(buffer);
// Cache if URL with LRU eviction
if (typeof source === 'string' && exifData) {
this.addToCache(source, exifData);
}
return exifData;
} catch (error) {
console.error('Failed to extract EXIF data:', error);
return null;
}
}
/**
* Get array buffer from various sources
*/
private async getArrayBuffer(source: string | File | Blob): Promise<ArrayBuffer> {
if (source instanceof File || source instanceof Blob) {
return source.arrayBuffer();
} else {
const response = await fetch(source);
return response.arrayBuffer();
}
}
/**
* Parse EXIF data from array buffer
*/
private parseExifData(buffer: ArrayBuffer): ExifData | null {
const dataView = new DataView(buffer);
// Check for JPEG SOI marker
if (dataView.getUint16(0) !== 0xFFD8) {
return null; // Not a JPEG
}
// Find APP1 marker (EXIF)
let offset = 2;
let marker;
while (offset < dataView.byteLength) {
marker = dataView.getUint16(offset);
if (marker === 0xFFE1) {
// Found EXIF marker
return this.parseExifSegment(dataView, offset + 2);
}
if ((marker & 0xFF00) !== 0xFF00) {
break; // Invalid marker
}
offset += 2 + dataView.getUint16(offset + 2);
}
return null;
}
/**
* Parse EXIF segment with bounds checking
*/
private parseExifSegment(dataView: DataView, offset: number): ExifData | null {
// Bounds check
if (offset + 2 > dataView.byteLength) {
console.error('Invalid offset for EXIF segment');
return null;
}
const length = dataView.getUint16(offset);
// Validate segment length
if (offset + length > dataView.byteLength) {
console.error('EXIF segment length exceeds buffer size');
return null;
}
// Check for "Exif\0\0" identifier with bounds check
if (offset + 6 > dataView.byteLength) {
console.error('Invalid EXIF header offset');
return null;
}
const exifHeader = String.fromCharCode(
dataView.getUint8(offset + 2),
dataView.getUint8(offset + 3),
dataView.getUint8(offset + 4),
dataView.getUint8(offset + 5)
);
if (exifHeader !== 'Exif') {
return null;
}
// TIFF header offset
const tiffOffset = offset + 8;
// Check byte order
const byteOrder = dataView.getUint16(tiffOffset);
const littleEndian = byteOrder === 0x4949; // 'II' for Intel
if (byteOrder !== 0x4949 && byteOrder !== 0x4D4D) {
return null; // Invalid byte order
}
// Parse IFD
const ifdOffset = this.getUint32(dataView, tiffOffset + 4, littleEndian);
const exifData = this.parseIFD(dataView, tiffOffset, tiffOffset + ifdOffset, littleEndian);
// Parse GPS data if available
if (exifData.raw?.gpsIFDPointer) {
const gpsData = this.parseGPSIFD(
dataView,
tiffOffset,
tiffOffset + exifData.raw.gpsIFDPointer,
littleEndian
);
Object.assign(exifData, gpsData);
}
return this.formatExifData(exifData);
}
/**
* Parse IFD (Image File Directory) with bounds checking
*/
private parseIFD(
dataView: DataView,
tiffOffset: number,
ifdOffset: number,
littleEndian: boolean
): ExifData {
// Bounds check for IFD offset
if (ifdOffset + 2 > dataView.byteLength) {
console.error('Invalid IFD offset');
return { raw: {} };
}
const numEntries = this.getUint16(dataView, ifdOffset, littleEndian);
// Validate number of entries
if (numEntries > 1000) { // Reasonable limit
console.error('Too many IFD entries');
return { raw: {} };
}
const data: ExifData = { raw: {} };
for (let i = 0; i < numEntries; i++) {
const entryOffset = ifdOffset + 2 + (i * 12);
// Bounds check for entry
if (entryOffset + 12 > dataView.byteLength) {
console.warn('IFD entry exceeds buffer bounds');
break;
}
const tag = this.getUint16(dataView, entryOffset, littleEndian);
const type = this.getUint16(dataView, entryOffset + 2, littleEndian);
const count = this.getUint32(dataView, entryOffset + 4, littleEndian);
const valueOffset = entryOffset + 8;
const value = this.getTagValue(
dataView,
tiffOffset,
type,
count,
valueOffset,
littleEndian
);
const tagName = EXIF_TAGS[tag];
if (tagName) {
(data as any)[tagName] = value;
}
// Store raw value
data.raw![tag] = value;
// Check for EXIF IFD pointer
if (tag === 0x8769) {
const exifIFDOffset = tiffOffset + value;
const exifData = this.parseIFD(dataView, tiffOffset, exifIFDOffset, littleEndian);
Object.assign(data, exifData);
}
// Store GPS IFD pointer
if (tag === 0x8825) {
data.raw!.gpsIFDPointer = value;
}
}
return data;
}
/**
* Parse GPS IFD
*/
private parseGPSIFD(
dataView: DataView,
tiffOffset: number,
ifdOffset: number,
littleEndian: boolean
): Partial<ExifData> {
const numEntries = this.getUint16(dataView, ifdOffset, littleEndian);
const gpsData: any = {};
for (let i = 0; i < numEntries; i++) {
const entryOffset = ifdOffset + 2 + (i * 12);
// Bounds check for entry
if (entryOffset + 12 > dataView.byteLength) {
console.warn('IFD entry exceeds buffer bounds');
break;
}
const tag = this.getUint16(dataView, entryOffset, littleEndian);
const type = this.getUint16(dataView, entryOffset + 2, littleEndian);
const count = this.getUint32(dataView, entryOffset + 4, littleEndian);
const valueOffset = entryOffset + 8;
const value = this.getTagValue(
dataView,
tiffOffset,
type,
count,
valueOffset,
littleEndian
);
const tagName = GPS_TAGS[tag];
if (tagName) {
gpsData[tagName] = value;
}
}
// Convert GPS coordinates
const result: Partial<ExifData> = {};
if (gpsData.gpsLatitude && gpsData.gpsLatitudeRef) {
result.gpsLatitude = this.convertGPSCoordinate(
gpsData.gpsLatitude,
gpsData.gpsLatitudeRef
);
}
if (gpsData.gpsLongitude && gpsData.gpsLongitudeRef) {
result.gpsLongitude = this.convertGPSCoordinate(
gpsData.gpsLongitude,
gpsData.gpsLongitudeRef
);
}
if (gpsData.gpsAltitude) {
result.gpsAltitude = gpsData.gpsAltitude;
}
return result;
}
/**
* Get tag value based on type
*/
private getTagValue(
dataView: DataView,
tiffOffset: number,
type: number,
count: number,
offset: number,
littleEndian: boolean
): any {
switch (type) {
case 1: // BYTE
case 7: // UNDEFINED
if (count === 1) {
return dataView.getUint8(offset);
}
const bytes = [];
for (let i = 0; i < count; i++) {
bytes.push(dataView.getUint8(offset + i));
}
return bytes;
case 2: // ASCII
const stringOffset = count > 4
? tiffOffset + this.getUint32(dataView, offset, littleEndian)
: offset;
let str = '';
for (let i = 0; i < count - 1; i++) {
const char = dataView.getUint8(stringOffset + i);
if (char === 0) break;
str += String.fromCharCode(char);
}
return str;
case 3: // SHORT
if (count === 1) {
return this.getUint16(dataView, offset, littleEndian);
}
const shorts = [];
const shortOffset = count > 2
? tiffOffset + this.getUint32(dataView, offset, littleEndian)
: offset;
for (let i = 0; i < count; i++) {
shorts.push(this.getUint16(dataView, shortOffset + i * 2, littleEndian));
}
return shorts;
case 4: // LONG
if (count === 1) {
return this.getUint32(dataView, offset, littleEndian);
}
const longs = [];
const longOffset = tiffOffset + this.getUint32(dataView, offset, littleEndian);
for (let i = 0; i < count; i++) {
longs.push(this.getUint32(dataView, longOffset + i * 4, littleEndian));
}
return longs;
case 5: // RATIONAL
const ratOffset = tiffOffset + this.getUint32(dataView, offset, littleEndian);
if (count === 1) {
const num = this.getUint32(dataView, ratOffset, littleEndian);
const den = this.getUint32(dataView, ratOffset + 4, littleEndian);
return den === 0 ? 0 : num / den;
}
const rationals = [];
for (let i = 0; i < count; i++) {
const num = this.getUint32(dataView, ratOffset + i * 8, littleEndian);
const den = this.getUint32(dataView, ratOffset + i * 8 + 4, littleEndian);
rationals.push(den === 0 ? 0 : num / den);
}
return rationals;
default:
return null;
}
}
/**
* Convert GPS coordinate to decimal degrees
*/
private convertGPSCoordinate(coord: number[], ref: string): number {
if (!coord || coord.length !== 3) return 0;
const degrees = coord[0];
const minutes = coord[1];
const seconds = coord[2];
let decimal = degrees + minutes / 60 + seconds / 3600;
if (ref === 'S' || ref === 'W') {
decimal = -decimal;
}
return decimal;
}
/**
* Format EXIF data for display
*/
private formatExifData(data: ExifData): ExifData {
const formatted: ExifData = { ...data };
// Format dates
if (formatted.dateTime) {
formatted.dateTime = this.parseExifDate(formatted.dateTime as any);
}
if (formatted.dateTimeOriginal) {
formatted.dateTimeOriginal = this.parseExifDate(formatted.dateTimeOriginal as any);
}
if (formatted.dateTimeDigitized) {
formatted.dateTimeDigitized = this.parseExifDate(formatted.dateTimeDigitized as any);
}
// Format exposure time
if (formatted.exposureTime) {
const time = formatted.exposureTime as any;
if (typeof time === 'number') {
if (time < 1) {
formatted.exposureTime = `1/${Math.round(1 / time)}`;
} else {
formatted.exposureTime = `${time}s`;
}
}
}
// Format exposure program
if (formatted.exposureProgram) {
const programs = [
'Not defined',
'Manual',
'Normal program',
'Aperture priority',
'Shutter priority',
'Creative program',
'Action program',
'Portrait mode',
'Landscape mode'
];
const index = formatted.exposureProgram as any;
formatted.exposureProgram = programs[index] || 'Unknown';
}
// Format metering mode
if (formatted.meteringMode) {
const modes = [
'Unknown',
'Average',
'Center-weighted average',
'Spot',
'Multi-spot',
'Pattern',
'Partial'
];
const index = formatted.meteringMode as any;
formatted.meteringMode = modes[index] || 'Unknown';
}
// Format flash
if (formatted.flash !== undefined) {
const flash = formatted.flash as any;
formatted.flash = (flash & 1) ? 'Flash fired' : 'Flash did not fire';
}
return formatted;
}
/**
* Parse EXIF date string
*/
private parseExifDate(dateStr: string): Date {
// EXIF date format: "YYYY:MM:DD HH:MM:SS"
const parts = dateStr.split(' ');
if (parts.length !== 2) return new Date(dateStr);
const dateParts = parts[0].split(':');
const timeParts = parts[1].split(':');
if (dateParts.length !== 3 || timeParts.length !== 3) {
return new Date(dateStr);
}
return new Date(
parseInt(dateParts[0]),
parseInt(dateParts[1]) - 1,
parseInt(dateParts[2]),
parseInt(timeParts[0]),
parseInt(timeParts[1]),
parseInt(timeParts[2])
);
}
/**
* Get uint16 with endianness and bounds checking
*/
private getUint16(dataView: DataView, offset: number, littleEndian: boolean): number {
if (offset + 2 > dataView.byteLength) {
console.error('Uint16 read exceeds buffer bounds');
return 0;
}
return dataView.getUint16(offset, littleEndian);
}
/**
* Get uint32 with endianness and bounds checking
*/
private getUint32(dataView: DataView, offset: number, littleEndian: boolean): number {
if (offset + 4 > dataView.byteLength) {
console.error('Uint32 read exceeds buffer bounds');
return 0;
}
return dataView.getUint32(offset, littleEndian);
}
/**
* Create EXIF display panel
*/
createExifPanel(exifData: ExifData): HTMLElement {
const panel = document.createElement('div');
panel.className = 'exif-panel';
panel.style.cssText = `
background: rgba(0, 0, 0, 0.9);
color: white;
padding: 15px;
border-radius: 8px;
max-width: 400px;
max-height: 500px;
overflow-y: auto;
font-size: 12px;
`;
const sections = [
{
title: 'Camera',
fields: ['make', 'model', 'lensModel']
},
{
title: 'Settings',
fields: ['exposureTime', 'fNumber', 'iso', 'focalLength', 'exposureProgram', 'meteringMode', 'flash']
},
{
title: 'Image',
fields: ['imageWidth', 'imageHeight', 'orientation', 'colorSpace', 'whiteBalance']
},
{
title: 'Date/Time',
fields: ['dateTimeOriginal', 'dateTime']
},
{
title: 'Location',
fields: ['gpsLatitude', 'gpsLongitude', 'gpsAltitude']
},
{
title: 'Other',
fields: ['software', 'artist', 'copyright', 'imageDescription']
}
];
sections.forEach(section => {
const hasData = section.fields.some(field => (exifData as any)[field]);
if (!hasData) return;
const sectionDiv = document.createElement('div');
sectionDiv.style.marginBottom = '15px';
const title = document.createElement('h4');
// Use textContent for safe title insertion
title.textContent = section.title;
title.style.cssText = 'margin: 0 0 8px 0; color: #4CAF50;';
title.setAttribute('aria-label', `Section: ${section.title}`);
sectionDiv.appendChild(title);
section.fields.forEach(field => {
const value = (exifData as any)[field];
if (!value) return;
const row = document.createElement('div');
row.style.cssText = 'display: flex; justify-content: space-between; margin: 4px 0;';
const label = document.createElement('span');
// Use textContent for safe text insertion
label.textContent = this.formatFieldName(field) + ':';
label.style.color = '#aaa';
const val = document.createElement('span');
// Use textContent for safe value insertion
val.textContent = this.formatFieldValue(field, value);
val.style.textAlign = 'right';
row.appendChild(label);
row.appendChild(val);
sectionDiv.appendChild(row);
});
panel.appendChild(sectionDiv);
});
// Add GPS map link if coordinates available
if (exifData.gpsLatitude && exifData.gpsLongitude) {
const mapLink = document.createElement('a');
mapLink.href = `https://www.google.com/maps?q=${exifData.gpsLatitude},${exifData.gpsLongitude}`;
mapLink.target = '_blank';
mapLink.textContent = 'View on Map';
mapLink.style.cssText = `
display: inline-block;
margin-top: 10px;
padding: 8px 12px;
background: #4CAF50;
color: white;
text-decoration: none;
border-radius: 4px;
`;
panel.appendChild(mapLink);
}
return panel;
}
/**
* Format field name for display
*/
private formatFieldName(field: string): string {
const names: Record<string, string> = {
make: 'Camera Make',
model: 'Camera Model',
lensModel: 'Lens',
exposureTime: 'Shutter Speed',
fNumber: 'Aperture',
iso: 'ISO',
focalLength: 'Focal Length',
exposureProgram: 'Mode',
meteringMode: 'Metering',
flash: 'Flash',
imageWidth: 'Width',
imageHeight: 'Height',
orientation: 'Orientation',
colorSpace: 'Color Space',
whiteBalance: 'White Balance',
dateTimeOriginal: 'Date Taken',
dateTime: 'Date Modified',
gpsLatitude: 'Latitude',
gpsLongitude: 'Longitude',
gpsAltitude: 'Altitude',
software: 'Software',
artist: 'Artist',
copyright: 'Copyright',
imageDescription: 'Description'
};
return names[field] || field;
}
/**
* Format field value for display
*/
private formatFieldValue(field: string, value: any): string {
if (value instanceof Date) {
return value.toLocaleString();
}
switch (field) {
case 'fNumber':
return `f/${value}`;
case 'focalLength':
return `${value}mm`;
case 'gpsLatitude':
case 'gpsLongitude':
return value.toFixed(6) + '°';
case 'gpsAltitude':
return `${value.toFixed(1)}m`;
case 'imageWidth':
case 'imageHeight':
return `${value}px`;
default:
return String(value);
}
}
/**
* Add to cache with LRU eviction
*/
private addToCache(key: string, data: ExifData): void {
// Remove from order if exists
const existingIndex = this.cacheOrder.indexOf(key);
if (existingIndex !== -1) {
this.cacheOrder.splice(existingIndex, 1);
}
// Add to end
this.cacheOrder.push(key);
this.exifCache.set(key, data);
// Evict oldest if over limit
while (this.cacheOrder.length > this.MAX_CACHE_SIZE) {
const oldestKey = this.cacheOrder.shift();
if (oldestKey) {
this.exifCache.delete(oldestKey);
}
}
}
/**
* Update cache order for LRU
*/
private updateCacheOrder(key: string): void {
const index = this.cacheOrder.indexOf(key);
if (index !== -1) {
this.cacheOrder.splice(index, 1);
this.cacheOrder.push(key);
}
}
/**
* Clear EXIF cache
*/
clearCache(): void {
this.exifCache.clear();
this.cacheOrder = [];
}
}
export default ImageExifService.getInstance();

View File

@@ -1,681 +0,0 @@
/**
* Image Sharing and Export Module for Trilium Notes
* Provides functionality for sharing, downloading, and exporting images
*/
import server from './server.js';
import utils from './utils.js';
import toastService from './toast.js';
import type FNote from '../entities/fnote.js';
import { ImageValidator, withErrorBoundary, MemoryMonitor, ImageError, ImageErrorType } from './image_error_handler.js';
/**
* Export format options
*/
export type ExportFormat = 'original' | 'jpeg' | 'png' | 'webp';
/**
* Export size presets
*/
export type SizePreset = 'original' | 'thumbnail' | 'small' | 'medium' | 'large' | 'custom';
/**
* Export configuration
*/
export interface ExportConfig {
format: ExportFormat;
quality: number; // 0-100 for JPEG/WebP
size: SizePreset;
customWidth?: number;
customHeight?: number;
maintainAspectRatio: boolean;
addWatermark: boolean;
watermarkText?: string;
watermarkPosition?: 'top-left' | 'top-right' | 'bottom-left' | 'bottom-right' | 'center';
watermarkOpacity?: number;
}
/**
* Share options
*/
export interface ShareOptions {
method: 'link' | 'email' | 'social';
expiresIn?: number; // Hours
password?: string;
allowDownload: boolean;
trackViews: boolean;
}
/**
* Share link data
*/
export interface ShareLink {
url: string;
shortUrl?: string;
expiresAt?: Date;
password?: string;
views: number;
maxViews?: number;
created: Date;
}
/**
* Size presets in pixels
*/
const SIZE_PRESETS = {
thumbnail: { width: 150, height: 150 },
small: { width: 400, height: 400 },
medium: { width: 800, height: 800 },
large: { width: 1600, height: 1600 }
};
/**
* ImageSharingService handles image sharing, downloading, and exporting
*/
class ImageSharingService {
private static instance: ImageSharingService;
private activeShares: Map<string, ShareLink> = new Map();
private downloadCanvas?: HTMLCanvasElement;
private downloadContext?: CanvasRenderingContext2D;
// Canvas size limits for security and memory management
private readonly MAX_CANVAS_SIZE = 8192; // Maximum width/height
private readonly MAX_CANVAS_AREA = 50000000; // 50 megapixels
private defaultExportConfig: ExportConfig = {
format: 'original',
quality: 90,
size: 'original',
maintainAspectRatio: true,
addWatermark: false,
watermarkPosition: 'bottom-right',
watermarkOpacity: 0.5
};
private constructor() {
// Initialize download canvas
this.downloadCanvas = document.createElement('canvas');
this.downloadContext = this.downloadCanvas.getContext('2d') || undefined;
}
static getInstance(): ImageSharingService {
if (!ImageSharingService.instance) {
ImageSharingService.instance = new ImageSharingService();
}
return ImageSharingService.instance;
}
/**
* Download image with options
*/
async downloadImage(
src: string,
filename: string,
config?: Partial<ExportConfig>
): Promise<void> {
await withErrorBoundary(async () => {
// Validate inputs
ImageValidator.validateUrl(src);
const sanitizedFilename = ImageValidator.sanitizeFilename(filename);
const finalConfig = { ...this.defaultExportConfig, ...config };
// Load image
const img = await this.loadImage(src);
// Process image based on config
const processedBlob = await this.processImage(img, finalConfig);
// Create download link
const url = URL.createObjectURL(processedBlob);
const link = document.createElement('a');
link.href = url;
// Determine filename with extension
const extension = finalConfig.format === 'original'
? this.getOriginalExtension(sanitizedFilename)
: finalConfig.format;
const finalFilename = this.ensureExtension(sanitizedFilename, extension);
link.download = finalFilename;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
// Cleanup
URL.revokeObjectURL(url);
toastService.showMessage(`Downloaded ${finalFilename}`);
});
}
/**
* Process image according to export configuration
*/
private async processImage(img: HTMLImageElement, config: ExportConfig): Promise<Blob> {
if (!this.downloadCanvas || !this.downloadContext) {
throw new Error('Canvas not initialized');
}
// Calculate dimensions
const { width, height } = this.calculateDimensions(
img.naturalWidth,
img.naturalHeight,
config
);
// Validate canvas dimensions
ImageValidator.validateDimensions(width, height);
// Check memory availability
const estimatedMemory = MemoryMonitor.estimateImageMemory(width, height);
if (!MemoryMonitor.checkMemoryAvailable(estimatedMemory)) {
throw new ImageError(
ImageErrorType.MEMORY_ERROR,
'Insufficient memory to process image',
{ width, height, estimatedMemory }
);
}
// Set canvas size
this.downloadCanvas.width = width;
this.downloadCanvas.height = height;
// Clear canvas
this.downloadContext.fillStyle = 'white';
this.downloadContext.fillRect(0, 0, width, height);
// Draw image
this.downloadContext.drawImage(img, 0, 0, width, height);
// Add watermark if enabled
if (config.addWatermark && config.watermarkText) {
this.addWatermark(this.downloadContext, width, height, config);
}
// Convert to blob
return new Promise((resolve, reject) => {
const mimeType = this.getMimeType(config.format);
const quality = config.quality / 100;
this.downloadCanvas!.toBlob(
(blob) => {
if (blob) {
resolve(blob);
} else {
reject(new Error('Failed to create blob'));
}
},
mimeType,
quality
);
});
}
/**
* Calculate dimensions based on size preset
*/
private calculateDimensions(
originalWidth: number,
originalHeight: number,
config: ExportConfig
): { width: number; height: number } {
if (config.size === 'original') {
return { width: originalWidth, height: originalHeight };
}
if (config.size === 'custom' && config.customWidth && config.customHeight) {
if (config.maintainAspectRatio) {
const aspectRatio = originalWidth / originalHeight;
const targetRatio = config.customWidth / config.customHeight;
if (aspectRatio > targetRatio) {
return {
width: config.customWidth,
height: Math.round(config.customWidth / aspectRatio)
};
} else {
return {
width: Math.round(config.customHeight * aspectRatio),
height: config.customHeight
};
}
}
return { width: config.customWidth, height: config.customHeight };
}
// Use preset
const preset = SIZE_PRESETS[config.size as keyof typeof SIZE_PRESETS];
if (!preset) {
return { width: originalWidth, height: originalHeight };
}
if (config.maintainAspectRatio) {
const aspectRatio = originalWidth / originalHeight;
const maxWidth = preset.width;
const maxHeight = preset.height;
let width = originalWidth;
let height = originalHeight;
if (width > maxWidth) {
width = maxWidth;
height = Math.round(width / aspectRatio);
}
if (height > maxHeight) {
height = maxHeight;
width = Math.round(height * aspectRatio);
}
return { width, height };
}
return preset;
}
/**
* Add watermark to canvas
*/
private addWatermark(
ctx: CanvasRenderingContext2D,
width: number,
height: number,
config: ExportConfig
): void {
if (!config.watermarkText) return;
ctx.save();
// Set watermark style
ctx.globalAlpha = config.watermarkOpacity || 0.5;
ctx.fillStyle = 'white';
ctx.strokeStyle = 'black';
ctx.lineWidth = 2;
ctx.font = `${Math.min(width, height) * 0.05}px Arial`;
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
// Calculate position
let x = width / 2;
let y = height / 2;
switch (config.watermarkPosition) {
case 'top-left':
x = width * 0.1;
y = height * 0.1;
ctx.textAlign = 'left';
break;
case 'top-right':
x = width * 0.9;
y = height * 0.1;
ctx.textAlign = 'right';
break;
case 'bottom-left':
x = width * 0.1;
y = height * 0.9;
ctx.textAlign = 'left';
break;
case 'bottom-right':
x = width * 0.9;
y = height * 0.9;
ctx.textAlign = 'right';
break;
}
// Draw watermark with outline
ctx.strokeText(config.watermarkText, x, y);
ctx.fillText(config.watermarkText, x, y);
ctx.restore();
}
/**
* Generate shareable link for image
*/
async generateShareLink(
noteId: string,
options?: Partial<ShareOptions>
): Promise<ShareLink> {
try {
const finalOptions = {
method: 'link' as const,
allowDownload: true,
trackViews: false,
...options
};
// Create share token on server
const response = await server.post(`notes/${noteId}/share`, {
type: 'image',
expiresIn: finalOptions.expiresIn,
password: finalOptions.password,
allowDownload: finalOptions.allowDownload,
trackViews: finalOptions.trackViews
});
const shareLink: ShareLink = {
url: `${window.location.origin}/share/${response.token}`,
shortUrl: response.shortUrl,
expiresAt: response.expiresAt ? new Date(response.expiresAt) : undefined,
password: finalOptions.password,
views: 0,
maxViews: response.maxViews,
created: new Date()
};
// Store in active shares
this.activeShares.set(response.token, shareLink);
return shareLink;
} catch (error) {
console.error('Failed to generate share link:', error);
throw error;
}
}
/**
* Copy image or link to clipboard
*/
async copyToClipboard(
src: string,
type: 'image' | 'link' = 'link'
): Promise<void> {
await withErrorBoundary(async () => {
// Validate URL
ImageValidator.validateUrl(src);
if (type === 'link') {
// Copy URL to clipboard
await navigator.clipboard.writeText(src);
toastService.showMessage('Link copied to clipboard');
} else {
// Copy image data to clipboard
const img = await this.loadImage(src);
if (!this.downloadCanvas || !this.downloadContext) {
throw new Error('Canvas not initialized');
}
// Validate dimensions before setting
ImageValidator.validateDimensions(img.naturalWidth, img.naturalHeight);
this.downloadCanvas.width = img.naturalWidth;
this.downloadCanvas.height = img.naturalHeight;
this.downloadContext.drawImage(img, 0, 0);
this.downloadCanvas.toBlob(async (blob) => {
if (blob) {
try {
const item = new ClipboardItem({ 'image/png': blob });
await navigator.clipboard.write([item]);
toastService.showMessage('Image copied to clipboard');
} catch (error) {
console.error('Failed to copy image to clipboard:', error);
// Fallback to copying link
await navigator.clipboard.writeText(src);
toastService.showMessage('Image link copied to clipboard');
}
}
});
}
});
}
/**
* Share via native share API (mobile)
*/
async shareNative(
src: string,
title: string,
text?: string
): Promise<void> {
if (!navigator.share) {
throw new Error('Native share not supported');
}
try {
// Try to share with file
const img = await this.loadImage(src);
const blob = await this.processImage(img, this.defaultExportConfig);
const file = new File([blob], `${title}.${this.defaultExportConfig.format}`, {
type: this.getMimeType(this.defaultExportConfig.format)
});
await navigator.share({
title,
text: text || `Check out this image: ${title}`,
files: [file]
});
} catch (error) {
// Fallback to sharing URL
try {
await navigator.share({
title,
text: text || `Check out this image: ${title}`,
url: src
});
} catch (shareError) {
console.error('Failed to share:', shareError);
throw shareError;
}
}
}
/**
* Export multiple images as ZIP
*/
async exportBatch(
images: Array<{ src: string; filename: string }>,
config?: Partial<ExportConfig>
): Promise<void> {
try {
// Dynamic import of JSZip
const JSZip = (await import('jszip')).default;
const zip = new JSZip();
const finalConfig = { ...this.defaultExportConfig, ...config };
// Process each image
for (const { src, filename } of images) {
try {
const img = await this.loadImage(src);
const blob = await this.processImage(img, finalConfig);
const extension = finalConfig.format === 'original'
? this.getOriginalExtension(filename)
: finalConfig.format;
const finalFilename = this.ensureExtension(filename, extension);
zip.file(finalFilename, blob);
} catch (error) {
console.error(`Failed to process image ${filename}:`, error);
}
}
// Generate and download ZIP
const zipBlob = await zip.generateAsync({ type: 'blob' });
const url = URL.createObjectURL(zipBlob);
const link = document.createElement('a');
link.href = url;
link.download = `images_${Date.now()}.zip`;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
URL.revokeObjectURL(url);
toastService.showMessage(`Exported ${images.length} images`);
} catch (error) {
console.error('Failed to export images:', error);
toastService.showError('Failed to export images');
throw error;
}
}
/**
* Open share dialog
*/
openShareDialog(
src: string,
title: string,
noteId?: string
): void {
// Create modal dialog
const dialog = document.createElement('div');
dialog.className = 'share-dialog-overlay';
dialog.style.cssText = `
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
z-index: 10000;
`;
const content = document.createElement('div');
content.className = 'share-dialog';
content.style.cssText = `
background: white;
border-radius: 8px;
padding: 20px;
width: 400px;
max-width: 90%;
`;
content.innerHTML = `
<h3 style="margin: 0 0 15px 0;">Share Image</h3>
<div class="share-options" style="display: flex; flex-direction: column; gap: 10px;">
<button class="share-copy-link" style="padding: 10px; border: 1px solid #ddd; border-radius: 4px; cursor: pointer;">
<i class="bx bx-link"></i> Copy Link
</button>
<button class="share-copy-image" style="padding: 10px; border: 1px solid #ddd; border-radius: 4px; cursor: pointer;">
<i class="bx bx-copy"></i> Copy Image
</button>
<button class="share-download" style="padding: 10px; border: 1px solid #ddd; border-radius: 4px; cursor: pointer;">
<i class="bx bx-download"></i> Download
</button>
${navigator.share ? `
<button class="share-native" style="padding: 10px; border: 1px solid #ddd; border-radius: 4px; cursor: pointer;">
<i class="bx bx-share"></i> Share...
</button>
` : ''}
${noteId ? `
<button class="share-generate-link" style="padding: 10px; border: 1px solid #ddd; border-radius: 4px; cursor: pointer;">
<i class="bx bx-link-external"></i> Generate Share Link
</button>
` : ''}
</div>
<button class="close-dialog" style="margin-top: 15px; padding: 8px 16px; background: #f0f0f0; border: none; border-radius: 4px; cursor: pointer;">
Close
</button>
`;
// Add event handlers
content.querySelector('.share-copy-link')?.addEventListener('click', () => {
this.copyToClipboard(src, 'link');
dialog.remove();
});
content.querySelector('.share-copy-image')?.addEventListener('click', () => {
this.copyToClipboard(src, 'image');
dialog.remove();
});
content.querySelector('.share-download')?.addEventListener('click', () => {
this.downloadImage(src, title);
dialog.remove();
});
content.querySelector('.share-native')?.addEventListener('click', () => {
this.shareNative(src, title);
dialog.remove();
});
content.querySelector('.share-generate-link')?.addEventListener('click', async () => {
if (noteId) {
const link = await this.generateShareLink(noteId);
await this.copyToClipboard(link.url, 'link');
dialog.remove();
}
});
content.querySelector('.close-dialog')?.addEventListener('click', () => {
dialog.remove();
});
dialog.appendChild(content);
document.body.appendChild(dialog);
}
/**
* Load image from URL
*/
private loadImage(src: string): Promise<HTMLImageElement> {
return new Promise((resolve, reject) => {
const img = new Image();
img.crossOrigin = 'anonymous';
img.onload = () => resolve(img);
img.onerror = () => reject(new Error(`Failed to load image: ${src}`));
img.src = src;
});
}
/**
* Get MIME type for format
*/
private getMimeType(format: ExportFormat): string {
switch (format) {
case 'jpeg':
return 'image/jpeg';
case 'png':
return 'image/png';
case 'webp':
return 'image/webp';
default:
return 'image/png';
}
}
/**
* Get original extension from filename
*/
private getOriginalExtension(filename: string): string {
const parts = filename.split('.');
if (parts.length > 1) {
return parts[parts.length - 1].toLowerCase();
}
return 'png';
}
/**
* Ensure filename has correct extension
*/
private ensureExtension(filename: string, extension: string): string {
const parts = filename.split('.');
if (parts.length > 1) {
parts[parts.length - 1] = extension;
return parts.join('.');
}
return `${filename}.${extension}`;
}
/**
* Cleanup resources
*/
cleanup(): void {
this.activeShares.clear();
// Clean up canvas memory
if (this.downloadCanvas && this.downloadContext) {
this.downloadContext.clearRect(0, 0, this.downloadCanvas.width, this.downloadCanvas.height);
this.downloadCanvas.width = 0;
this.downloadCanvas.height = 0;
}
this.downloadCanvas = undefined;
this.downloadContext = undefined;
}
}
export default ImageSharingService.getInstance();

View File

@@ -1,552 +0,0 @@
import PhotoSwipe from 'photoswipe';
import type PhotoSwipeOptions from 'photoswipe';
import type { DataSource, SlideData } from 'photoswipe';
import 'photoswipe/style.css';
import '../styles/photoswipe-mobile-a11y.css';
import mobileA11yService, { type MobileA11yConfig } from './photoswipe_mobile_a11y.js';
// Define Content type locally since it's not exported by PhotoSwipe
interface Content {
width?: number;
height?: number;
[key: string]: any;
}
// Define AugmentedEvent type locally
interface AugmentedEvent<T extends string> {
content: Content;
slide?: any;
preventDefault?: () => void;
[key: string]: any;
}
/**
* Media item interface for PhotoSwipe gallery
*/
export interface MediaItem {
src: string;
width?: number;
height?: number;
alt?: string;
title?: string;
noteId?: string;
element?: HTMLElement;
msrc?: string; // Thumbnail source
}
/**
* Configuration options for the media viewer
*/
export interface MediaViewerConfig {
bgOpacity?: number;
showHideOpacity?: boolean;
showAnimationDuration?: number;
hideAnimationDuration?: number;
allowPanToNext?: boolean;
spacing?: number;
maxSpreadZoom?: number;
getThumbBoundsFn?: (index: number) => { x: number; y: number; w: number } | undefined;
pinchToClose?: boolean;
closeOnScroll?: boolean;
closeOnVerticalDrag?: boolean;
mouseMovePan?: boolean;
arrowKeys?: boolean;
returnFocus?: boolean;
escKey?: boolean;
errorMsg?: string;
preloadFirstSlide?: boolean;
preload?: [number, number];
loop?: boolean;
wheelToZoom?: boolean;
mobileA11y?: MobileA11yConfig; // Mobile and accessibility configuration
}
/**
* Event callbacks for media viewer
*/
export interface MediaViewerCallbacks {
onOpen?: () => void;
onClose?: () => void;
onChange?: (index: number) => void;
onImageLoad?: (index: number, item: MediaItem) => void;
onImageError?: (index: number, item: MediaItem, error?: Error) => void;
}
/**
* PhotoSwipe data item with original item reference
*/
interface PhotoSwipeDataItem extends SlideData {
_originalItem?: MediaItem;
}
/**
* Error handler for media viewer operations
*/
class MediaViewerError extends Error {
constructor(message: string, public readonly cause?: unknown) {
super(message);
this.name = 'MediaViewerError';
}
}
/**
* MediaViewerService manages the PhotoSwipe lightbox for viewing images and media
* in Trilium Notes. Implements singleton pattern for global access.
*/
class MediaViewerService {
private static instance: MediaViewerService;
private photoSwipe: PhotoSwipe | null = null;
private defaultConfig: MediaViewerConfig;
private currentItems: MediaItem[] = [];
private callbacks: MediaViewerCallbacks = {};
private cleanupHandlers: Array<() => void> = [];
private constructor() {
// Default configuration optimized for Trilium
this.defaultConfig = {
bgOpacity: 0.95,
showHideOpacity: true,
showAnimationDuration: 250,
hideAnimationDuration: 250,
allowPanToNext: true,
spacing: 0.12,
maxSpreadZoom: 4,
pinchToClose: true,
closeOnScroll: false,
closeOnVerticalDrag: true,
mouseMovePan: true,
arrowKeys: true,
returnFocus: true,
escKey: true,
errorMsg: 'The image could not be loaded',
preloadFirstSlide: true,
preload: [1, 2],
loop: true,
wheelToZoom: true
};
// Setup global cleanup on window unload
window.addEventListener('beforeunload', () => this.destroy());
}
/**
* Get singleton instance of MediaViewerService
*/
static getInstance(): MediaViewerService {
if (!MediaViewerService.instance) {
MediaViewerService.instance = new MediaViewerService();
}
return MediaViewerService.instance;
}
/**
* Open the media viewer with specified items
*/
open(items: MediaItem[], startIndex: number = 0, config?: Partial<MediaViewerConfig>, callbacks?: MediaViewerCallbacks): void {
try {
// Validate inputs
if (!items || items.length === 0) {
throw new MediaViewerError('No items provided to media viewer');
}
if (startIndex < 0 || startIndex >= items.length) {
console.warn(`Invalid start index ${startIndex}, using 0`);
startIndex = 0;
}
// Close any existing viewer
this.close();
this.currentItems = items;
this.callbacks = callbacks || {};
// Prepare data source for PhotoSwipe with error handling
const dataSource: DataSource = items.map((item, index) => {
try {
return this.prepareItem(item);
} catch (error) {
console.error(`Failed to prepare item at index ${index}:`, error);
// Return a minimal valid item as fallback
return {
src: item.src,
width: 800,
height: 600,
alt: item.alt || 'Error loading image'
} as PhotoSwipeDataItem;
}
});
// Merge configurations
const finalConfig = {
...this.defaultConfig,
...config,
dataSource,
index: startIndex,
errorMsg: config?.errorMsg || 'The image could not be loaded. Please try again.'
};
// Create and initialize PhotoSwipe
this.photoSwipe = new PhotoSwipe(finalConfig);
// Setup event handlers
this.setupEventHandlers();
// Apply mobile and accessibility enhancements
if (config?.mobileA11y || this.shouldAutoEnhance()) {
mobileA11yService.enhancePhotoSwipe(this.photoSwipe, config?.mobileA11y);
}
// Initialize the viewer
this.photoSwipe.init();
} catch (error) {
console.error('Failed to open media viewer:', error);
// Cleanup on error
this.close();
// Re-throw as MediaViewerError
throw error instanceof MediaViewerError ? error : new MediaViewerError('Failed to open media viewer', error);
}
}
/**
* Open a single image in the viewer
*/
openSingle(item: MediaItem, config?: Partial<MediaViewerConfig>, callbacks?: MediaViewerCallbacks): void {
this.open([item], 0, config, callbacks);
}
/**
* Close the media viewer
*/
close(): void {
if (this.photoSwipe) {
this.photoSwipe.destroy();
this.photoSwipe = null;
this.cleanupEventHandlers();
}
}
/**
* Navigate to next item
*/
next(): void {
if (this.photoSwipe) {
this.photoSwipe.next();
}
}
/**
* Navigate to previous item
*/
prev(): void {
if (this.photoSwipe) {
this.photoSwipe.prev();
}
}
/**
* Go to specific slide by index
*/
goTo(index: number): void {
if (this.photoSwipe && index >= 0 && index < this.currentItems.length) {
this.photoSwipe.goTo(index);
}
}
/**
* Get current slide index
*/
getCurrentIndex(): number {
return this.photoSwipe ? this.photoSwipe.currIndex : -1;
}
/**
* Check if viewer is open
*/
isOpen(): boolean {
return this.photoSwipe !== null;
}
/**
* Update configuration dynamically
*/
updateConfig(config: Partial<MediaViewerConfig>): void {
this.defaultConfig = {
...this.defaultConfig,
...config
};
}
/**
* Prepare item for PhotoSwipe
*/
private prepareItem(item: MediaItem): PhotoSwipeDataItem {
const prepared: PhotoSwipeDataItem = {
src: item.src,
alt: item.alt || '',
title: item.title
};
// If dimensions are provided, use them
if (item.width && item.height) {
prepared.width = item.width;
prepared.height = item.height;
} else {
// Default dimensions - will be updated when image loads
prepared.width = 0;
prepared.height = 0;
}
// Add thumbnail if provided
if (item.msrc) {
prepared.msrc = item.msrc;
}
// Store original item reference
prepared._originalItem = item;
return prepared;
}
/**
* Setup event handlers for PhotoSwipe
*/
private setupEventHandlers(): void {
if (!this.photoSwipe) return;
// Opening event
const openHandler = () => {
if (this.callbacks.onOpen) {
this.callbacks.onOpen();
}
};
this.photoSwipe.on('openingAnimationEnd', openHandler);
this.cleanupHandlers.push(() => this.photoSwipe?.off('openingAnimationEnd', openHandler));
// Closing event
const closeHandler = () => {
if (this.callbacks.onClose) {
this.callbacks.onClose();
}
};
this.photoSwipe.on('close', closeHandler);
this.cleanupHandlers.push(() => this.photoSwipe?.off('close', closeHandler));
// Change event
const changeHandler = () => {
if (this.callbacks.onChange && this.photoSwipe) {
this.callbacks.onChange(this.photoSwipe.currIndex);
}
};
this.photoSwipe.on('change', changeHandler);
this.cleanupHandlers.push(() => this.photoSwipe?.off('change', changeHandler));
// Image load event - also update dimensions if needed
const loadCompleteHandler = (e: any) => {
try {
const { content } = e;
const extContent = content as Content & { type?: string; data?: HTMLImageElement; index?: number; _originalItem?: MediaItem };
if (extContent.type === 'image' && extContent.data) {
// Update dimensions if they were not provided
if (content.width === 0 || content.height === 0) {
const img = extContent.data;
content.width = img.naturalWidth;
content.height = img.naturalHeight;
if (typeof extContent.index === 'number') {
this.photoSwipe?.refreshSlideContent(extContent.index);
}
}
if (this.callbacks.onImageLoad && typeof extContent.index === 'number' && extContent._originalItem) {
this.callbacks.onImageLoad(extContent.index, extContent._originalItem);
}
}
} catch (error) {
console.error('Error in loadComplete handler:', error);
}
};
this.photoSwipe.on('loadComplete', loadCompleteHandler);
this.cleanupHandlers.push(() => this.photoSwipe?.off('loadComplete', loadCompleteHandler));
// Image error event
const errorHandler = (e: any) => {
try {
const { content } = e;
const extContent = content as Content & { index?: number; _originalItem?: MediaItem };
if (this.callbacks.onImageError && typeof extContent.index === 'number' && extContent._originalItem) {
const error = new MediaViewerError(`Failed to load image at index ${extContent.index}`);
this.callbacks.onImageError(extContent.index, extContent._originalItem, error);
}
} catch (error) {
console.error('Error in errorHandler:', error);
}
};
this.photoSwipe.on('loadError', errorHandler);
this.cleanupHandlers.push(() => this.photoSwipe?.off('loadError', errorHandler));
}
/**
* Cleanup event handlers
*/
private cleanupEventHandlers(): void {
this.cleanupHandlers.forEach(handler => handler());
this.cleanupHandlers = [];
}
/**
* Destroy the service and cleanup resources
*/
destroy(): void {
this.close();
this.currentItems = [];
this.callbacks = {};
// Cleanup mobile and accessibility enhancements
mobileA11yService.cleanup();
}
/**
* Get dimensions from image element or URL with proper resource cleanup
*/
async getImageDimensions(src: string): Promise<{ width: number; height: number }> {
return new Promise((resolve, reject) => {
const img = new Image();
let resolved = false;
const cleanup = () => {
img.onload = null;
img.onerror = null;
// Clear the src to help with garbage collection
if (!resolved) {
img.src = '';
}
};
img.onload = () => {
resolved = true;
const dimensions = {
width: img.naturalWidth,
height: img.naturalHeight
};
cleanup();
resolve(dimensions);
};
img.onerror = () => {
const error = new MediaViewerError(`Failed to load image: ${src}`);
cleanup();
reject(error);
};
// Set a timeout for image loading
const timeoutId = setTimeout(() => {
if (!resolved) {
cleanup();
reject(new MediaViewerError(`Image loading timeout: ${src}`));
}
}, 30000); // 30 second timeout
img.src = src;
// Clear timeout on success or error
// Store the original handlers with timeout cleanup
const originalOnload = img.onload;
const originalOnerror = img.onerror;
img.onload = function(ev: Event) {
clearTimeout(timeoutId);
if (originalOnload) {
originalOnload.call(img, ev);
}
};
img.onerror = function(ev: Event | string) {
clearTimeout(timeoutId);
if (originalOnerror) {
originalOnerror.call(img, ev);
}
};
});
}
/**
* Create items from image elements in a container with error isolation
*/
async createItemsFromContainer(container: HTMLElement, selector: string = 'img'): Promise<MediaItem[]> {
const images = container.querySelectorAll<HTMLImageElement>(selector);
const items: MediaItem[] = [];
// Process each image with isolated error handling
const promises = Array.from(images).map(async (img) => {
try {
const item: MediaItem = {
src: img.src,
alt: img.alt || `Image ${items.length + 1}`,
title: img.title || img.alt || `Image ${items.length + 1}`,
element: img,
width: img.naturalWidth || undefined,
height: img.naturalHeight || undefined
};
// Try to get dimensions if not available
if (!item.width || !item.height) {
try {
const dimensions = await this.getImageDimensions(img.src);
item.width = dimensions.width;
item.height = dimensions.height;
} catch (error) {
// Log but don't fail - image will still be viewable
console.warn(`Failed to get dimensions for image: ${img.src}`, error);
// Set default dimensions as fallback
item.width = 800;
item.height = 600;
}
}
return item;
} catch (error) {
// Log error but continue processing other images
console.error(`Failed to process image: ${img.src}`, error);
return null;
}
});
// Wait for all promises and filter out nulls
const results = await Promise.allSettled(promises);
for (const result of results) {
if (result.status === 'fulfilled' && result.value !== null) {
items.push(result.value);
}
}
return items;
}
/**
* Apply theme-specific styles
*/
applyTheme(isDarkTheme: boolean): void {
// This will be expanded to modify PhotoSwipe's appearance based on Trilium's theme
const opacity = isDarkTheme ? 0.95 : 0.9;
this.updateConfig({ bgOpacity: opacity });
}
/**
* Check if mobile/accessibility enhancements should be auto-enabled
*/
private shouldAutoEnhance(): boolean {
// Auto-enable for touch devices
const isTouchDevice = 'ontouchstart' in window ||
navigator.maxTouchPoints > 0;
// Auto-enable if user has accessibility preferences
const prefersReducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
const prefersHighContrast = window.matchMedia('(prefers-contrast: high)').matches;
return isTouchDevice || prefersReducedMotion || prefersHighContrast;
}
}
// Export singleton instance
export default MediaViewerService.getInstance();

View File

@@ -1,541 +0,0 @@
/**
* Tests for PhotoSwipe Mobile & Accessibility Enhancement Module
*/
import { describe, it, expect, beforeEach, afterEach, jest } from '@jest/globals';
import type PhotoSwipe from 'photoswipe';
import mobileA11yService from './photoswipe_mobile_a11y.js';
// Mock PhotoSwipe
const mockPhotoSwipe = {
template: document.createElement('div'),
currSlide: {
currZoomLevel: 1,
zoomTo: jest.fn(),
data: {
src: 'test.jpg',
alt: 'Test image',
title: 'Test',
width: 800,
height: 600
}
},
currIndex: 0,
viewportSize: { x: 800, y: 600 },
ui: { toggle: jest.fn() },
next: jest.fn(),
prev: jest.fn(),
goTo: jest.fn(),
close: jest.fn(),
getNumItems: () => 5,
on: jest.fn(),
off: jest.fn(),
options: {
showAnimationDuration: 250,
hideAnimationDuration: 250
}
} as unknown as PhotoSwipe;
describe('PhotoSwipeMobileA11yService', () => {
beforeEach(() => {
// Reset DOM
document.body.innerHTML = '';
// Reset mocks
jest.clearAllMocks();
});
afterEach(() => {
// Cleanup
mobileA11yService.cleanup();
});
describe('Device Capabilities Detection', () => {
it('should detect touch device capabilities', () => {
// Add touch support to window
Object.defineProperty(window, 'ontouchstart', {
value: () => {},
writable: true
});
// Service should detect touch support on initialization
const service = mobileA11yService;
expect(service).toBeDefined();
});
it('should detect accessibility preferences', () => {
// Mock matchMedia for reduced motion
const mockMatchMedia = jest.fn().mockImplementation(query => ({
matches: query === '(prefers-reduced-motion: reduce)',
media: query,
addListener: jest.fn(),
removeListener: jest.fn()
}));
Object.defineProperty(window, 'matchMedia', {
value: mockMatchMedia,
writable: true
});
const service = mobileA11yService;
expect(service).toBeDefined();
});
});
describe('ARIA Live Region', () => {
it('should create ARIA live region for announcements', () => {
mobileA11yService.enhancePhotoSwipe(mockPhotoSwipe);
const liveRegion = document.querySelector('[aria-live]');
expect(liveRegion).toBeTruthy();
expect(liveRegion?.getAttribute('aria-live')).toBe('polite');
expect(liveRegion?.getAttribute('role')).toBe('status');
});
it('should announce changes to screen readers', () => {
mobileA11yService.enhancePhotoSwipe(mockPhotoSwipe, {
a11y: {
enableScreenReaderAnnouncements: true
}
});
const liveRegion = document.querySelector('[aria-live]');
// Trigger navigation
const changeHandler = (mockPhotoSwipe.on as jest.Mock).mock.calls
.find(call => call[0] === 'change')?.[1];
if (changeHandler) {
changeHandler();
// Check if announcement was made
expect(liveRegion?.textContent).toContain('Image 1 of 5');
}
});
});
describe('Keyboard Navigation', () => {
it('should handle arrow key navigation', () => {
mobileA11yService.enhancePhotoSwipe(mockPhotoSwipe);
// Simulate arrow key presses
const leftArrow = new KeyboardEvent('keydown', { key: 'ArrowLeft' });
const rightArrow = new KeyboardEvent('keydown', { key: 'ArrowRight' });
document.dispatchEvent(leftArrow);
expect(mockPhotoSwipe.prev).toHaveBeenCalled();
document.dispatchEvent(rightArrow);
expect(mockPhotoSwipe.next).toHaveBeenCalled();
});
it('should handle zoom with arrow keys', () => {
mobileA11yService.enhancePhotoSwipe(mockPhotoSwipe);
const upArrow = new KeyboardEvent('keydown', { key: 'ArrowUp' });
const downArrow = new KeyboardEvent('keydown', { key: 'ArrowDown' });
document.dispatchEvent(upArrow);
expect(mockPhotoSwipe.currSlide?.zoomTo).toHaveBeenCalledWith(
expect.any(Number),
expect.any(Object),
333
);
document.dispatchEvent(downArrow);
expect(mockPhotoSwipe.currSlide?.zoomTo).toHaveBeenCalledTimes(2);
});
it('should show keyboard help on ? key', () => {
mobileA11yService.enhancePhotoSwipe(mockPhotoSwipe);
const helpKey = new KeyboardEvent('keydown', { key: '?' });
document.dispatchEvent(helpKey);
const helpDialog = document.querySelector('.photoswipe-keyboard-help');
expect(helpDialog).toBeTruthy();
expect(helpDialog?.getAttribute('role')).toBe('dialog');
});
it('should support quick navigation with number keys', () => {
mobileA11yService.enhancePhotoSwipe(mockPhotoSwipe);
const key3 = new KeyboardEvent('keydown', { key: '3' });
document.dispatchEvent(key3);
expect(mockPhotoSwipe.goTo).toHaveBeenCalledWith(2); // 0-indexed
});
});
describe('Touch Gestures', () => {
it('should handle pinch to zoom', () => {
mobileA11yService.enhancePhotoSwipe(mockPhotoSwipe);
const element = mockPhotoSwipe.template;
// Simulate pinch gesture
const touch1 = { clientX: 100, clientY: 100, identifier: 0 };
const touch2 = { clientX: 200, clientY: 200, identifier: 1 };
const touchStart = new TouchEvent('touchstart', {
touches: [touch1, touch2] as any
});
element?.dispatchEvent(touchStart);
// Move touches apart (zoom in)
const touch1Move = { clientX: 50, clientY: 50, identifier: 0 };
const touch2Move = { clientX: 250, clientY: 250, identifier: 1 };
const touchMove = new TouchEvent('touchmove', {
touches: [touch1Move, touch2Move] as any
});
element?.dispatchEvent(touchMove);
// Zoom should be triggered
expect(mockPhotoSwipe.currSlide?.zoomTo).toHaveBeenCalled();
});
it('should handle double tap to zoom', (done) => {
mobileA11yService.enhancePhotoSwipe(mockPhotoSwipe);
const element = mockPhotoSwipe.template;
const pos = { clientX: 400, clientY: 300 };
// First tap
const firstTap = new TouchEvent('touchend', {
changedTouches: [{ ...pos, identifier: 0 }] as any
});
element?.dispatchEvent(new TouchEvent('touchstart', {
touches: [{ ...pos, identifier: 0 }] as any
}));
element?.dispatchEvent(firstTap);
// Second tap within double tap delay
setTimeout(() => {
element?.dispatchEvent(new TouchEvent('touchstart', {
touches: [{ ...pos, identifier: 0 }] as any
}));
const secondTap = new TouchEvent('touchend', {
changedTouches: [{ ...pos, identifier: 0 }] as any
});
element?.dispatchEvent(secondTap);
// Check zoom was triggered
expect(mockPhotoSwipe.currSlide?.zoomTo).toHaveBeenCalled();
done();
}, 100);
});
it('should detect swipe gestures', () => {
mobileA11yService.enhancePhotoSwipe(mockPhotoSwipe);
const element = mockPhotoSwipe.template;
// Simulate swipe left
const touchStart = new TouchEvent('touchstart', {
touches: [{ clientX: 300, clientY: 300, identifier: 0 }] as any
});
const touchEnd = new TouchEvent('touchend', {
changedTouches: [{ clientX: 100, clientY: 300, identifier: 0 }] as any
});
element?.dispatchEvent(touchStart);
element?.dispatchEvent(touchEnd);
// Should navigate to next image
expect(mockPhotoSwipe.next).toHaveBeenCalled();
});
});
describe('Focus Management', () => {
it('should trap focus within gallery', () => {
mobileA11yService.enhancePhotoSwipe(mockPhotoSwipe);
const element = mockPhotoSwipe.template;
// Add focusable elements
const button1 = document.createElement('button');
const button2 = document.createElement('button');
element?.appendChild(button1);
element?.appendChild(button2);
// Focus first button
button1.focus();
// Simulate Tab on last focusable element
const tabEvent = new KeyboardEvent('keydown', {
key: 'Tab',
shiftKey: false
});
button2.focus();
element?.dispatchEvent(tabEvent);
// Focus should wrap to first element
expect(document.activeElement).toBe(button1);
});
it('should restore focus on close', () => {
const originalFocus = document.createElement('button');
document.body.appendChild(originalFocus);
originalFocus.focus();
mobileA11yService.enhancePhotoSwipe(mockPhotoSwipe);
// Trigger close handler
const closeHandler = (mockPhotoSwipe.on as jest.Mock).mock.calls
.find(call => call[0] === 'close')?.[1];
if (closeHandler) {
closeHandler();
// Focus should be restored
expect(document.activeElement).toBe(originalFocus);
}
});
});
describe('ARIA Attributes', () => {
it('should add proper ARIA attributes to gallery', () => {
mobileA11yService.enhancePhotoSwipe(mockPhotoSwipe);
const element = mockPhotoSwipe.template;
expect(element?.getAttribute('role')).toBe('dialog');
expect(element?.getAttribute('aria-label')).toContain('Image gallery');
expect(element?.getAttribute('aria-modal')).toBe('true');
});
it('should label controls for screen readers', () => {
mobileA11yService.enhancePhotoSwipe(mockPhotoSwipe);
const element = mockPhotoSwipe.template;
// Add mock controls
const prevBtn = document.createElement('button');
prevBtn.className = 'pswp__button--arrow--prev';
element?.appendChild(prevBtn);
const nextBtn = document.createElement('button');
nextBtn.className = 'pswp__button--arrow--next';
element?.appendChild(nextBtn);
// Enhance again to label the newly added controls
mobileA11yService.enhancePhotoSwipe(mockPhotoSwipe);
expect(prevBtn.getAttribute('aria-label')).toBe('Previous image');
expect(nextBtn.getAttribute('aria-label')).toBe('Next image');
});
});
describe('Mobile UI Adaptations', () => {
it('should ensure minimum touch target size', () => {
mobileA11yService.enhancePhotoSwipe(mockPhotoSwipe, {
mobileUI: {
minTouchTargetSize: 44
}
});
const element = mockPhotoSwipe.template;
// Add a button
const button = document.createElement('button');
button.className = 'pswp__button';
button.style.width = '30px';
button.style.height = '30px';
element?.appendChild(button);
// Enhance to apply minimum sizes
mobileA11yService.enhancePhotoSwipe(mockPhotoSwipe);
// Button should be resized to meet minimum
expect(button.style.minWidth).toBe('44px');
expect(button.style.minHeight).toBe('44px');
});
it('should add swipe indicators for mobile', () => {
// Mock as mobile device
Object.defineProperty(window, 'ontouchstart', {
value: () => {},
writable: true
});
mobileA11yService.enhancePhotoSwipe(mockPhotoSwipe, {
mobileUI: {
swipeIndicators: true
}
});
const indicators = document.querySelector('.photoswipe-swipe-indicators');
expect(indicators).toBeTruthy();
});
});
describe('Performance Optimizations', () => {
it('should adapt quality based on device capabilities', () => {
// Mock low memory device
Object.defineProperty(navigator, 'deviceMemory', {
value: 1,
writable: true
});
mobileA11yService.enhancePhotoSwipe(mockPhotoSwipe, {
performance: {
adaptiveQuality: true
}
});
// Service should detect low memory and adjust settings
expect(mobileA11yService).toBeDefined();
});
it('should apply reduced motion preferences', () => {
// Mock reduced motion preference
const mockMatchMedia = jest.fn().mockImplementation(query => ({
matches: query === '(prefers-reduced-motion: reduce)',
media: query,
addListener: jest.fn(),
removeListener: jest.fn()
}));
Object.defineProperty(window, 'matchMedia', {
value: mockMatchMedia,
writable: true
});
mobileA11yService.enhancePhotoSwipe(mockPhotoSwipe);
// Animations should be disabled
expect(mockPhotoSwipe.options.showAnimationDuration).toBe(0);
expect(mockPhotoSwipe.options.hideAnimationDuration).toBe(0);
});
it('should optimize for battery saving', () => {
// Mock battery API
const mockBattery = {
charging: false,
level: 0.15,
addEventListener: jest.fn()
};
(navigator as any).getBattery = jest.fn().mockResolvedValue(mockBattery);
mobileA11yService.enhancePhotoSwipe(mockPhotoSwipe, {
performance: {
batteryOptimization: true
}
});
// Battery optimization should be enabled
expect((navigator as any).getBattery).toHaveBeenCalled();
});
});
describe('High Contrast Mode', () => {
it('should apply high contrast styles when enabled', () => {
// Mock high contrast preference
const mockMatchMedia = jest.fn().mockImplementation(query => ({
matches: query === '(prefers-contrast: high)',
media: query,
addListener: jest.fn(),
removeListener: jest.fn()
}));
Object.defineProperty(window, 'matchMedia', {
value: mockMatchMedia,
writable: true
});
mobileA11yService.enhancePhotoSwipe(mockPhotoSwipe);
const element = mockPhotoSwipe.template;
// Should have high contrast styles
expect(element?.style.outline).toContain('2px solid white');
});
});
describe('Haptic Feedback', () => {
it('should trigger haptic feedback on supported devices', () => {
// Mock vibration API
const mockVibrate = jest.fn();
Object.defineProperty(navigator, 'vibrate', {
value: mockVibrate,
writable: true
});
mobileA11yService.enhancePhotoSwipe(mockPhotoSwipe, {
touch: {
hapticFeedback: true
}
});
// Trigger a gesture that should cause haptic feedback
const element = mockPhotoSwipe.template;
// Double tap
const tap = new TouchEvent('touchend', {
changedTouches: [{ clientX: 100, clientY: 100, identifier: 0 }] as any
});
element?.dispatchEvent(new TouchEvent('touchstart', {
touches: [{ clientX: 100, clientY: 100, identifier: 0 }] as any
}));
element?.dispatchEvent(tap);
// Quick second tap
setTimeout(() => {
element?.dispatchEvent(new TouchEvent('touchstart', {
touches: [{ clientX: 100, clientY: 100, identifier: 0 }] as any
}));
element?.dispatchEvent(tap);
// Haptic feedback should be triggered
expect(mockVibrate).toHaveBeenCalled();
}, 50);
});
});
describe('Configuration Updates', () => {
it('should update configuration dynamically', () => {
mobileA11yService.enhancePhotoSwipe(mockPhotoSwipe);
// Update configuration
mobileA11yService.updateConfig({
a11y: {
ariaLiveRegion: 'assertive'
},
touch: {
hapticFeedback: false
}
});
const liveRegion = document.querySelector('[aria-live]');
expect(liveRegion?.getAttribute('aria-live')).toBe('assertive');
});
});
describe('Cleanup', () => {
it('should properly cleanup resources', () => {
mobileA11yService.enhancePhotoSwipe(mockPhotoSwipe);
// Create some elements
const liveRegion = document.querySelector('[aria-live]');
const helpDialog = document.querySelector('.photoswipe-keyboard-help');
expect(liveRegion).toBeTruthy();
// Cleanup
mobileA11yService.cleanup();
// Elements should be removed
expect(document.querySelector('[aria-live]')).toBeFalsy();
expect(document.querySelector('.photoswipe-keyboard-help')).toBeFalsy();
});
});
});

File diff suppressed because it is too large Load Diff

View File

@@ -1,284 +0,0 @@
/**
* Gallery styles for PhotoSwipe integration
* Provides styling for gallery UI elements
*/
/* Gallery thumbnail strip */
.gallery-thumbnail-strip {
scrollbar-width: thin;
scrollbar-color: rgba(255, 255, 255, 0.3) transparent;
}
.gallery-thumbnail-strip::-webkit-scrollbar {
height: 6px;
}
.gallery-thumbnail-strip::-webkit-scrollbar-track {
background: transparent;
}
.gallery-thumbnail-strip::-webkit-scrollbar-thumb {
background: rgba(255, 255, 255, 0.3);
border-radius: 3px;
}
.gallery-thumbnail-strip::-webkit-scrollbar-thumb:hover {
background: rgba(255, 255, 255, 0.5);
}
.gallery-thumbnail {
position: relative;
overflow: hidden;
}
.gallery-thumbnail.active::after {
content: '';
position: absolute;
inset: 0;
background: rgba(255, 255, 255, 0.1);
pointer-events: none;
}
/* Gallery controls animations */
.gallery-slideshow-controls button {
transition: transform 0.2s, background 0.2s;
}
.gallery-slideshow-controls button:hover {
transform: scale(1.1);
background: rgba(255, 255, 255, 1) !important;
}
.gallery-slideshow-controls button:active {
transform: scale(0.95);
}
/* Slideshow progress indicator */
.slideshow-progress {
position: absolute;
bottom: 0;
left: 0;
right: 0;
height: 3px;
background: rgba(255, 255, 255, 0.2);
z-index: 101;
}
.slideshow-progress-bar {
height: 100%;
background: rgba(255, 255, 255, 0.8);
width: 0;
transition: width linear;
}
.slideshow-progress.active .slideshow-progress-bar {
animation: slideshow-progress var(--slideshow-interval) linear;
}
@keyframes slideshow-progress {
from { width: 0; }
to { width: 100%; }
}
/* Gallery counter styling */
.gallery-counter {
font-family: var(--font-family-monospace);
letter-spacing: 0.05em;
user-select: none;
}
.gallery-counter .current-index {
font-weight: bold;
color: #fff;
}
.gallery-counter .total-count {
opacity: 0.8;
}
/* Enhanced image hover effects */
.pswp__img {
transition: transform 0.3s cubic-bezier(0.4, 0, 0.2, 1);
}
.pswp__img.pswp__img--zoomed {
cursor: move;
}
/* Gallery navigation arrows */
.pswp__button--arrow--left,
.pswp__button--arrow--right {
background: rgba(0, 0, 0, 0.5) !important;
backdrop-filter: blur(10px);
border-radius: 50%;
width: 50px;
height: 50px;
margin: 20px;
}
.pswp__button--arrow--left:hover,
.pswp__button--arrow--right:hover {
background: rgba(0, 0, 0, 0.7) !important;
}
/* Touch-friendly tap areas */
@media (pointer: coarse) {
.gallery-thumbnail {
min-width: 60px;
min-height: 60px;
}
.gallery-slideshow-controls button {
min-width: 50px;
min-height: 50px;
}
}
/* Smooth transitions */
.pswp--animate_opacity {
transition: opacity 250ms cubic-bezier(0.4, 0, 0.2, 1);
}
.pswp__bg {
transition: opacity 250ms cubic-bezier(0.4, 0, 0.2, 1);
}
/* Loading state */
.pswp__preloader {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
}
.pswp__preloader__icn {
width: 30px;
height: 30px;
border: 3px solid rgba(255, 255, 255, 0.3);
border-top-color: #fff;
border-radius: 50%;
animation: gallery-spin 1s linear infinite;
}
@keyframes gallery-spin {
to { transform: rotate(360deg); }
}
/* Error state */
.pswp__error-msg {
background: rgba(0, 0, 0, 0.8);
color: #ff6b6b;
padding: 20px;
border-radius: 8px;
max-width: 400px;
text-align: center;
}
/* Accessibility improvements */
.pswp__button:focus-visible {
outline: 2px solid #4a9eff;
outline-offset: 2px;
}
.gallery-thumbnail:focus-visible {
outline: 2px solid #4a9eff;
outline-offset: -2px;
}
/* Dark theme adjustments */
body.theme-dark .gallery-thumbnail-strip {
background: rgba(0, 0, 0, 0.8);
}
body.theme-dark .gallery-slideshow-controls button {
background: rgba(255, 255, 255, 0.8);
color: #000;
}
body.theme-dark .gallery-slideshow-controls button:hover {
background: rgba(255, 255, 255, 0.95);
}
/* Light theme adjustments */
body.theme-light .pswp__bg {
background: rgba(255, 255, 255, 0.95);
}
body.theme-light .gallery-counter,
body.theme-light .gallery-keyboard-hints {
background: rgba(0, 0, 0, 0.8);
color: white;
}
/* Mobile-specific styles */
@media (max-width: 768px) {
.gallery-thumbnail-strip {
bottom: 40px;
padding: 6px;
gap: 4px;
}
.gallery-thumbnail {
width: 50px !important;
height: 50px !important;
}
.gallery-slideshow-controls {
top: 10px;
right: 10px;
}
.gallery-counter {
font-size: 12px;
padding: 6px 10px;
}
.pswp__button--arrow--left,
.pswp__button--arrow--right {
width: 40px;
height: 40px;
margin: 10px;
}
}
/* Tablet-specific styles */
@media (min-width: 769px) and (max-width: 1024px) {
.gallery-thumbnail-strip {
max-width: 80%;
}
.gallery-thumbnail {
width: 70px !important;
height: 70px !important;
}
}
/* High-DPI display optimizations */
@media (-webkit-min-device-pixel-ratio: 2), (min-resolution: 192dpi) {
.gallery-thumbnail img {
image-rendering: -webkit-optimize-contrast;
image-rendering: crisp-edges;
}
}
/* Reduced motion support */
@media (prefers-reduced-motion: reduce) {
.gallery-thumbnail,
.gallery-slideshow-controls button,
.pswp__img,
.pswp--animate_opacity,
.pswp__bg {
transition: none !important;
animation: none !important;
}
}
/* Print styles */
@media print {
.gallery-thumbnail-strip,
.gallery-slideshow-controls,
.gallery-counter,
.gallery-keyboard-hints {
display: none !important;
}
}

View File

@@ -1,528 +0,0 @@
/**
* PhotoSwipe Mobile & Accessibility Styles
* Phase 6: Complete mobile optimization and WCAG 2.1 AA compliance
*/
/* ==========================================================================
Touch Target Optimization (WCAG 2.1 Success Criterion 2.5.5)
========================================================================== */
/* Ensure all interactive elements meet minimum 44x44px touch target */
.pswp__button,
.pswp__button--arrow--left,
.pswp__button--arrow--right,
.pswp__button--close,
.pswp__button--zoom,
.pswp__button--fs,
.gallery-thumbnail,
.photoswipe-bottom-sheet button {
min-width: 44px !important;
min-height: 44px !important;
display: flex;
align-items: center;
justify-content: center;
}
/* Increase touch target padding on mobile */
@media (pointer: coarse) {
.pswp__button {
padding: 12px;
}
/* Larger hit areas for navigation arrows */
.pswp__button--arrow--left,
.pswp__button--arrow--right {
width: 60px !important;
height: 100px !important;
}
}
/* ==========================================================================
Focus Indicators (WCAG 2.1 Success Criterion 2.4.7)
========================================================================== */
/* High visibility focus indicators */
.pswp__button:focus,
.pswp__button:focus-visible,
.gallery-thumbnail:focus,
.photoswipe-bottom-sheet button:focus,
.photoswipe-focused {
outline: 3px solid #4A90E2 !important;
outline-offset: 2px !important;
box-shadow: 0 0 0 3px rgba(74, 144, 226, 0.3);
}
/* Remove default browser outline */
.pswp__button:focus:not(:focus-visible) {
outline: none;
}
/* Focus indicator for images */
.pswp__img:focus {
outline: 3px solid #4A90E2;
outline-offset: -3px;
}
/* Skip link styles */
.photoswipe-skip-link {
position: absolute;
left: -10000px;
top: 0;
background: #000;
color: #fff;
padding: 8px 16px;
text-decoration: none;
z-index: 100000;
border-radius: 4px;
}
.photoswipe-skip-link:focus {
left: 10px !important;
top: 10px !important;
width: auto !important;
height: auto !important;
}
/* ==========================================================================
Mobile UI Adaptations
========================================================================== */
/* Mobile-optimized toolbar */
@media (max-width: 768px) {
.pswp__top-bar {
height: 60px;
background: rgba(0, 0, 0, 0.8);
}
.pswp__button {
width: 50px;
height: 50px;
}
/* Reposition counter for mobile */
.pswp__counter {
top: auto;
bottom: 70px;
left: 50%;
transform: translateX(-50%);
background: rgba(0, 0, 0, 0.7);
padding: 8px 16px;
border-radius: 20px;
font-size: 14px;
}
}
/* Bottom sheet for mobile controls */
.photoswipe-bottom-sheet {
position: fixed;
bottom: 0;
left: 0;
right: 0;
background: rgba(0, 0, 0, 0.95);
padding: env(safe-area-inset-bottom, 20px) 20px;
display: flex;
justify-content: space-around;
align-items: center;
z-index: 100;
transform: translateY(100%);
transition: transform 0.3s cubic-bezier(0.4, 0, 0.2, 1);
border-top: 1px solid rgba(255, 255, 255, 0.1);
}
.photoswipe-bottom-sheet.active {
transform: translateY(0);
}
.photoswipe-bottom-sheet button {
background: none;
border: 2px solid transparent;
color: white;
font-size: 24px;
padding: 10px;
border-radius: 50%;
transition: all 0.2s;
}
.photoswipe-bottom-sheet button:active {
background: rgba(255, 255, 255, 0.1);
transform: scale(0.95);
}
/* Swipe indicators */
.photoswipe-swipe-indicators {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
pointer-events: none;
animation: fadeInOut 3s ease-in-out;
}
@keyframes fadeInOut {
0%, 100% { opacity: 0; }
20%, 80% { opacity: 0.7; }
}
/* Gesture hints */
.photoswipe-gesture-hints {
position: absolute;
top: 60px;
left: 50%;
transform: translateX(-50%);
background: rgba(0, 0, 0, 0.85);
color: white;
padding: 12px 24px;
border-radius: 24px;
font-size: 14px;
font-weight: 500;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
pointer-events: none;
}
/* Context menu for long press */
.photoswipe-context-menu {
background: var(--theme-background-color, white);
border: 1px solid var(--theme-border-color, #ccc);
border-radius: 8px;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.2);
overflow: hidden;
min-width: 200px;
}
.photoswipe-context-menu button {
display: block;
width: 100%;
padding: 12px 16px;
border: none;
background: none;
text-align: left;
cursor: pointer;
font-size: 16px;
transition: background 0.2s;
}
.photoswipe-context-menu button:hover,
.photoswipe-context-menu button:focus {
background: var(--theme-hover-background, rgba(0, 0, 0, 0.05));
}
/* ==========================================================================
Responsive Breakpoints
========================================================================== */
/* Small phones (< 375px) */
@media (max-width: 374px) {
.pswp__button {
width: 40px;
height: 40px;
}
.gallery-thumbnail-strip {
padding: 5px !important;
}
.gallery-thumbnail {
width: 60px !important;
height: 60px !important;
}
}
/* Tablets (768px - 1024px) */
@media (min-width: 768px) and (max-width: 1024px) {
.pswp__button {
width: 48px;
height: 48px;
}
.gallery-thumbnail {
width: 90px !important;
height: 90px !important;
}
}
/* Landscape orientation adjustments */
@media (orientation: landscape) and (max-height: 500px) {
.pswp__top-bar {
height: 44px;
}
.pswp__button {
width: 44px;
height: 44px;
}
.gallery-thumbnail-strip {
bottom: 40px !important;
max-height: 60px;
}
.gallery-thumbnail {
width: 50px !important;
height: 50px !important;
}
}
/* ==========================================================================
Accessibility Enhancements
========================================================================== */
/* Screen reader only content */
.photoswipe-sr-only,
.photoswipe-live-region,
.photoswipe-aria-live {
position: absolute !important;
left: -10000px !important;
width: 1px !important;
height: 1px !important;
overflow: hidden !important;
}
/* Keyboard help dialog */
.photoswipe-keyboard-help {
position: fixed;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
background: var(--theme-background-color, white);
color: var(--theme-text-color, black);
padding: 30px;
border-radius: 12px;
box-shadow: 0 10px 40px rgba(0, 0, 0, 0.3);
z-index: 10001;
max-width: 500px;
max-height: 80vh;
overflow-y: auto;
}
.photoswipe-keyboard-help h2 {
margin: 0 0 20px 0;
font-size: 20px;
font-weight: 600;
}
.photoswipe-keyboard-help dl {
margin: 0;
padding: 0;
}
.photoswipe-keyboard-help dt {
float: left;
clear: left;
width: 120px;
margin-bottom: 10px;
}
.photoswipe-keyboard-help dd {
margin-left: 140px;
margin-bottom: 10px;
}
.photoswipe-keyboard-help kbd {
display: inline-block;
padding: 3px 8px;
background: var(--theme-kbd-background, #f0f0f0);
border: 1px solid var(--theme-border-color, #ccc);
border-radius: 4px;
font-family: monospace;
font-size: 14px;
box-shadow: 0 2px 0 rgba(0, 0, 0, 0.1);
}
.photoswipe-keyboard-help .close-help {
margin-top: 20px;
padding: 10px 20px;
background: var(--theme-primary-color, #4A90E2);
color: white;
border: none;
border-radius: 6px;
cursor: pointer;
font-size: 16px;
transition: background 0.2s;
}
.photoswipe-keyboard-help .close-help:hover,
.photoswipe-keyboard-help .close-help:focus {
background: var(--theme-primary-hover, #357ABD);
}
/* ==========================================================================
High Contrast Mode Support
========================================================================== */
@media (prefers-contrast: high) {
.pswp__bg {
background: #000 !important;
}
.pswp__button {
background: #000 !important;
border: 2px solid #fff !important;
}
.pswp__button svg {
fill: #fff !important;
}
.pswp__counter {
background: #000 !important;
color: #fff !important;
border: 2px solid #fff !important;
}
.gallery-thumbnail {
border-width: 3px !important;
}
.photoswipe-keyboard-help {
background: #000 !important;
color: #fff !important;
border: 2px solid #fff !important;
}
.photoswipe-keyboard-help kbd {
background: #fff !important;
color: #000 !important;
border-color: #fff !important;
}
}
/* Windows High Contrast Mode */
@media (-ms-high-contrast: active) {
.pswp__button {
border: 2px solid WindowText !important;
}
.pswp__counter {
border: 2px solid WindowText !important;
}
}
/* ==========================================================================
Reduced Motion Support (WCAG 2.1 Success Criterion 2.3.3)
========================================================================== */
@media (prefers-reduced-motion: reduce) {
/* Disable all animations */
.pswp *,
.pswp *::before,
.pswp *::after {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
}
/* Remove slide transitions */
.pswp__container {
transition: none !important;
}
/* Remove zoom animations */
.pswp__img {
transition: none !important;
}
/* Instant show/hide for indicators */
.photoswipe-swipe-indicators,
.photoswipe-gesture-hints {
animation: none !important;
transition: none !important;
}
}
/* ==========================================================================
Performance Optimizations
========================================================================== */
/* GPU acceleration for smooth animations */
.pswp__container,
.pswp__img,
.pswp__zoom-wrap {
will-change: transform;
transform: translateZ(0);
}
/* Optimize rendering for low-end devices */
@media (max-width: 768px) and (max-resolution: 2dppx) {
.pswp__img {
image-rendering: -webkit-optimize-contrast;
image-rendering: crisp-edges;
}
}
/* Reduce visual complexity on low-end devices */
.low-performance-mode .pswp__button {
box-shadow: none !important;
}
.low-performance-mode .gallery-thumbnail {
box-shadow: none !important;
transition: none !important;
}
/* ==========================================================================
Battery Optimization Mode
========================================================================== */
.battery-saver-mode .pswp__img {
filter: brightness(0.9);
}
.battery-saver-mode .pswp__button {
opacity: 0.8;
}
.battery-saver-mode .gallery-thumbnail-strip {
display: none;
}
/* ==========================================================================
Print Styles
========================================================================== */
@media print {
.pswp {
display: none !important;
}
}
/* ==========================================================================
Custom Scrollbar for Mobile
========================================================================== */
.gallery-thumbnail-strip::-webkit-scrollbar {
height: 4px;
}
.gallery-thumbnail-strip::-webkit-scrollbar-track {
background: rgba(255, 255, 255, 0.1);
border-radius: 2px;
}
.gallery-thumbnail-strip::-webkit-scrollbar-thumb {
background: rgba(255, 255, 255, 0.3);
border-radius: 2px;
}
.gallery-thumbnail-strip::-webkit-scrollbar-thumb:hover {
background: rgba(255, 255, 255, 0.5);
}
/* ==========================================================================
Safe Area Insets (for devices with notches)
========================================================================== */
.pswp__top-bar {
padding-top: env(safe-area-inset-top);
}
.photoswipe-bottom-sheet {
padding-bottom: env(safe-area-inset-bottom);
}
.pswp__button--arrow--left {
left: env(safe-area-inset-left, 10px);
}
.pswp__button--arrow--right {
right: env(safe-area-inset-right, 10px);
}

View File

@@ -1,253 +0,0 @@
/**
* Media Viewer Styles for Trilium Notes
* Customizes PhotoSwipe appearance to match Trilium's theme
*/
/* Base PhotoSwipe container customization */
.pswp {
--pswp-bg: rgba(0, 0, 0, 0.95);
--pswp-placeholder-bg: rgba(30, 30, 30, 0.9);
--pswp-icon-color: #fff;
--pswp-icon-color-secondary: rgba(255, 255, 255, 0.75);
--pswp-icon-stroke-color: #fff;
--pswp-icon-stroke-width: 1px;
--pswp-error-text-color: #f44336;
}
/* Dark theme adjustments */
body.theme-dark .pswp,
body.theme-next-dark .pswp {
--pswp-bg: rgba(0, 0, 0, 0.95);
--pswp-placeholder-bg: rgba(30, 30, 30, 0.9);
}
/* Light theme adjustments */
body.theme-light .pswp,
body.theme-next-light .pswp {
--pswp-bg: rgba(0, 0, 0, 0.9);
--pswp-placeholder-bg: rgba(50, 50, 50, 0.8);
}
/* Toolbar and controls styling */
.pswp__top-bar {
background-color: rgba(0, 0, 0, 0.5);
backdrop-filter: blur(10px);
}
.pswp__button {
transition: opacity 0.2s ease-in-out;
}
.pswp__button:hover {
opacity: 1;
}
/* Counter styling */
.pswp__counter {
font-family: var(--main-font-family);
font-size: 14px;
color: rgba(255, 255, 255, 0.9);
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.5);
}
/* Caption styling */
.pswp__caption {
background-color: rgba(0, 0, 0, 0.7);
backdrop-filter: blur(10px);
}
.pswp__caption__center {
text-align: center;
font-family: var(--main-font-family);
font-size: 14px;
color: rgba(255, 255, 255, 0.9);
padding: 10px 20px;
}
/* Image styling */
.pswp__img {
cursor: zoom-in;
}
.pswp__img--placeholder {
background-color: var(--pswp-placeholder-bg);
}
.pswp--zoomed-in .pswp__img {
cursor: grab;
}
.pswp--dragging .pswp__img {
cursor: grabbing;
}
/* Loading indicator */
.pswp__preloader {
width: 44px;
height: 44px;
}
.pswp__preloader__icn {
width: 20px;
height: 20px;
}
/* Error message styling */
.pswp__error-msg {
font-family: var(--main-font-family);
font-size: 14px;
color: var(--pswp-error-text-color);
text-align: center;
padding: 20px;
}
/* Thumbnails strip (for future implementation) */
.pswp__thumbnails {
position: absolute;
bottom: 0;
left: 0;
right: 0;
height: 80px;
background-color: rgba(0, 0, 0, 0.7);
backdrop-filter: blur(10px);
display: flex;
align-items: center;
padding: 10px;
gap: 10px;
overflow-x: auto;
z-index: 1;
}
.pswp__thumbnail {
width: 60px;
height: 60px;
cursor: pointer;
opacity: 0.6;
transition: opacity 0.2s;
object-fit: cover;
border: 2px solid transparent;
}
.pswp__thumbnail:hover {
opacity: 0.9;
}
.pswp__thumbnail--active {
opacity: 1;
border-color: var(--main-border-color);
}
/* Animations */
.pswp--open {
animation: pswpFadeIn 0.25s ease-out;
}
.pswp--closing {
animation: pswpFadeOut 0.25s ease-out;
}
@keyframes pswpFadeIn {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
@keyframes pswpFadeOut {
from {
opacity: 1;
}
to {
opacity: 0;
}
}
/* Zoom animation */
.pswp__zoom-wrap {
transition: transform 0.25s cubic-bezier(0.4, 0, 0.2, 1);
}
/* Mobile-specific adjustments */
@media (max-width: 768px) {
.pswp__caption__center {
font-size: 12px;
padding: 8px 16px;
}
.pswp__counter {
font-size: 12px;
}
.pswp__thumbnails {
height: 60px;
}
.pswp__thumbnail {
width: 45px;
height: 45px;
}
}
/* Integration with Trilium's note context */
.media-viewer-trigger {
cursor: zoom-in;
transition: opacity 0.2s;
}
.media-viewer-trigger:hover {
opacity: 0.9;
}
/* Gallery mode indicators */
.media-viewer-gallery-indicator {
position: absolute;
top: 10px;
right: 10px;
background-color: rgba(0, 0, 0, 0.6);
color: white;
padding: 4px 8px;
border-radius: 4px;
font-size: 12px;
font-family: var(--main-font-family);
pointer-events: none;
z-index: 1;
}
/* Fullscreen mode adjustments */
.pswp--fs {
background-color: black;
}
.pswp--fs .pswp__top-bar {
background-color: rgba(0, 0, 0, 0.7);
}
/* Accessibility improvements */
.pswp__button:focus {
outline: 2px solid var(--main-border-color);
outline-offset: 2px;
}
.pswp__img:focus {
outline: none;
}
/* Custom toolbar buttons */
.pswp__button--download {
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='white' stroke-width='2'%3E%3Cpath d='M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4'%3E%3C/path%3E%3Cpolyline points='7 10 12 15 17 10'%3E%3C/polyline%3E%3Cline x1='12' y1='15' x2='12' y2='3'%3E%3C/line%3E%3C/svg%3E");
background-size: 24px 24px;
}
.pswp__button--info {
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='white' stroke-width='2'%3E%3Ccircle cx='12' cy='12' r='10'%3E%3C/circle%3E%3Cline x1='12' y1='16' x2='12' y2='12'%3E%3C/line%3E%3Cline x1='12' y1='8' x2='12.01' y2='8'%3E%3C/line%3E%3C/svg%3E");
background-size: 24px 24px;
}
/* Print styles */
@media print {
.pswp {
display: none !important;
}
}

View File

@@ -967,7 +967,7 @@
},
"protected_session": {
"enter_password_instruction": "显示受保护的笔记需要输入您的密码:",
"start_session_button": "开始受保护的会话",
"start_session_button": "开始受保护的会话 <kbd>Enter</kbd>",
"started": "受保护的会话已启动。",
"wrong_password": "密码错误。",
"protecting-finished-successfully": "保护操作已成功完成。",
@@ -1028,7 +1028,7 @@
"error_creating_anonymized_database": "无法创建匿名化数据库,请检查后端日志以获取详细信息",
"successfully_created_fully_anonymized_database": "成功创建完全匿名化的数据库,路径为 {{anonymizedFilePath}}",
"successfully_created_lightly_anonymized_database": "成功创建轻度匿名化的数据库,路径为 {{anonymizedFilePath}}",
"no_anonymized_database_yet": "尚无匿名化数据库"
"no_anonymized_database_yet": "尚无匿名化数据库"
},
"database_integrity_check": {
"title": "数据库完整性检查",
@@ -1333,7 +1333,7 @@
"oauth_title": "OAuth/OpenID 认证",
"oauth_description": "OpenID 是一种标准化方式,允许您使用其他服务(如 Google的账号登录网站来验证您的身份。默认的身份提供者是 Google但您可以更改为任何其他 OpenID 提供者。点击<a href=\"#root/_hidden/_help/_help_Otzi9La2YAUX/_help_WOcw2SLH6tbX/_help_7DAiwaf8Z7Rz\">这里</a>了解更多信息。请参阅这些 <a href=\"https://developers.google.com/identity/openid-connect/openid-connect\">指南</a> 通过 Google 设置 OpenID 服务。",
"oauth_description_warning": "要启用 OAuth/OpenID您需要设置 config.ini 文件中的 OAuth/OpenID 基础 URL、客户端 ID 和客户端密钥,并重新启动应用程序。如果要从环境变量设置,请设置 TRILIUM_OAUTH_BASE_URL、TRILIUM_OAUTH_CLIENT_ID 和 TRILIUM_OAUTH_CLIENT_SECRET 环境变量。",
"oauth_missing_vars": "缺少以下设置项: {{missingVars}}",
"oauth_missing_vars": "缺少以下设置项{{variables}}",
"oauth_user_account": "用户账号: ",
"oauth_user_email": "用户邮箱: ",
"oauth_user_not_logged_in": "未登录!"

View File

@@ -156,7 +156,9 @@
"showSQLConsole": "afficher la console SQL",
"other": "Autre",
"quickSearch": "aller à la recherche rapide",
"inPageSearch": "recherche sur la page"
"inPageSearch": "recherche sur la page",
"title": "Aide-mémoire",
"newTabWithActivationNoteLink": "Lorsquon clique sur un lien de note, celle-ci souvre et devient active dans un nouvel onglet"
},
"import": {
"importIntoNote": "Importer dans la note",
@@ -377,7 +379,7 @@
"share_root": "partage cette note à l'adresse racine /share.",
"share_description": "définir le texte à ajouter à la balise méta HTML pour la description",
"share_raw": "la note sera servie dans son format brut, sans wrapper HTML",
"share_disallow_robot_indexing": "interdira l'indexation par robot de cette note via l'en-tête <code>X-Robots-Tag: noindex</code>",
"share_disallow_robot_indexing": "Interdira l'indexation par robot de cette note via l'en-tête <code>X-Robots-Tag: noindex</code>",
"share_credentials": "exiger des informations didentification pour accéder à cette note partagée. La valeur devrait être au format « nom d'utilisateur : mot de passe ». N'oubliez pas de rendre cela héritable pour l'appliquer aux notes/images enfants.",
"share_index": "la note avec ce label listera toutes les racines des notes partagées",
"display_relations": "noms des relations délimités par des virgules qui doivent être affichés. Tous les autres seront masqués.",
@@ -536,7 +538,7 @@
},
"attachments_actions": {
"open_externally": "Ouverture externe",
"open_externally_title": "Le fichier sera ouvert dans une application externe et les modifications apportées seront surveillées. \nVous pourrez ensuite téléverser la version modifiée dans Trilium.",
"open_externally_title": "Le fichier sera ouvert dans une application externe et surveillé pour détecter les modifications. Vous pourrez ensuite téléverser la version modifiée dans Trilium.",
"open_custom": "Ouvrir avec",
"open_custom_title": "Le fichier sera ouvert dans une application externe et surveillé pour les modifications. Vous pourrez ensuite téléverser la version modifiée sur Trilium.",
"download": "Télécharger",
@@ -1134,7 +1136,7 @@
"note_erasure_timeout": {
"note_erasure_timeout_title": "Délai d'effacement des notes",
"note_erasure_description": "Les notes supprimées (et les attributs, versions...) sont seulement marquées comme supprimées et il est possible de les récupérer à partir de la boîte de dialogue Notes récentes. Après un certain temps, les notes supprimées sont « effacées », ce qui signifie que leur contenu n'est plus récupérable. Ce paramètre vous permet de configurer la durée entre la suppression et l'effacement de la note.",
"erase_notes_after": "Effacer les notes après",
"erase_notes_after": "Effacer les notes après:",
"manual_erasing_description": "Vous pouvez également déclencher l'effacement manuellement (sans tenir compte de la durée définie ci-dessus) :",
"erase_deleted_notes_now": "Effacer les notes supprimées maintenant",
"deleted_notes_erased": "Les notes supprimées ont été effacées."

View File

@@ -55,7 +55,11 @@
"show_help": "ヘルプを表示",
"about": "Trilium Notesについて",
"logout": "ログアウト",
"show-cheatsheet": "チートシートを表示"
"show-cheatsheet": "チートシートを表示",
"zoom_out": "ズームアウト",
"zoom_in": "ズームイン",
"advanced": "高度",
"toggle-zen-mode": "禅モード"
},
"left_pane_toggle": {
"show_panel": "パネルを表示",
@@ -66,23 +70,26 @@
"move_right": "右に移動"
},
"clone_to": {
"notes_to_clone": "複製するノート",
"notes_to_clone": "クローンするノート",
"target_parent_note": "ターゲットの親ノート",
"search_for_note_by_its_name": "ノート名で検索",
"cloned_note_prefix_title": "複製されたノートは、指定された接頭辞を付けてノートツリーに表示されます",
"cloned_note_prefix_title": "クローンされたノートは、指定された接頭辞を付けてノートツリーに表示されます",
"prefix_optional": "接頭辞(任意)",
"clone_to_selected_note": "選択したノートに複製",
"no_path_to_clone_to": "複製先のパスが存在しません。",
"note_cloned": "ノート \"{{clonedTitle}}\" は \"{{targetTitle}}\" に複製されました"
"clone_to_selected_note": "選択したノートにクローン",
"no_path_to_clone_to": "クローン先のパスが存在しません。",
"note_cloned": "ノート \"{{clonedTitle}}\" は \"{{targetTitle}}\" にクローンされました",
"clone_notes_to": "ノートをクローンして...",
"help_on_links": "ヘルプへのリンク"
},
"delete_notes": {
"delete_all_clones_description": "すべての複製も削除する(最近の変更では元に戻すことができる)",
"delete_all_clones_description": "すべてのクローンも削除する(最近の変更では元に戻すことができる)",
"erase_notes_description": "通常の(ソフト)削除では、ノートは削除されたものとしてマークされ、一定期間内に(最近の変更で)削除を取り消すことができます。このオプションをオンにすると、ノートは即座に削除され、削除を取り消すことはできません。",
"erase_notes_warning": "すべての複製を含め、ノートを完全に消去します(元に戻せません)。これにより、アプリケーションは強制的にリロードされます。",
"erase_notes_warning": "すべてのクローンを含め、ノートを完全に消去します(元に戻せません)。これにより、アプリケーションは強制的にリロードされます。",
"notes_to_be_deleted": "以下のノートが削除されます ({{notesCount}})",
"no_note_to_delete": "ノートは削除されません(複製のみ)。",
"no_note_to_delete": "ノートは削除されません(クローンのみ)。",
"cancel": "キャンセル",
"ok": "OK"
"ok": "OK",
"close": "閉じる"
},
"calendar": {
"mon": "月",
@@ -113,14 +120,24 @@
},
"basic_properties": {
"note_type": "ノートタイプ",
"editable": "編集可能"
"editable": "編集可能",
"basic_properties": "基本プロパティ",
"language": "言語"
},
"i18n": {
"title": "ローカライゼーション",
"language": "言語",
"first-day-of-the-week": "週の最初",
"sunday": "日曜日",
"monday": "月曜日"
"monday": "月曜日",
"first-week-of-the-year": "年の最初の週",
"first-week-contains-first-day": "最初の週には、元日が含まれる",
"first-week-contains-first-thursday": "最初の週には、その年の最初の木曜日が含まれる",
"first-week-has-minimum-days": "最初の週は最低日数",
"min-days-in-first-week": "最初の週の最低日数",
"first-week-info": "最初の週は、その年の最初の木曜日を含む週を指し、<a href=\"https://en.wikipedia.org/wiki/ISO_week_date#First_week\">ISO 8601</a>規格に基づいています。",
"first-week-warning": "最初の週のオプションを変更すると、既存のウィークノートと重複する可能性があり、既存のウィークノートはそれに応じて更新されません。",
"formatting-locale": "日付と数値のフォーマット"
},
"tab_row": {
"close_tab": "タブを閉じる",
@@ -159,7 +176,11 @@
"action": "アクション",
"search_button": "検索 <kbd>Enter</kbd>",
"search_execute": "検索とアクションの実行",
"save_to_note": "ノートに保存"
"save_to_note": "ノートに保存",
"search_parameters": "検索パラメータ",
"unknown_search_option": "不明な検索オプション {{searchOptionName}}",
"search_note_saved": "検索ノートが {{- notePathTitle}} に保存されました",
"actions_executed": "アクションが実行されました。"
},
"shortcuts": {
"multiple_shortcuts": "同じアクションに対して複数のショートカットを設定する場合、カンマで区切ることができます。",
@@ -171,6 +192,675 @@
"description": "説明",
"reload_app": "リロードして変更を適用する",
"set_all_to_default": "すべてのショートカットをデフォルトに戻す",
"confirm_reset": "キーボードショートカットをすべてデフォルトにリセットしますか?"
"confirm_reset": "キーボードショートカットをすべてデフォルトにリセットしますか?",
"keyboard_shortcuts": "キーボードショートカット"
},
"confirm": {
"confirmation": "確認",
"cancel": "キャンセル",
"ok": "OK"
},
"export": {
"export_note_title": "ノートをエクスポート",
"close": "閉じる",
"export_type_subtree": "このノートとすべての子孫",
"format_html": "HTML - すべての書式が保たれるため、おすすめ",
"format_html_zip": "HTML ZIPアーカイブ - すべての書式が保たれるため、推奨されます。",
"format_markdown": "Markdown - ほとんどの書式が維持される。",
"format_opml": "OPML - テキストのみのアウトライン交換フォーマットです。書式設定、画像、ファイルは含まれません。",
"opml_version_1": "OPML v1.0 - プレーンテキストのみ",
"opml_version_2": "OPML v2.0 - HTMLが許可されています",
"export_type_single": "このノートのみで、子孫ノートは含まない",
"export": "エクスポート",
"choose_export_type": "最初にエクスポートタイプを選択してください",
"export_status": "エクスポート状況",
"export_in_progress": "エクスポート処理中: {{progressCount}}",
"export_finished_successfully": "エクスポートが正常に完了しました。",
"format_pdf": "PDF - 印刷または共有目的に。"
},
"help": {
"title": "チートシート",
"noteNavigation": "ノートナビゲーション",
"collapseExpand": "ノードの格納/展開",
"goBackForwards": "履歴を戻る/進む",
"scrollToActiveNote": "アクティブノートまでスクロール",
"jumpToParentNote": "親ノートへ移動",
"collapseWholeTree": "すべてのノートツリーを格納",
"collapseSubTree": "サブツリーを格納",
"tabShortcuts": "タブショートカット",
"newTabNoteLink": "ノートのリンクをクリックすると、新しいタブで開く",
"newTabWithActivationNoteLink": "ノートのリンクをクリックすると、新しいタブで開き、アクティブにします",
"onlyInDesktop": "デスクトップ版Electronビルドのみ",
"openEmptyTab": "空のタブを開く",
"closeActiveTab": "アクティブなタブを閉じる",
"activateNextTab": "次のタブに移動",
"activatePreviousTab": "前のタブに移動",
"creatingNotes": "ノートの作成",
"createNoteAfter": "アクティブなノートの後ろに新しいノートを作成",
"createNoteInto": "アクティブなノートに新しいサブノートを作成",
"movingCloningNotes": "ノートの移動/クローン",
"moveNoteUpHierarchy": "階層内でノートを上下に移動",
"multiSelectNote": "複数選択に上/下のノートを追加",
"selectAllNotes": "現在のレベルのノートをすべて選択",
"selectNote": "ノートを選択",
"copyNotes": "アクティブなノート(または現在の選択範囲)をクリップボードにコピーする(<a class=\"external\" href=\"https://triliumnext.github.io/Docs/Wiki/cloning-notes.html#cloning-notes\">クローン</a>に使用)",
"cutNotes": "アクティブなノート(または現在の選択範囲)をクリップボードにカットする(ノートの移動に使用)",
"pasteNotes": "ノートをサブノートとしてアクティブノートに貼り付ける(コピーされたかカットされたかに よって、移動またはクローンになる)",
"deleteNotes": "ノート/サブツリーを削除",
"editingNotes": "ノート編集",
"editNoteTitle": "押下するとツリーペインからタイトルの編集に移ります。タイトルの編集からEnterキーを押すと、本文の編集に移動します。<kbd>Ctrl+.</kbd> で本文の編集からツリーペインに戻ります。",
"createEditLink": "外部リンクの作成/編集",
"createInternalLink": "内部リンクの作成",
"followLink": "カーソル下のリンクをたどる",
"insertDateTime": "カーソル位置に現在の日時を挿入",
"jumpToTreePane": "ツリーペインにジャンプし、アクティブなノートまでスクロール",
"markdownAutoformat": "Markdownライクな自動フォーマット",
"headings": "<code>##</code>, <code>###</code>, <code>####</code> など。その後にスペースで見出しになる",
"bulletList": "<code>*</code> または <code>-</code> その後にスペースで箇条書きになる",
"numberedList": "<code>1.</code> または <code>1)</code> その後にスペースで番号付きリストになる",
"blockQuote": "行の先頭に <code>></code> その後にスペースで引用になる",
"troubleshooting": "トラブルシューティング",
"reloadFrontend": "Triliumのフロントエンドをリロード",
"showDevTools": "開発者ツールを表示",
"showSQLConsole": "SQLコンソールを表示",
"other": "その他",
"quickSearch": "クイックサーチにフォーカス",
"inPageSearch": "ページ内検索"
},
"import": {
"importIntoNote": "ノートにインポート",
"chooseImportFile": "インポートするファイルを選択",
"importDescription": "選択されたファイルの内容は、子ノートとしてインポートされます",
"options": "オプション",
"safeImportTooltip": "Triliumの <code>.zip</code> 形式のエクスポートファイルには、有害な動作を含む実行可能スクリプトが含まれている可能性があります。セーフインポートは、インポートされたすべてのスクリプトの自動実行を無効にします。インポートするファイルの内容を完全に信頼できる場合のみ、「セーフインポート」のチェックを外してください。",
"safeImport": "セーフインポート",
"explodeArchivesTooltip": "これがチェックされている場合、Triliumは<code>.zip</code>、<code>.enex</code>、<code>.opml</code>ファイルを読み込み、それらのアーカイブからートを作成します。チェックされていない場合、Triliumはアーカイブ自体をートに添付します。",
"shrinkImagesTooltip": "<p>これをチェックすると、Triliumはインポートされた画像を、拡大縮小や最適化によって縮小しようとします。これにより、画像の品質が損なわれる可能性があります。チェックを外すと、画像は変更されずにインポートされます。</p><p>これは、メタデータ付きの<code>.zip</code>インポートには適用されません。これらのファイルは既に最適化されていると考えられるためです。</p>",
"shrinkImages": "画像を縮小",
"textImportedAsText": "メタデータから判断できない場合は、HTML、Markdown、TXTをテキストートとしてインポート",
"codeImportedAsCode": "メタデータから判断できない場合は、コードファイル(例: <code>.json</code>)をコードノートとしてインポート",
"replaceUnderscoresWithSpaces": "インポートされたノート名のアンダーバーをスペースに置換する",
"import": "インポート",
"failed": "インポートに失敗しました: {{message}}.",
"html_import_tags": {
"title": "HTMLインポートタグ",
"description": "インポート時に保持するHTMLタグを設定します。このリストにないタグはインポート時に削除されます。一部のタグ'script'など)は、セキュリティ上の懸念から常に削除されます。",
"placeholder": "HTMLタグを1行に1つ入力",
"reset_button": "リストをデフォルトにリセット"
},
"import-status": "インポート状況",
"in-progress": "インポート中: {{progress}}",
"successful": "インポートは正常に終了しました。"
},
"password_not_set": {
"title": "パスワードが設定されていない",
"body1": "保護されたノートはユーザーのパスワードを使用して暗号化されますが、パスワードはまだ設定されていません。",
"body2": "ノートを保護するには、下のボタンをクリックしてオプションダイアログを開き、パスワードを設定してください。",
"go_to_password_options": "パスワードのオプションへ"
},
"recent_changes": {
"title": "最近の変更",
"erase_notes_button": "削除したメモを今すぐ消去する",
"deleted_notes_message": "削除されたメモは完全に消去されました。",
"no_changes_message": "変更はまだありません...",
"undelete_link": "削除を取り消す",
"confirm_undelete": "このノートとサブノートを復元しますか?"
},
"sort_child_notes": {
"sort_children_by": "子ノートの並び替え...",
"sorting_criteria": "ソート基準",
"title": "タイトル",
"date_created": "作成日",
"date_modified": "更新日",
"sorting_direction": "ソート方向",
"ascending": "昇順",
"descending": "降順",
"folders": "フォルダ",
"sort_folders_at_top": "フォルダーを一番上にソートする",
"natural_sort": "自然順",
"sort_with_respect_to_different_character_sorting": "言語や地域によって異なる文字の並べ替えや照合順序の規則に従ってソートする。",
"sort": "ソート",
"natural_sort_language": "自然順言語",
"the_language_code_for_natural_sort": "自然順の言語コード。例えば、中国語の場合は \"zh-CN\"。"
},
"close_pane_button": {
"close_this_pane": "ペインを閉じる"
},
"create_pane_button": {
"create_new_split": "新しく分割する"
},
"edit_button": {
"edit_this_note": "このノートを編集"
},
"show_toc_widget_button": {
"show_toc": "目次を表示"
},
"show_highlights_list_widget_button": {
"show_highlights_list": "ハイライト一覧を表示"
},
"relation_map_buttons": {
"zoom_out_title": "ズームアウト",
"zoom_in_title": "ズームイン"
},
"tree-context-menu": {
"advanced": "高度",
"open-in-a-new-tab": "新しいタブで開く <kbd>Ctrl+Click</kbd>",
"open-in-a-new-split": "新しく分割して開く",
"insert-note-after": "ノートを後ろに挿入",
"insert-child-note": "子ノートを挿入",
"delete": "削除",
"search-in-subtree": "サブツリー内を検索",
"expand-subtree": "サブツリーを展開",
"collapse-subtree": "サブツリーを折りたたむ",
"sort-by": "並べ替え...",
"recent-changes-in-subtree": "サブツリー内の最近の変更",
"copy-note-path-to-clipboard": "ノートのパスをクリップボードにコピー",
"protect-subtree": "サブツリーを保護",
"unprotect-subtree": "サブツリーの保護を解除",
"copy-clone": "コピー/クローン",
"clone-to": "クローンして...",
"cut": "カット",
"move-to": "移動して...",
"paste-into": "貼り付け",
"paste-after": "後ろに貼り付け",
"duplicate": "複製",
"export": "エクスポート",
"import-into-note": "ノートにインポート",
"apply-bulk-actions": "一括操作の適用",
"converted-to-attachments": "{{count}}ノートが添付ファイルに変換されました。",
"convert-to-attachment": "添付ファイルに変換",
"convert-to-attachment-confirm": "選択したノートを親ノートの添付ファイルに変換しますか?",
"open-in-popup": "クイックエディット"
},
"zen_mode": {
"button_exit": "禅モードを退出"
},
"sync_status": {
"unknown": "<p>同期状況は、次回の同期が開始されるとわかるようになります。</p><p>クリックして今すぐ同期を開始する。</p>",
"connected_with_changes": "<p>同期サーバーに接続されました。<br>まだ同期されていない未処理の変更がいくつかあります。</p><p>クリックして同期を開始。</p>",
"connected_no_changes": "<p>同期サーバーに接続されました。<br>すべての変更はすでに同期されています。</p><p>クリックして同期を開始。</p>",
"disconnected_with_changes": "<p>同期サーバーへの接続の確立に失敗しました。<br>まだ同期されていない未処理の変更がいくつかあります。</p><p>クリックして同期を開始。</p>",
"disconnected_no_changes": "<p>同期サーバーへの接続確立に失敗しました。<br>既知の変更はすべて同期されました。</p><p>クリックして同期を開始。</p>",
"in_progress": "サーバーと同期中です。"
},
"note_actions": {
"re_render_note": "ノートを再描画",
"search_in_note": "ノート内検索",
"note_source": "ノートのソース",
"open_note_externally": "外部でノートを開く",
"open_note_externally_title": "ファイルを外部アプリケーションで開き、変更を監視します。その後、変更されたバージョンをTriliumにアップロードできるようになります。",
"open_note_custom": "プログラムからノートを開く",
"import_files": "ファイルをインポート",
"export_note": "ノートをエクスポート",
"delete_note": "ノートを削除する",
"print_note": "ノートを印刷",
"print_pdf": "PDFとしてエクスポート..."
},
"command_palette": {
"export_note_title": "ノートをエクスポート",
"search_subtree_title": "サブツリー内を検索"
},
"delete_note": {
"delete_note": "ノートを削除する"
},
"board_view": {
"delete-note": "ノートを削除する"
},
"code_buttons": {
"execute_button_title": "スクリプトを実行",
"trilium_api_docs_button_title": "Trilium APIのドキュメントを開く",
"save_to_note_button_title": "ノートに保存",
"opening_api_docs_message": "APIドキュメントを開いています...",
"sql_console_saved_message": "SQLコンソールが {{note_path}} に保存されました"
},
"execute_script": {
"execute_script": "スクリプトを実行"
},
"script_executor": {
"execute_script": "スクリプトを実行",
"query": "クエリ",
"script": "スクリプト",
"execute_query": "クエリを実行"
},
"hide_floating_buttons_button": {
"button_title": "ボタンを非表示"
},
"show_floating_buttons_button": {
"button_title": "ボタンを表示"
},
"svg_export_button": {
"button_title": "図をSVGとしてエクスポート"
},
"book_properties": {
"grid": "グリッド",
"list": "リスト",
"collapse_all_notes": "すべてのノートを格納",
"expand_all_children": "すべての子を展開",
"collapse": "格納",
"expand": "展開",
"book_properties": "コレクションプロパティ",
"invalid_view_type": "無効なビュータイプ '{{type}}'",
"view_type": "ビュータイプ",
"calendar": "カレンダー",
"table": "テーブル",
"geo-map": "ジオマップ",
"board": "ボード"
},
"note_types": {
"geo-map": "ジオマップ",
"file": "ファイル",
"image": "画像",
"text": "テキスト",
"code": "コード",
"saved-search": "検索の保存",
"relation-map": "関係マップ",
"note-map": "ノートマップ",
"render-note": "レンダリングノート",
"book": "コレクション",
"mermaid-diagram": "Mermaidダイアグラム",
"canvas": "キャンバス",
"web-view": "Web ビュー",
"mind-map": "マインドマップ",
"launcher": "ランチャー",
"doc": "ドキュメント",
"widget": "ウィジェット",
"confirm-change": "ノートの内容が空ではない場合、ノートタイプを変更することは推奨されません。続行しますか?",
"beta-feature": "Beta",
"ai-chat": "AI チャット",
"task-list": "タスクリスト",
"new-feature": "新しい",
"collections": "コレクション"
},
"edited_notes": {
"no_edited_notes_found": "この日の編集されたメモはまだありません...",
"title": "編集されたノート",
"deleted": "(削除済み)"
},
"file_properties": {
"note_id": "ノート ID",
"file_type": "ファイルタイプ",
"file_size": "ファイルサイズ",
"download": "ダウンロード",
"open": "開く",
"title": "ファイル"
},
"note_info_widget": {
"note_id": "ノート ID",
"created": "作成日時",
"modified": "更新日時",
"type": "タイプ",
"note_size": "ノートサイズ",
"calculate": "計算",
"subtree_size": "(サブツリーサイズ: {{size}}、ノード数: {{count}}",
"title": "ノート情報"
},
"image_properties": {
"file_type": "ファイルタイプ",
"file_size": "ファイルサイズ",
"download": "ダウンロード",
"open": "開く",
"title": "画像"
},
"revisions": {
"download_button": "ダウンロード",
"delete_button": "削除"
},
"attachments_actions": {
"download": "ダウンロード"
},
"etapi": {
"created": "作成日時",
"title": "ETAPI",
"description": "ETAPI は、Trilium インスタンスに UI なしでプログラム的にアクセスするための REST API です。",
"see_more": "詳細は{{- link_to_wiki}}と{{- link_to_openapi_spec}}または{{- link_to_swagger_ui }}を参照してください。",
"wiki": "wiki",
"openapi_spec": "ETAPI OpenAPIの仕様",
"swagger_ui": "ETAPI Swagger UI",
"create_token": "新しくETAPIトークンを作成",
"existing_tokens": "既存のトークン",
"no_tokens_yet": "トークンはまだありません。上のボタンをクリックして作成してください。",
"token_name": "トークン名",
"actions": "アクション",
"new_token_title": "新しいETAPIトークン",
"new_token_message": "新しいトークンの名前を入力",
"rename_token_message": "新しいトークンの名前を入力",
"default_token_name": "新しいトークン",
"error_empty_name": "トークン名は空にできません",
"token_created_title": "ETAPIトークン作成",
"token_created_message": "作成されたトークンをクリップボードにコピーします。Trilium はトークンをハッシュ化して保存するため、これがトークンを見る最後の機会となります。",
"rename_token": "トークン名を変更",
"delete_token": "このトークンを削除/無効にする",
"rename_token_title": "トークン名の変更",
"delete_token_confirmation": "本当にETAPIトークン\"{{name}}\"を削除しますか?"
},
"note_paths": {
"title": "ノートパス",
"clone_button": "ノートを新しい場所にクローン...",
"intro_placed": "このノートは以下のパスに置かれる:",
"intro_not_placed": "このノートはまだノートツリーに配置されていません。",
"archived": "アーカイブされた",
"search": "検索"
},
"note_properties": {
"info": "情報"
},
"similar_notes": {
"title": "類似ノート",
"no_similar_notes_found": "類似したノートが見つかりません。"
},
"abstract_search_option": {
"remove_this_search_option": "この検索オプションを削除"
},
"debug": {
"debug": "デバッグ",
"debug_info": "デバッグは、複雑なクエリのデバッグを支援するために、追加のデバッグ情報をコンソールに表示します。",
"access_info": "デバッグ情報にアクセスするには、クエリを実行し、左上隅にある \"バックエンドログを表示 \"をクリックしてください。"
},
"fast_search": {
"fast_search": "高速検索",
"description": "高速検索オプションは、ノートの全文検索を無効にし、大規模データベースでの検索を高速化します。"
},
"include_archived_notes": {
"include_archived_notes": "アーカイブされたノートを含む"
},
"limit": {
"limit": "リミット",
"take_first_x_results": "最初からX個の結果のみを取得。"
},
"order_by": {
"order_by": "並べ替え",
"relevancy": "関連性(デフォルト)",
"title": "タイトル",
"date_created": "作成日",
"date_modified": "最終更新日",
"content_size": "ノート内容のサイズ",
"children_count": "子ノートの数",
"parent_count": "クローンの数",
"random": "ランダムな順番",
"asc": "昇順(デフォルト)",
"desc": "降順"
},
"table_view": {
"sort-column-descending": "降順",
"sort-column-ascending": "昇順"
},
"search_script": {
"title": "検索スクリプト:",
"placeholder": "ノート名で検索"
},
"include_note": {
"placeholder_search": "ノート名で検索",
"dialog_title": "埋め込みノート",
"box_size_prompt": "埋め込みノート枠のサイズ:",
"button_include": "埋め込みノート"
},
"ancestor": {
"placeholder": "ノート名で検索"
},
"move_to": {
"search_placeholder": "ノート名で検索"
},
"web_view": {
"web_view": "Web ビュー",
"embed_websites": "Web ビュータイプでは、ウェブサイトをTriliumに埋め込むことができます。",
"create_label": "まず始めに、埋め込みたいURLアドレスのラベルを作成してください。例: #webViewSrc=\"https://www.google.com\""
},
"backend_log": {
"refresh": "リフレッシュ"
},
"sync": {
"title": "同期"
},
"fonts": {
"fonts": "フォント",
"main_font": "メインフォント",
"font_family": "フォントファミリー",
"size": "サイズ",
"note_tree_font": "ノートツリーフォント",
"note_detail_font": "ノート詳細フォント",
"monospace_font": "等幅(コード)フォント",
"note_tree_and_detail_font_sizing": "ツリーと詳細のフォントサイズは、メインのフォントサイズに対して相対的であることに注意してください。",
"not_all_fonts_available": "リストされているすべてのフォントが、お使いのシステムで利用できるとは限りません。",
"apply_font_changes": "フォントの変更を適用するには、クリックしてください",
"reload_frontend": "フロントエンドをリロード",
"generic-fonts": "一般的なフォント",
"sans-serif-system-fonts": "サンセリフのシステムフォント",
"serif-system-fonts": "セリフのシステムフォント",
"monospace-system-fonts": "等幅のシステムフォント",
"handwriting-system-fonts": "手書きのシステムフォント",
"serif": "セリフ",
"sans-serif": "サンセリフ",
"monospace": "等幅",
"system-default": "システムのデフォルト"
},
"max_content_width": {
"reload_button": "フロントエンドをリロード"
},
"theme": {
"title": "アプリのテーマ",
"theme_label": "テーマ",
"override_theme_fonts_label": "テーマのフォントを上書き",
"auto_theme": "レガシー(システムの配色に従う)",
"light_theme": "レガシー(ライト)",
"dark_theme": "レガシー(ダーク)",
"triliumnext": "Triliumシステムの配色に従う",
"triliumnext-light": "Triliumライト",
"triliumnext-dark": "Triliumダーク",
"layout": "レイアウト",
"layout-vertical-title": "垂直",
"layout-horizontal-title": "水平",
"layout-vertical-description": "ランチャーバーは左側(デフォルト)",
"layout-horizontal-description": "ランチャーバーはタブバーの下にあり、タブバーは全幅に。"
},
"vim_key_bindings": {
"use_vim_keybindings_in_code_notes": "Vimキーバインド",
"enable_vim_keybindings": "Vimキーバインドをコードートで有効にしますexモードはありません"
},
"wrap_lines": {
"wrap_lines_in_code_notes": "コードノートで行を折り返す",
"enable_line_wrap": "行の折り返しを有効にする(変更を適用にするにはフロントエンドのリロードが必要な場合があります)"
},
"images": {
"images_section_title": "画像",
"download_images_automatically": "画像を自動的にダウンロードしてオフラインで使用可能にする。",
"download_images_description": "貼り付けられたHTMLにはオンライン画像への参照が含まれていることがありますが、Triliumはそれらの参照を見つけて画像をダウンロードし、オフラインで利用できるようにします。",
"enable_image_compression": "画像の圧縮を有効にする",
"max_image_dimensions": "画像の最大幅/高さ(この設定を超えると画像はリサイズされます)。",
"max_image_dimensions_unit": "ピクセル",
"jpeg_quality_description": "JPEGの品質10 - 最低品質、100 - 最高品質、50 - 80を推奨"
},
"search_engine": {
"title": "検索エンジン",
"custom_search_engine_info": "カスタム検索エンジンは、名前とURLの両方を設定する必要があります。どちらも設定されていない場合、DuckDuckGoがデフォルトの検索エンジンとして使用されます。",
"predefined_templates_label": "定義済みの検索エンジンのテンプレート",
"bing": "Bing",
"baidu": "Baidu",
"duckduckgo": "DuckDuckGo",
"google": "Google",
"custom_name_label": "カスタム検索エンジンの名前",
"custom_name_placeholder": "カスタム検索エンジンの名前",
"custom_url_label": "カスタム検索エンジンのURLには、検索語句のプレースホルダーとして {keyword} を含める必要があります。",
"custom_url_placeholder": "カスタム検索エンジンのurl",
"save_button": "保存"
},
"tray": {
"title": "システムトレイ",
"enable_tray": "トレイを有効にする (この変更を適用にするには、Triliumを再起動する必要があります)"
},
"heading_style": {
"title": "見出しのスタイル",
"plain": "プレーン",
"underline": "下線",
"markdown": "Markdownスタイル"
},
"highlights_list": {
"title": "ハイライトリスト",
"description": "右のパネルに表示されるハイライトリストをカスタマイズできます:",
"bold": "太字",
"italic": "イタリック体",
"underline": "下線",
"color": "カラーテキスト",
"bg_color": "背景色付きテキスト",
"visibility_title": "ハイライトリスト表示",
"visibility_description": "#hideHighlightWidget ラベルを追加することで、ノートごとにハイライトウィジェットを非表示にできます。",
"shortcut_info": "オプション -> ショートカット('右ペイン切り替え')で、右ペイン(ハイライトを含む)を素早く切り替えるキーボードショートカットを設定できます。"
},
"table_of_contents": {
"title": "目次",
"description": "ノートに定義された数以上の見出しがある場合、テキストノートに目次が表示されます。この数はカスタマイズできます:",
"unit": "見出し",
"disable_info": "このオプションに非常に大きな数値を設定することで、目次を効果的に無効にすることもできる。",
"shortcut_info": "オプション -> ショートカット('右ペイン切り替え')で、右ペイン(目次を含む)を素早く切り替えるキーボードショートカットを設定できます。"
},
"toc": {
"table_of_contents": "目次"
},
"text_auto_read_only_size": {
"title": "自動読み取り専用のサイズ",
"description": "自動読み取り専用のノートサイズは、ノートが読み取り専用モード(パフォーマンス上の理由)で表示されるようになるサイズです。",
"label": "自動読み取り専用のサイズ(テキストノート)",
"unit": "文字"
},
"code_auto_read_only_size": {
"title": "自動読み取り専用のサイズ",
"description": "自動読み取り専用のノートサイズは、ノートが読み取り専用モード(パフォーマンス上の理由)で表示されるようになるサイズです。",
"unit": "文字"
},
"custom_date_time_format": {
"title": "日付/時刻フォーマットのカスタム",
"description": "<kbd></kbd>またはツールバーから挿入される日付と時刻のフォーマットをカスタマイズする。 利用可能なトークンについては <a href=\"https://day.js.org/docs/en/display/format\" target=\"_blank\" rel=\"noopener noreferrer\">Day.js ドキュメント</a> を参照してください。",
"format_string": "文字列形式:",
"formatted_time": "日付/時刻形式:"
},
"backup": {
"automatic_backup": "自動バックアップ",
"automatic_backup_description": "Triliumは自動的にデータベースをバックアップすることができます:",
"enable_daily_backup": "毎日バックアップ",
"enable_weekly_backup": "毎週バックアップ",
"enable_monthly_backup": "毎月バックアップ",
"backup_recommendation": "バックアップはオンが推奨されますが、大規模なデータベースや低速なストレージデバイスの場合、アプリの起動を遅くする可能性があります。",
"backup_now": "今すぐバックアップ",
"backup_database_now": "今すぐデータベースをバックアップ",
"existing_backups": "既存のバックアップ",
"date-and-time": "日時",
"path": "パス",
"database_backed_up_to": "データベースは{{backupFilePath}}にバックアップされました",
"no_backup_yet": "バックアップがありません"
},
"password": {
"wiki": "wiki",
"heading": "パスワード",
"alert_message": "新しいパスワードは大切に保管してください。パスワードはウェブインターフェースへのログインや、保護されたノートの暗号化に使用されます。パスワードを忘れると、保護されたノートはすべて永久に失われます。",
"reset_link": "リセットするにはここをクリック。",
"old_password": "旧パスワード",
"new_password": "新パスワード",
"new_password_confirmation": "新パスワードの確認",
"change_password": "パスワードの変更",
"change_password_heading": "パスワードの変更",
"protected_session_timeout": "保護されたセッションのタイムアウト",
"protected_session_timeout_description": "保護されたセッションのタイムアウトは、保護されたセッションがブラウザのメモリから消去される時間です。これは、保護されたノートとの最後のやり取りから測定されます。参照",
"for_more_info": "詳細はこちら。",
"protected_session_timeout_label": "保護されたセッションのタイムアウト:",
"reset_confirmation": "パスワードをリセットすると、保護されているすべてのノートにアクセスできなくなります。本当にパスワードをリセットしますか?",
"reset_success_message": "パスワードがリセットされました。新しいパスワードを設定してください",
"set_password_heading": "パスワードの設定",
"set_password": "パスワードの設定",
"password_mismatch": "新しいパスワードが同じではありません。",
"password_changed_success": "パスワードが変更されました。OKを押すとTriliumがリロードされます。"
},
"spellcheck": {
"title": "スペルチェック",
"description": "これらのオプションはデスクトップビルドにのみ適用され、ブラウザはそれぞれのネイティブスペルチェックを使用します。",
"enable": "スペルチェックを有効",
"language_code_label": "言語コード",
"language_code_placeholder": "例えば \"en-US\", \"de-AT\"",
"multiple_languages_info": "複数の言語はカンマで区切ることができます。例: \"en-US, de-DE, cs\"。 ",
"available_language_codes_label": "使用可能な言語コード:",
"restart-required": "スペルチェックオプションの変更は、アプリケーションの再起動後に有効になります。"
},
"sync_2": {
"config_title": "同期設定",
"server_address": "サーバーインスタンスのアドレス",
"timeout": "同期タイムアウト",
"timeout_unit": "ミリ秒",
"proxy_label": "同期プロキシサーバー(任意)",
"note": "注",
"note_description": "プロキシ設定を空白のままにすると、システムプロキシが使用されます(デスクトップ/electronビルドにのみ適用されます。",
"special_value_description": "もう一つの特別な値は <code>noproxy</code> で、これはシステムプロキシさえも無視して、 <code>NODE_TLS_REJECT_UNAUTHORIZED</code> を尊重するように強制します。",
"save": "保存",
"help": "ヘルプ",
"test_title": "同期のテスト",
"test_description": "これは同期サーバとの接続とハンドシェイクをテストします。同期サーバーが初期化されていない場合、ローカルドキュメントと同期するように設定します。",
"test_button": "同期試行",
"handshake_failed": "同期サーバーのハンドシェイクに失敗しました。エラー: {{message}}"
},
"api_log": {
"close": "閉じる"
},
"info": {
"closeButton": "閉じる"
},
"protected_session_password": {
"close_label": "閉じる"
},
"modal": {
"close": "閉じる"
},
"bookmark_switch": {
"bookmark": "ブックマーク",
"bookmark_this_note": "このノートを左サイドパネルにブックマークする",
"remove_bookmark": "ブックマークを削除"
},
"attribute_detail": {
"delete": "削除"
},
"link_context_menu": {
"open_note_in_popup": "クイックエディット"
},
"note_tooltip": {
"quick-edit": "クイックエディット"
},
"protect_note": {
"toggle-on": "ノートを保護",
"toggle-off": "ノートの保護を解除",
"toggle-on-hint": "ノートは保護されていません。クリックして保護してください",
"toggle-off-hint": "ノートは保護されています。クリックして保護を解除してください"
},
"shared_switch": {
"shared": "共有",
"toggle-on-title": "ノートを共有",
"toggle-off-title": "ノートの共有を解除",
"shared-branch": "このノートは共有ノートとしてのみ存在し、共有を解除すると削除されます。続行してこのノートを削除しますか?",
"inherited": "このノートは、親から継承された共有方法のため、ここでは共有解除できません。"
},
"template_switch": {
"template": "テンプレート",
"toggle-on-hint": "ノートをテンプレート化する"
},
"open-help-page": "ヘルプページを開く",
"shared_info": {
"shared_publicly": "このノートは一般公開されています",
"shared_locally": "このノートはローカルで共有されています",
"help_link": "ヘルプについては、<a href=\"https://triliumnext.github.io/Docs/Wiki/sharing.html\">wiki</a>をご覧ください。"
},
"highlights_list_2": {
"title": "ハイライトリスト",
"options": "オプション"
},
"quick-search": {
"placeholder": "クイックサーチ",
"searching": "検索中...",
"no-results": "結果は見つかりませんでした",
"more-results": "... および {{number}} 件の他の結果。",
"show-in-full-search": "検索結果をすべて表示"
},
"note_tree": {
"collapse-title": "ノートツリーを折りたたむ",
"scroll-active-title": "アクティブノートまでスクロール",
"tree-settings-title": "ツリーの設定",
"hide-archived-notes": "アーカイブノートを隠す",
"automatically-collapse-notes": "ノートを自動的に折りたたむ",
"automatically-collapse-notes-title": "一定期間使用されないと、ツリーを整理するためにノートは折りたたまれます。",
"save-changes": "変更を保存して適用"
}
}

View File

@@ -249,7 +249,7 @@
},
"prompt": {
"title": "Prompt",
"ok": "OK <kbd>enter</kbd>",
"ok": "OK",
"defaultTitle": "Prompt"
},
"protected_session_password": {
@@ -257,7 +257,7 @@
"help_title": "Ajuda sobre notas protegidas",
"close_label": "Fechar",
"form_label": "Para prosseguir com a ação solicitada, você precisa iniciar uma sessão protegida digitando a senha:",
"start_button": "Iniciar sessão protegida <kbd>enter</kbd>"
"start_button": "Iniciar sessão protegida"
},
"recent_changes": {
"title": "Alterações recentes",
@@ -306,12 +306,12 @@
"sort_with_respect_to_different_character_sorting": "classificar de acordo com diferentes regras de ordenação de caracteres e colação em diferentes idiomas ou regiões.",
"natural_sort_language": "Linguagem da ordenação natural",
"the_language_code_for_natural_sort": "O código do idioma para ordenação natural, por exemplo, \"zh-CN\" para chinês.",
"sort": "Ordenar <kbd>enter</kbd>"
"sort": "Ordenar"
},
"upload_attachments": {
"upload_attachments_to_note": "Enviar anexos para a nota",
"choose_files": "Escolher arquivos",
"files_will_be_uploaded": "Os arquivos serão enviados como anexos para",
"files_will_be_uploaded": "Os arquivos serão enviados como anexos para {{noteTitle}}",
"options": "Opções",
"shrink_images": "Reduzir imagens",
"upload": "Enviar",

View File

@@ -29,7 +29,7 @@
"note": "Заметка",
"link_title": "Заголовок ссылки",
"link_title_arbitrary": "заголовок ссылки может быть изменен произвольно",
"button_add_link": "Добавить ссылку <kbd>enter</kbd>",
"button_add_link": "Добавить ссылку",
"help_on_links": "Помощь по ссылкам",
"search_note": "поиск заметки по ее названию",
"link_title_mirrors": "название ссылки отражает текущий заголовок заметки"
@@ -68,9 +68,9 @@
"erase_notes_description": "Обычное (мягкое) удаление только отмечает заметки как удалённые, и их можно восстановить (в диалоговом окне последних изменений) в течение определённого времени. Если выбрать этот параметр, заметки будут удалены немедленно, и восстановить их будет невозможно.",
"delete_all_clones_description": "Удалить также все клоны (можно отменить в последних изменениях)",
"erase_notes_warning": "Удалить заметки без возможности восстановления, включая все клоны. Это приведёт к принудительной перезагрузке приложения.",
"notes_to_be_deleted": "Следующие заметки будут удалены ({{- noteCount}})",
"notes_to_be_deleted": "Следующие заметки будут удалены ({{noteCount}})",
"no_note_to_delete": "Заметка не будет удалена (только клоны).",
"broken_relations_to_be_deleted": "Следующие связи будут разорваны и удалены ({{- relationCount}})",
"broken_relations_to_be_deleted": "Следующие связи будут разорваны и удалены ({{relationCount}})",
"cancel": "Отмена",
"ok": "ОК",
"deleted_relation_text": "Примечание {{- note}} (подлежит удалению) ссылается на отношение {{- relation}}, происходящее из {{- source}}.",
@@ -182,7 +182,7 @@
"no_path_to_clone_to": "Не задан путь для клонирования.",
"note_cloned": "Заметка \"{{clonedTitle}}\" клонирована в \"{{targetTitle}}\"",
"help_on_links": "Помощь по ссылкам",
"clone_to_selected_note": "Клонировать в выбранную заметку <kbd>enter</kbd>"
"clone_to_selected_note": "Клонировать в выбранную заметку"
},
"export": {
"export_note_title": "Экспортировать заметку",
@@ -204,13 +204,13 @@
},
"help": {
"noteNavigation": "Навигация по заметке",
"goUpDown": "<kbd>UP</kbd>, <kbd>DOWN</kbd> - вверх/вниз в списке заметок",
"collapseExpand": "<kbd> LEFT</kbd>, <kbd>RIGHT</kbd> - свернуть/развернуть узел",
"goUpDown": "вверх/вниз в списке заметок",
"collapseExpand": "свернуть/развернуть узел",
"notSet": "не установлено",
"goBackForwards": "назад / вперед в истории",
"showJumpToNoteDialog": "показать <a class=\"external\" href=\"https://triliumnext.github.io/Docs/Wiki/note-navigation.html#jump-to-note\">окно \"Перейти к\"</a>",
"scrollToActiveNote": "прокрутка к активной заметке",
"jumpToParentNote": "<kbd>Backspace</kbd> - переход к родительской заметке",
"jumpToParentNote": "переход к родительской заметке",
"collapseWholeTree": "свернуть все дерево заметок",
"collapseSubTree": "свернуть поддерево",
"openEmptyTab": "открыть пустую вкладку",
@@ -233,8 +233,8 @@
"showSQLConsole": "показать консоль SQL",
"inPageSearch": "поиск на странице",
"editingNotes": "Редактирование заметок",
"newTabNoteLink": "<kbd>Ctrl+щелчок</kbd> - (или <kbd>щелчок средней кнопкой мыши</kbd>) по ссылке на заметку открывает заметку в новой вкладке",
"newTabWithActivationNoteLink": "<kbd>Ctrl+Shift+щелчок</kbd> - (или <kbd>Shift+щелчок средней кнопкой мыши</kbd>) по ссылке на заметку открывает и активирует заметку в новой вкладке",
"newTabNoteLink": "по ссылке на заметку открывает заметку в новой вкладке",
"newTabWithActivationNoteLink": "по ссылке на заметку открывает и активирует заметку в новой вкладке",
"onlyInDesktop": "Только на десктопе (сборка Electron)",
"createNoteAfter": "создать новую заметку после активной заметки",
"createNoteInto": "создать новую дочернюю заметку к активной заметке",
@@ -245,9 +245,9 @@
"tabShortcuts": "Сочетания клавиш для управления вкладками",
"editBranchPrefix": "изменить <a class=\"external\" href=\"https://triliumnext.github.io/Docs/Wiki/tree-concepts.html#prefix\">префикс</a> клона активной заметки",
"multiSelectNote": "множественный выбор заметки выше/ниже",
"selectNote": "<kbd>Shift+click</kbd> - выбрать заметку",
"selectNote": "выбрать заметку",
"copyNotes": "скопировать активную заметку (или выделение) в буфер обмер (используется для <a class=\"external\" href=\"https://triliumnext.github.io/Docs/Wiki/cloning-notes.html#cloning-notes\">клонирования</a>)",
"createEditLink": "<kbd>Ctrl+K</kbd> - создать/редактировать внешнюю ссылку",
"createEditLink": "создать/редактировать внешнюю ссылку",
"headings": "<code>##</code>, <code>###</code>, <code>####</code> и т. д., за которыми следует пробел для заголовков.",
"bulletList": "<code>*</code> или <code>-</code> с последующим пробелом для маркированного списка",
"numberedList": "<code>1.</code> или <code>1)</code> с последующим пробелом для нумерованного списка",
@@ -287,13 +287,13 @@
"markdown_import": {
"dialog_title": "Импорт Markdown",
"modal_body_text": "Из-за особенностей браузера песочница не позволяет напрямую читать буфер обмена из JavaScript. Вставьте разметку Markdown для импорта в текстовую область ниже и нажмите кнопку «Импорт»",
"import_button": "Импорт Ctrl+Enter",
"import_button": "Импорт",
"import_success": "Содержимое Markdown импортировано в документ."
},
"note_type_chooser": {
"modal_title": "Выберите тип заметки",
"modal_body": "Выберите тип / шаблон новой заметки:",
"templates": "Шаблоны:",
"templates": "Шаблоны",
"change_path_prompt": "Изменить место создания новой заметки:",
"search_placeholder": "поиск пути по имени (по умолчанию, если пусто)"
},
@@ -305,7 +305,7 @@
"protected_session_password": {
"modal_title": "Защищенный сеанс",
"form_label": "Чтобы продолжить, вам необходимо начать защищенный сеанс, введя пароль:",
"start_button": "Начать защищенный сеанс <kbd>enter</kbd>",
"start_button": "Начать защищенный сеанс",
"close_label": "Закрыть",
"help_title": "Помощь по защищенным заметкам"
},
@@ -352,7 +352,7 @@
"descending": "по убыванию",
"sort_with_respect_to_different_character_sorting": "Сортировка с учетом различных правил сортировки и сопоставления символов в разных языках и регионах.",
"folders": "Папки",
"sort": "Сортировать <kbd>enter</kbd>",
"sort": "Сортировать",
"natural_sort": "Естественная сортировка",
"natural_sort_language": "Язык естественной сортировки",
"sort_folders_at_top": "сортировать папки вверху",
@@ -641,21 +641,21 @@
},
"jump_to_note": {
"search_placeholder": "Найдите заметку по ее названию или введите > для команд...",
"search_button": "Поиск по всему тексту <kbd>Ctrl+Enter</kbd>"
"search_button": "Поиск по всему тексту"
},
"move_to": {
"target_parent_note": "Целевая родительская заметка",
"notes_to_move": "Заметки к переносу",
"dialog_title": "Переместить заметки в ...",
"search_placeholder": "поиск заметки по ее названию",
"move_button": "Переместить к выбранной заметке <kbd>enter</kbd>",
"move_button": "Переместить к выбранной заметке",
"error_no_path": "Отсутствует путь для перемещения.",
"move_success_message": "Выбранные заметки были перемещены в "
},
"prompt": {
"title": "Запрос",
"defaultTitle": "Запрос",
"ok": "OK <kbd>enter</kbd>"
"ok": "OK"
},
"move_note": {
"to": "в",
@@ -1042,7 +1042,7 @@
"include_note": {
"dialog_title": "Вставить заметку",
"label_note": "Заметка",
"button_include": "Вставить заметку <kbd>enter</kbd>",
"button_include": "Вставить заметку",
"placeholder_search": "поиск заметки по ее названию",
"box_size_small": "небольшой (~ 10 строк)",
"box_size_medium": "средний (~ 30 строк)",

File diff suppressed because it is too large Load Diff

View File

@@ -9,9 +9,6 @@ import contentRenderer from "../services/content_renderer.js";
import toastService from "../services/toast.js";
import type FAttachment from "../entities/fattachment.js";
import type { EventData } from "../components/app_context.js";
import appContext from "../components/app_context.js";
import mediaViewer from "../services/media_viewer.js";
import type { MediaItem } from "../services/media_viewer.js";
const TPL = /*html*/`
<div class="attachment-detail-widget">
@@ -68,12 +65,6 @@ const TPL = /*html*/`
.attachment-content-wrapper img {
margin: 10px;
cursor: zoom-in;
transition: opacity 0.2s;
}
.attachment-content-wrapper img:hover {
opacity: 0.9;
}
.attachment-detail-wrapper.list-view .attachment-content-wrapper img, .attachment-detail-wrapper.list-view .attachment-content-wrapper video {
@@ -87,24 +78,6 @@ const TPL = /*html*/`
object-fit: contain;
}
.attachment-lightbox-hint {
position: absolute;
top: 10px;
right: 10px;
background: rgba(0, 0, 0, 0.7);
color: white;
padding: 5px 10px;
border-radius: 4px;
font-size: 12px;
opacity: 0;
transition: opacity 0.3s;
pointer-events: none;
}
.attachment-content-wrapper:hover .attachment-lightbox-hint {
opacity: 1;
}
.attachment-detail-wrapper.scheduled-for-deletion .attachment-content-wrapper img {
filter: contrast(10%);
}
@@ -115,9 +88,7 @@ const TPL = /*html*/`
<div class="attachment-actions-container"></div>
<h4 class="attachment-title"></h4>
<div class="attachment-details"></div>
<button class="btn btn-sm back-to-note-btn" style="margin-left: auto;" title="Back to Note">
<span class="bx bx-arrow-back"></span> Back to Note
</button>
<div style="flex: 1 1;"></div>
</div>
<div class="attachment-deletion-warning alert alert-info" style="margin-top: 15px;"></div>
@@ -154,14 +125,6 @@ export default class AttachmentDetailWidget extends BasicWidget {
this.$wrapper = this.$widget.find(".attachment-detail-wrapper");
this.$wrapper.addClass(this.isFullDetail ? "full-detail" : "list-view");
// Setup back to note button (only show in full detail mode)
if (this.isFullDetail) {
const $backBtn = this.$wrapper.find('.back-to-note-btn');
$backBtn.on('click', () => this.handleBackToNote());
} else {
this.$wrapper.find('.back-to-note-btn').hide();
}
if (!this.isFullDetail) {
const $link = await linkService.createLink(this.attachment.ownerId, {
title: this.attachment.title,
@@ -207,92 +170,7 @@ export default class AttachmentDetailWidget extends BasicWidget {
this.$wrapper.find(".attachment-actions-container").append(this.attachmentActionsWidget.render());
const { $renderedContent } = await contentRenderer.getRenderedContent(this.attachment, { imageHasZoom: this.isFullDetail });
const $contentWrapper = this.$wrapper.find(".attachment-content-wrapper");
$contentWrapper.append($renderedContent);
// Add PhotoSwipe integration for image attachments
if (this.attachment.role === 'image') {
this.setupPhotoSwipeIntegration($contentWrapper);
}
}
setupPhotoSwipeIntegration($contentWrapper: JQuery<HTMLElement>) {
// Add lightbox hint
const $hint = $('<div class="attachment-lightbox-hint">Click to view in lightbox</div>');
$contentWrapper.css('position', 'relative').append($hint);
// Find the image element
const $img = $contentWrapper.find('img');
if (!$img.length) return;
// Setup click handler for lightbox with namespace for proper cleanup
$img.off('click.photoswipe').on('click.photoswipe', (e) => {
e.preventDefault();
e.stopPropagation();
const item: MediaItem = {
src: $img.attr('src') || '',
alt: this.attachment.title,
title: this.attachment.title,
noteId: this.attachment.ownerId,
element: $img[0] as HTMLElement
};
// Try to get actual dimensions
const imgElement = $img[0] as HTMLImageElement;
if (imgElement.naturalWidth && imgElement.naturalHeight) {
item.width = imgElement.naturalWidth;
item.height = imgElement.naturalHeight;
}
mediaViewer.openSingle(item, {
bgOpacity: 0.95,
showHideOpacity: true,
pinchToClose: true,
closeOnScroll: false,
closeOnVerticalDrag: true,
wheelToZoom: true,
getThumbBoundsFn: () => {
// Get position for zoom animation
const rect = imgElement.getBoundingClientRect();
return {
x: rect.left,
y: rect.top,
w: rect.width
};
}
}, {
onOpen: () => {
console.log('Attachment image opened in lightbox');
},
onClose: () => {
// Check if we're in attachment detail view and reset viewScope if needed
const activeContext = appContext.tabManager.getActiveContext();
if (activeContext?.viewScope?.viewMode === 'attachments' &&
activeContext?.viewScope?.attachmentId === this.attachment.attachmentId) {
// Reset to normal note view when closing lightbox from attachment detail
activeContext.setNote(this.attachment.ownerId, {
viewScope: { viewMode: 'default' }
});
}
// Restore focus to the image
$img.focus();
}
});
});
// Add keyboard support
$img.attr('tabindex', '0')
.attr('role', 'button')
.attr('aria-label', 'Click to view in lightbox');
// Use namespaced event for proper cleanup
$img.off('keydown.photoswipe').on('keydown.photoswipe', (e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
$img.trigger('click');
}
});
this.$wrapper.find(".attachment-content-wrapper").append($renderedContent);
}
async copyAttachmentLinkToClipboard() {
@@ -326,43 +204,4 @@ export default class AttachmentDetailWidget extends BasicWidget {
}
}
}
async handleBackToNote() {
try {
const activeContext = appContext.tabManager.getActiveContext();
if (!activeContext) {
console.warn('No active context available for navigation');
return;
}
if (!this.attachment.ownerId) {
console.error('Cannot navigate back: no owner ID available');
return;
}
await activeContext.setNote(this.attachment.ownerId, {
viewScope: { viewMode: 'default' }
});
} catch (error) {
console.error('Failed to navigate back to note:', error);
toastService.showError('Failed to navigate back to note');
}
}
cleanup() {
// Remove all event handlers before cleanup
const $contentWrapper = this.$wrapper?.find('.attachment-content-wrapper');
if ($contentWrapper?.length) {
const $img = $contentWrapper.find('img');
if ($img.length) {
// Remove namespaced event handlers
$img.off('.photoswipe');
}
}
// Remove back button handler
this.$wrapper?.find('.back-to-note-btn').off('click');
super.cleanup();
}
}

View File

@@ -1,549 +0,0 @@
/**
* Embedded Image Gallery Widget
* Handles image galleries within text notes and other content types
*/
import BasicWidget from "./basic_widget.js";
import galleryManager from "../services/gallery_manager.js";
import mediaViewer from "../services/media_viewer.js";
import type { GalleryItem, GalleryConfig } from "../services/gallery_manager.js";
import type { MediaViewerCallbacks } from "../services/media_viewer.js";
import utils from "../services/utils.js";
const TPL = /*html*/`
<style>
.embedded-gallery-trigger {
position: relative;
display: inline-block;
cursor: pointer;
user-select: none;
}
.embedded-gallery-trigger::after {
content: '';
position: absolute;
top: 8px;
right: 8px;
width: 32px;
height: 32px;
background: rgba(0, 0, 0, 0.6);
border-radius: 4px;
display: flex;
align-items: center;
justify-content: center;
opacity: 0;
transition: opacity 0.2s;
pointer-events: none;
}
.embedded-gallery-trigger:hover::after {
opacity: 1;
}
.embedded-gallery-trigger.has-gallery::after {
content: '\\f0660'; /* Gallery icon from boxicons font */
font-family: 'boxicons';
color: white;
font-size: 20px;
display: flex;
align-items: center;
justify-content: center;
line-height: 32px;
text-align: center;
}
.gallery-indicator {
position: absolute;
top: 8px;
left: 8px;
background: rgba(0, 0, 0, 0.7);
color: white;
padding: 4px 8px;
border-radius: 4px;
font-size: 12px;
font-weight: bold;
pointer-events: none;
z-index: 1;
}
.image-grid-view {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
gap: 16px;
padding: 16px;
}
.image-grid-item {
position: relative;
aspect-ratio: 1;
overflow: hidden;
border-radius: 8px;
cursor: pointer;
transition: transform 0.2s, box-shadow 0.2s;
background: var(--accented-background-color);
}
.image-grid-item:hover {
transform: scale(1.05);
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.2);
}
.image-grid-item img {
width: 100%;
height: 100%;
object-fit: cover;
}
.image-grid-caption {
position: absolute;
bottom: 0;
left: 0;
right: 0;
background: linear-gradient(to top, rgba(0, 0, 0, 0.8), transparent);
color: white;
padding: 8px;
font-size: 12px;
opacity: 0;
transition: opacity 0.2s;
}
.image-grid-item:hover .image-grid-caption {
opacity: 1;
}
/* Mobile optimizations */
@media (max-width: 768px) {
.image-grid-view {
grid-template-columns: repeat(auto-fill, minmax(120px, 1fr));
gap: 8px;
padding: 8px;
}
.gallery-indicator {
font-size: 10px;
padding: 2px 4px;
}
}
</style>
`;
interface ImageElement {
element: HTMLImageElement;
src: string;
alt?: string;
title?: string;
caption?: string;
noteId?: string;
index: number;
}
export default class EmbeddedImageGallery extends BasicWidget {
private galleryItems: GalleryItem[] = [];
private imageElements: Map<HTMLElement, ImageElement> = new Map();
private observer?: MutationObserver;
private processingQueue: Set<HTMLElement> = new Set();
doRender(): JQuery<HTMLElement> {
this.$widget = $(TPL);
this.setupMutationObserver();
return this.$widget;
}
/**
* Initialize gallery for a container element
*/
async initializeGallery(
container: HTMLElement | JQuery<HTMLElement>,
options?: {
selector?: string;
autoEnhance?: boolean;
gridView?: boolean;
galleryConfig?: GalleryConfig;
}
): Promise<void> {
const $container = $(container);
const selector = options?.selector || 'img';
const autoEnhance = options?.autoEnhance !== false;
const gridView = options?.gridView || false;
// Find all images in the container
const images = $container.find(selector).toArray() as HTMLImageElement[];
if (images.length === 0) {
return;
}
// Create gallery items
this.galleryItems = await this.createGalleryItems(images, $container);
if (gridView) {
// Create grid view
this.createGridView($container, this.galleryItems);
} else if (autoEnhance) {
// Enhance individual images
this.enhanceImages(images);
}
}
/**
* Create gallery items from image elements
*/
private async createGalleryItems(
images: HTMLImageElement[],
$container: JQuery<HTMLElement>
): Promise<GalleryItem[]> {
const items: GalleryItem[] = [];
for (let i = 0; i < images.length; i++) {
const img = images[i];
// Skip already processed images
if (img.dataset.galleryProcessed === 'true') {
continue;
}
const item: GalleryItem = {
src: img.src,
alt: img.alt || `Image ${i + 1}`,
title: img.title || img.alt,
element: img,
index: i,
width: img.naturalWidth || undefined,
height: img.naturalHeight || undefined
};
// Extract caption from figure element
const $img = $(img);
const $figure = $img.closest('figure');
if ($figure.length) {
const $caption = $figure.find('figcaption');
if ($caption.length) {
item.caption = $caption.text();
}
}
// Check for note ID in data attributes or URL
item.noteId = this.extractNoteId(img);
// Get dimensions if not available
if (!item.width || !item.height) {
try {
const dimensions = await mediaViewer.getImageDimensions(img.src);
item.width = dimensions.width;
item.height = dimensions.height;
} catch (error) {
console.warn('Failed to get image dimensions:', error);
}
}
items.push(item);
// Store image element data
this.imageElements.set(img, {
element: img,
src: img.src,
alt: item.alt,
title: item.title,
caption: item.caption,
noteId: item.noteId,
index: i
});
// Mark as processed
img.dataset.galleryProcessed = 'true';
}
return items;
}
/**
* Enhance individual images with gallery functionality
*/
private enhanceImages(images: HTMLImageElement[]): void {
images.forEach((img, index) => {
const $img = $(img);
// Wrap image in a trigger container if not already wrapped
if (!$img.parent().hasClass('embedded-gallery-trigger')) {
$img.wrap('<span class="embedded-gallery-trigger"></span>');
}
const $trigger = $img.parent();
// Add gallery indicator if multiple images
if (this.galleryItems.length > 1) {
$trigger.addClass('has-gallery');
// Add count indicator
if (!$trigger.find('.gallery-indicator').length) {
$trigger.prepend(`
<span class="gallery-indicator" aria-label="Image ${index + 1} of ${this.galleryItems.length}">
${index + 1}/${this.galleryItems.length}
</span>
`);
}
}
// Remove any existing click handlers
$img.off('click.gallery');
// Add click handler to open gallery
$img.on('click.gallery', (e) => {
e.preventDefault();
e.stopPropagation();
this.openGallery(index);
});
// Add keyboard support
$img.attr('tabindex', '0');
$img.attr('role', 'button');
$img.attr('aria-label', `${img.alt || 'Image'} - Click to open in gallery`);
$img.on('keydown.gallery', (e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
this.openGallery(index);
}
});
});
}
/**
* Create grid view of images
*/
private createGridView($container: JQuery<HTMLElement>, items: GalleryItem[]): void {
const $grid = $('<div class="image-grid-view"></div>');
items.forEach((item, index) => {
const $gridItem = $(`
<div class="image-grid-item" data-index="${index}" tabindex="0" role="button">
<img src="${item.src}" alt="${item.alt}" loading="lazy" />
${item.caption ? `<div class="image-grid-caption">${item.caption}</div>` : ''}
</div>
`);
$gridItem.on('click', () => this.openGallery(index));
$gridItem.on('keydown', (e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
this.openGallery(index);
}
});
$grid.append($gridItem);
});
// Replace container content with grid
$container.empty().append($grid);
}
/**
* Open gallery at specified index
*/
private openGallery(startIndex: number = 0): void {
if (this.galleryItems.length === 0) {
return;
}
const config: GalleryConfig = {
showThumbnails: this.galleryItems.length > 1,
thumbnailHeight: 80,
autoPlay: false,
slideInterval: 4000,
showCounter: this.galleryItems.length > 1,
enableKeyboardNav: true,
enableSwipeGestures: true,
preloadCount: 2,
loop: true
};
const callbacks: MediaViewerCallbacks = {
onOpen: () => {
console.log('Embedded gallery opened');
this.trigger('galleryOpened', { items: this.galleryItems, startIndex });
},
onClose: () => {
console.log('Embedded gallery closed');
this.trigger('galleryClosed');
// Restore focus to the trigger element
const currentItem = this.galleryItems[galleryManager.getGalleryState()?.currentIndex || startIndex];
if (currentItem?.element) {
(currentItem.element as HTMLElement).focus();
}
},
onChange: (index) => {
console.log('Gallery slide changed to:', index);
this.trigger('gallerySlideChanged', { index, item: this.galleryItems[index] });
},
onImageLoad: (index, item) => {
console.log('Gallery image loaded:', item.title);
},
onImageError: (index, item, error) => {
console.error('Failed to load gallery image:', error);
}
};
if (this.galleryItems.length === 1) {
// Open single image
mediaViewer.openSingle(this.galleryItems[0], {
bgOpacity: 0.95,
showHideOpacity: true,
wheelToZoom: true,
pinchToClose: true
}, callbacks);
} else {
// Open gallery
galleryManager.openGallery(this.galleryItems, startIndex, config, callbacks);
}
}
/**
* Extract note ID from image element
*/
private extractNoteId(img: HTMLImageElement): string | undefined {
// Check data attribute
if (img.dataset.noteId) {
return img.dataset.noteId;
}
// Try to extract from URL
const match = img.src.match(/\/api\/images\/([a-zA-Z0-9_]+)/);
if (match) {
return match[1];
}
return undefined;
}
/**
* Setup mutation observer to detect dynamically added images
*/
private setupMutationObserver(): void {
this.observer = new MutationObserver((mutations) => {
const imagesToProcess: HTMLImageElement[] = [];
mutations.forEach((mutation) => {
mutation.addedNodes.forEach((node) => {
if (node.nodeType === Node.ELEMENT_NODE) {
const element = node as HTMLElement;
// Check if it's an image
if (element.tagName === 'IMG') {
imagesToProcess.push(element as HTMLImageElement);
}
// Check for images within the added element
const images = element.querySelectorAll('img');
images.forEach(img => imagesToProcess.push(img as HTMLImageElement));
}
});
});
if (imagesToProcess.length > 0) {
this.processNewImages(imagesToProcess);
}
});
}
/**
* Process newly added images
*/
private async processNewImages(images: HTMLImageElement[]): Promise<void> {
// Filter out already processed images
const newImages = images.filter(img =>
img.dataset.galleryProcessed !== 'true' &&
!this.processingQueue.has(img)
);
if (newImages.length === 0) {
return;
}
// Add to processing queue
newImages.forEach(img => this.processingQueue.add(img));
try {
// Create gallery items for new images
const newItems = await this.createGalleryItems(newImages, $(document.body));
// Add to existing gallery
this.galleryItems.push(...newItems);
// Enhance the new images
this.enhanceImages(newImages);
} finally {
// Remove from processing queue
newImages.forEach(img => this.processingQueue.delete(img));
}
}
/**
* Start observing a container for new images
*/
observeContainer(container: HTMLElement): void {
if (!this.observer) {
this.setupMutationObserver();
}
this.observer?.observe(container, {
childList: true,
subtree: true
});
}
/**
* Stop observing
*/
stopObserving(): void {
this.observer?.disconnect();
}
/**
* Refresh gallery items
*/
async refresh(): Promise<void> {
// Clear existing items
this.galleryItems = [];
this.imageElements.clear();
// Mark all images as unprocessed
$('[data-gallery-processed="true"]').removeAttr('data-gallery-processed');
// Re-initialize if there's a container
const $container = this.$widget?.parent();
if ($container?.length) {
await this.initializeGallery($container);
}
}
/**
* Get current gallery items
*/
getGalleryItems(): GalleryItem[] {
return this.galleryItems;
}
/**
* Cleanup
*/
cleanup(): void {
// Stop observing
this.stopObserving();
// Close gallery if open
if (galleryManager.isGalleryOpen()) {
galleryManager.closeGallery();
}
// Remove event handlers
$('[data-gallery-processed="true"]').off('.gallery');
// Clear data
this.galleryItems = [];
this.imageElements.clear();
this.processingQueue.clear();
super.cleanup();
}
}

View File

@@ -1,573 +0,0 @@
import { TypedBasicWidget } from "./basic_widget.js";
import Component from "../components/component.js";
import mediaViewerService from "../services/media_viewer.js";
import type { MediaItem, MediaViewerConfig, MediaViewerCallbacks } from "../services/media_viewer.js";
import type FNote from "../entities/fnote.js";
import type { EventData } from "../components/app_context.js";
import froca from "../services/froca.js";
import utils from "../services/utils.js";
import server from "../services/server.js";
import toastService from "../services/toast.js";
/**
* MediaViewerWidget provides a modern lightbox experience for viewing images
* and other media in Trilium Notes using PhotoSwipe 5.
*
* This widget can be used in two modes:
* 1. As a standalone viewer for a single note's media
* 2. As a gallery viewer for multiple media items
*/
export class MediaViewerWidget extends TypedBasicWidget<Component> {
private currentNoteId: string | null = null;
private galleryItems: MediaItem[] = [];
private isGalleryMode: boolean = false;
private clickHandlers: Map<HTMLElement, () => void> = new Map();
private boundKeyboardHandler: ((event: KeyboardEvent) => void) | null = null;
constructor() {
super();
this.setupGlobalHandlers();
}
/**
* Setup global event handlers for media viewing
*/
private setupGlobalHandlers(): void {
// Store bound handler for proper cleanup
this.boundKeyboardHandler = this.handleKeyboard.bind(this);
document.addEventListener('keydown', this.boundKeyboardHandler);
// Cleanup will be called by parent class
}
/**
* Handle keyboard shortcuts with error boundary
*/
private handleKeyboard(event: KeyboardEvent): void {
try {
// Only handle if viewer is open
if (!mediaViewerService.isOpen()) {
return;
}
switch (event.key) {
case 'ArrowLeft':
mediaViewerService.prev();
event.preventDefault();
break;
case 'ArrowRight':
mediaViewerService.next();
event.preventDefault();
break;
case 'Escape':
mediaViewerService.close();
event.preventDefault();
break;
}
} catch (error) {
console.error('Error handling keyboard event:', error);
}
}
/**
* Open viewer for a single image note with comprehensive error handling
*/
async openImageNote(noteId: string, config?: Partial<MediaViewerConfig>): Promise<void> {
try {
const note = await froca.getNote(noteId);
if (!note || note.type !== 'image') {
toastService.showError('Note is not an image');
return;
}
const item: MediaItem = {
src: utils.createImageSrcUrl(note),
alt: note.title || `Image ${noteId}`,
title: note.title || `Image ${noteId}`,
noteId: noteId
};
// Try to get image dimensions from attributes
const widthAttr = note.getAttribute('label', 'imageWidth');
const heightAttr = note.getAttribute('label', 'imageHeight');
if (widthAttr && heightAttr) {
const width = parseInt(widthAttr.value);
const height = parseInt(heightAttr.value);
if (!isNaN(width) && !isNaN(height) && width > 0 && height > 0) {
item.width = width;
item.height = height;
}
}
// Get dimensions dynamically if not available
if (!item.width || !item.height) {
try {
const dimensions = await mediaViewerService.getImageDimensions(item.src);
item.width = dimensions.width;
item.height = dimensions.height;
} catch (error) {
console.warn('Failed to get image dimensions, using defaults:', error);
// Use default dimensions as fallback
item.width = 800;
item.height = 600;
}
}
const callbacks: MediaViewerCallbacks = {
onOpen: () => this.onViewerOpen(noteId),
onClose: () => this.onViewerClose(noteId),
onImageError: (index, errorItem, error) => this.onImageError(errorItem, error)
};
mediaViewerService.openSingle(item, config, callbacks);
this.currentNoteId = noteId;
} catch (error) {
console.error('Failed to open image note:', error);
const errorMessage = error instanceof Error ? error.message : 'Failed to open image';
toastService.showError(errorMessage);
}
}
/**
* Open viewer for multiple images (gallery mode) with isolated error handling
*/
async openGallery(noteIds: string[], startIndex: number = 0, config?: Partial<MediaViewerConfig>): Promise<void> {
try {
const items: MediaItem[] = [];
const errors: Array<{ noteId: string; error: unknown }> = [];
// Process each note with isolated error handling
await Promise.all(noteIds.map(async (noteId) => {
try {
const note = await froca.getNote(noteId);
if (!note || note.type !== 'image') {
return; // Skip non-image notes silently
}
const item: MediaItem = {
src: utils.createImageSrcUrl(note),
alt: note.title || `Image ${noteId}`,
title: note.title || `Image ${noteId}`,
noteId: noteId
};
// Try to get dimensions
const widthAttr = note.getAttribute('label', 'imageWidth');
const heightAttr = note.getAttribute('label', 'imageHeight');
if (widthAttr && heightAttr) {
const width = parseInt(widthAttr.value);
const height = parseInt(heightAttr.value);
if (!isNaN(width) && !isNaN(height) && width > 0 && height > 0) {
item.width = width;
item.height = height;
}
}
// Use default dimensions if not available
if (!item.width || !item.height) {
item.width = 800;
item.height = 600;
}
items.push(item);
} catch (error) {
console.error(`Failed to process note ${noteId}:`, error);
errors.push({ noteId, error });
}
}));
if (items.length === 0) {
if (errors.length > 0) {
toastService.showError('Failed to load any images');
} else {
toastService.showMessage('No images to display');
}
return;
}
// Show warning if some images failed
if (errors.length > 0) {
toastService.showMessage(`Loaded ${items.length} images (${errors.length} failed)`);
}
// Validate and adjust start index
if (startIndex < 0 || startIndex >= items.length) {
console.warn(`Invalid start index ${startIndex}, using 0`);
startIndex = 0;
}
const callbacks: MediaViewerCallbacks = {
onOpen: () => this.onGalleryOpen(),
onClose: () => this.onGalleryClose(),
onChange: (index) => this.onGalleryChange(index),
onImageError: (index, item, error) => this.onImageError(item, error)
};
mediaViewerService.open(items, startIndex, config, callbacks);
this.galleryItems = items;
this.isGalleryMode = true;
} catch (error) {
console.error('Failed to open gallery:', error);
const errorMessage = error instanceof Error ? error.message : 'Failed to open gallery';
toastService.showError(errorMessage);
}
}
/**
* Open viewer for images in note content
*/
async openContentImages(noteId: string, container: HTMLElement, startIndex: number = 0): Promise<void> {
try {
const note = await froca.getNote(noteId);
if (!note) {
toastService.showError('Note not found');
return;
}
// Find all images in the container
const items = await mediaViewerService.createItemsFromContainer(container, 'img:not(.note-icon)');
if (items.length === 0) {
toastService.showMessage('No images found in content');
return;
}
// Add note context to items
items.forEach(item => {
item.noteId = noteId;
});
const callbacks: MediaViewerCallbacks = {
onOpen: () => this.onContentViewerOpen(noteId),
onClose: () => this.onContentViewerClose(noteId),
onChange: (index) => this.onContentImageChange(index, items),
onImageError: (index, item, error) => this.onImageError(item, error)
};
const config: Partial<MediaViewerConfig> = {
getThumbBoundsFn: (index) => {
// Get thumbnail bounds for zoom animation
const item = items[index];
if (item.element) {
const rect = item.element.getBoundingClientRect();
return { x: rect.left, y: rect.top, w: rect.width };
}
return undefined;
}
};
mediaViewerService.open(items, startIndex, config, callbacks);
this.currentNoteId = noteId;
} catch (error) {
console.error('Failed to open content images:', error);
toastService.showError('Failed to open images');
}
}
/**
* Attach click handlers to images in a container with accessibility
*/
attachToContainer(container: HTMLElement, noteId: string): void {
try {
const images = container.querySelectorAll<HTMLImageElement>('img:not(.note-icon)');
images.forEach((img, index) => {
// Skip if already has handler
if (this.clickHandlers.has(img)) {
return;
}
const handler = () => {
this.openContentImages(noteId, container, index).catch(error => {
console.error('Failed to open content images:', error);
toastService.showError('Failed to open image viewer');
});
};
img.addEventListener('click', handler);
img.classList.add('media-viewer-trigger');
img.style.cursor = 'zoom-in';
// Add accessibility attributes
img.setAttribute('role', 'button');
img.setAttribute('tabindex', '0');
img.setAttribute('aria-label', img.alt || 'Click to view image in fullscreen');
// Add keyboard support for accessibility
const keyHandler = (event: KeyboardEvent) => {
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault();
handler();
}
};
img.addEventListener('keydown', keyHandler);
// Store both handlers
this.clickHandlers.set(img, handler);
});
} catch (error) {
console.error('Failed to attach container handlers:', error);
}
}
/**
* Detach click handlers from a container
*/
detachFromContainer(container: HTMLElement): void {
const images = container.querySelectorAll<HTMLImageElement>('img.media-viewer-trigger');
images.forEach(img => {
const handler = this.clickHandlers.get(img);
if (handler) {
img.removeEventListener('click', handler);
img.classList.remove('media-viewer-trigger');
img.style.cursor = '';
this.clickHandlers.delete(img);
}
});
}
/**
* Called when viewer opens for a single image
*/
private onViewerOpen(noteId: string): void {
// Log for debugging purposes
console.debug('Media viewer opened for note:', noteId);
}
/**
* Called when viewer closes for a single image
*/
private onViewerClose(noteId: string): void {
this.currentNoteId = null;
console.debug('Media viewer closed for note:', noteId);
}
/**
* Called when gallery opens
*/
private onGalleryOpen(): void {
console.debug('Gallery opened with', this.galleryItems.length, 'items');
}
/**
* Called when gallery closes
*/
private onGalleryClose(): void {
this.isGalleryMode = false;
this.galleryItems = [];
console.debug('Gallery closed');
}
/**
* Called when gallery slide changes
*/
private onGalleryChange(index: number): void {
const item = this.galleryItems[index];
if (item && item.noteId) {
console.debug('Gallery slide changed to index:', index, 'noteId:', item.noteId);
}
}
/**
* Called when content viewer opens
*/
private onContentViewerOpen(noteId: string): void {
console.debug('Content viewer opened for note:', noteId);
}
/**
* Called when content viewer closes
*/
private onContentViewerClose(noteId: string): void {
this.currentNoteId = null;
console.debug('Content viewer closed for note:', noteId);
}
/**
* Called when content image changes
*/
private onContentImageChange(index: number, items: MediaItem[]): void {
console.debug('Content image changed to index:', index, 'of', items.length);
}
/**
* Handle image loading errors with graceful degradation
*/
private onImageError(item: MediaItem, error?: Error): void {
const errorMessage = `Failed to load image: ${item.title || 'Unknown'}`;
console.error(errorMessage, { src: item.src, error });
// Show user-friendly error message
toastService.showError(errorMessage);
// Log the error for debugging
console.debug('Image load error:', {
item,
error: error?.message || 'Unknown error'
});
}
/**
* Download current image
*/
async downloadCurrent(): Promise<void> {
if (!mediaViewerService.isOpen()) {
return;
}
const index = mediaViewerService.getCurrentIndex();
const item = this.isGalleryMode ? this.galleryItems[index] : null;
if (item && item.noteId) {
try {
const note = await froca.getNote(item.noteId);
if (note) {
const url = `api/notes/${note.noteId}/download`;
window.open(url);
}
} catch (error) {
console.error('Failed to download image:', error);
toastService.showError('Failed to download image');
}
}
}
/**
* Copy image reference to clipboard
*/
async copyImageReference(): Promise<void> {
if (!mediaViewerService.isOpen()) {
return;
}
const index = mediaViewerService.getCurrentIndex();
const item = this.isGalleryMode ? this.galleryItems[index] : null;
if (item && item.noteId) {
try {
const reference = `![](api/images/${item.noteId}/view)`;
await navigator.clipboard.writeText(reference);
toastService.showMessage('Image reference copied to clipboard');
} catch (error) {
console.error('Failed to copy image reference:', error);
toastService.showError('Failed to copy image reference');
}
}
}
/**
* Get metadata for current image with type safety
*/
async getCurrentMetadata(): Promise<{
noteId: string;
title: string;
mime?: string;
fileSize?: string;
width?: number;
height?: number;
dateCreated?: string;
dateModified?: string;
} | null> {
try {
if (!mediaViewerService.isOpen()) {
return null;
}
const index = mediaViewerService.getCurrentIndex();
const item = this.isGalleryMode ? this.galleryItems[index] : null;
if (item && item.noteId) {
const note = await froca.getNote(item.noteId);
if (note) {
const metadata = await note.getMetadata();
return {
noteId: note.noteId,
title: note.title || 'Untitled',
mime: note.mime,
fileSize: note.getAttribute('label', 'fileSize')?.value,
width: item.width,
height: item.height,
dateCreated: metadata.dateCreated,
dateModified: metadata.dateModified
};
}
}
} catch (error) {
console.error('Failed to get image metadata:', error);
}
return null;
}
/**
* Cleanup handlers and resources
*/
cleanup(): void {
try {
// Close viewer if open
mediaViewerService.close();
// Remove all click handlers
this.clickHandlers.forEach((handler, element) => {
element.removeEventListener('click', handler);
element.classList.remove('media-viewer-trigger');
element.style.cursor = '';
});
this.clickHandlers.clear();
// Remove keyboard handler with proper reference
if (this.boundKeyboardHandler) {
document.removeEventListener('keydown', this.boundKeyboardHandler);
this.boundKeyboardHandler = null;
}
// Clear references
this.currentNoteId = null;
this.galleryItems = [];
this.isGalleryMode = false;
} catch (error) {
console.error('Error during MediaViewerWidget cleanup:', error);
}
}
/**
* Handle note changes
*/
async entitiesReloadedEvent({ loadResults }: EventData<"entitiesReloaded">): Promise<void> {
// Refresh viewer if current note was reloaded
if (this.currentNoteId && loadResults.isNoteReloaded(this.currentNoteId)) {
// Close and reopen with updated data
if (mediaViewerService.isOpen()) {
const index = mediaViewerService.getCurrentIndex();
mediaViewerService.close();
if (this.isGalleryMode) {
const noteIds = this.galleryItems.map(item => item.noteId).filter(Boolean) as string[];
await this.openGallery(noteIds, index);
} else {
await this.openImageNote(this.currentNoteId);
}
}
}
}
/**
* Apply theme changes
*/
themeChangedEvent(): void {
const isDarkTheme = document.body.classList.contains('theme-dark') ||
document.body.classList.contains('theme-next-dark');
mediaViewerService.applyTheme(isDarkTheme);
}
}
// Create global instance for easy access
const mediaViewerWidget = new MediaViewerWidget();
export default mediaViewerWidget;

View File

@@ -6,7 +6,6 @@ import contentRenderer from "../../services/content_renderer.js";
import utils from "../../services/utils.js";
import options from "../../services/options.js";
import attributes from "../../services/attributes.js";
import ckeditorPhotoswipeIntegration from "../../services/ckeditor_photoswipe_integration.js";
export default class AbstractTextTypeWidget extends TypeWidget {
doRender() {
@@ -36,29 +35,7 @@ export default class AbstractTextTypeWidget extends TypeWidget {
const parsedImage = await this.parseFromImage($img);
if (parsedImage) {
// Check if this is an attachment image and PhotoSwipe is available
if (parsedImage.viewScope?.attachmentId) {
// Instead of navigating to attachment detail, trigger PhotoSwipe
// Check if the image is already processed by PhotoSwipe
const imgElement = $img[0] as HTMLImageElement;
// Check if PhotoSwipe is integrated with this image using multiple reliable indicators
const hasPhotoSwipe = imgElement.classList.contains('photoswipe-enabled') ||
imgElement.hasAttribute('data-photoswipe') ||
imgElement.style.cursor === 'zoom-in';
if (hasPhotoSwipe) {
// Image has PhotoSwipe integration, trigger click to open lightbox
$img.trigger('click');
return;
}
// Otherwise, fall back to opening attachment detail (but with improved navigation)
appContext.tabManager.getActiveContext()?.setNote(parsedImage.noteId, { viewScope: parsedImage.viewScope });
} else {
// Regular note image, navigate normally
appContext.tabManager.getActiveContext()?.setNote(parsedImage.noteId, { viewScope: parsedImage.viewScope });
}
} else {
window.open($img.prop("src"), "_blank");
}

View File

@@ -4,8 +4,6 @@ import linkService from "../../services/link.js";
import utils from "../../services/utils.js";
import { t } from "../../services/i18n.js";
import type { EventData } from "../../components/app_context.js";
import galleryManager from "../../services/gallery_manager.js";
import type { GalleryItem } from "../../services/gallery_manager.js";
const TPL = /*html*/`
<div class="attachment-list note-detail-printable">
@@ -22,81 +20,17 @@ const TPL = /*html*/`
justify-content: space-between;
align-items: baseline;
}
.attachment-list .gallery-toolbar {
display: flex;
gap: 5px;
margin-bottom: 10px;
}
.attachment-list .gallery-toolbar button {
padding: 5px 10px;
font-size: 12px;
}
.attachment-list .image-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
gap: 10px;
margin-bottom: 20px;
}
.attachment-list .image-grid .image-thumbnail {
position: relative;
width: 100%;
padding-bottom: 100%; /* 1:1 aspect ratio */
overflow: hidden;
border-radius: 4px;
cursor: pointer;
background: var(--accented-background-color);
}
.attachment-list .image-grid .image-thumbnail img {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
object-fit: cover;
transition: transform 0.2s;
}
.attachment-list .image-grid .image-thumbnail:hover img {
transform: scale(1.05);
}
.attachment-list .image-grid .image-thumbnail .overlay {
position: absolute;
bottom: 0;
left: 0;
right: 0;
background: linear-gradient(to top, rgba(0,0,0,0.7), transparent);
color: white;
padding: 5px;
font-size: 11px;
opacity: 0;
transition: opacity 0.2s;
}
.attachment-list .image-grid .image-thumbnail:hover .overlay {
opacity: 1;
}
</style>
<div class="links-wrapper"></div>
<div class="gallery-toolbar" style="display: none;"></div>
<div class="image-grid" style="display: none;"></div>
<div class="attachment-list-wrapper"></div>
</div>`;
export default class AttachmentListTypeWidget extends TypeWidget {
$list!: JQuery<HTMLElement>;
$linksWrapper!: JQuery<HTMLElement>;
$galleryToolbar!: JQuery<HTMLElement>;
$imageGrid!: JQuery<HTMLElement>;
renderedAttachmentIds!: Set<string>;
imageAttachments: GalleryItem[] = [];
otherAttachments: any[] = [];
static getType() {
return "attachmentList";
@@ -106,8 +40,6 @@ export default class AttachmentListTypeWidget extends TypeWidget {
this.$widget = $(TPL);
this.$list = this.$widget.find(".attachment-list-wrapper");
this.$linksWrapper = this.$widget.find(".links-wrapper");
this.$galleryToolbar = this.$widget.find(".gallery-toolbar");
this.$imageGrid = this.$widget.find(".image-grid");
super.doRender();
}
@@ -143,12 +75,8 @@ export default class AttachmentListTypeWidget extends TypeWidget {
);
this.$list.empty();
this.$imageGrid.empty().hide();
this.$galleryToolbar.empty().hide();
this.children = [];
this.renderedAttachmentIds = new Set();
this.imageAttachments = [];
this.otherAttachments = [];
const attachments = await note.getAttachments();
@@ -157,122 +85,17 @@ export default class AttachmentListTypeWidget extends TypeWidget {
return;
}
// Separate image and non-image attachments
for (const attachment of attachments) {
if (attachment.role === 'image') {
const galleryItem: GalleryItem = {
src: `/api/attachments/${attachment.attachmentId}/image`,
alt: attachment.title,
title: attachment.title,
attachmentId: attachment.attachmentId,
noteId: attachment.ownerId,
index: this.imageAttachments.length
};
this.imageAttachments.push(galleryItem);
} else {
this.otherAttachments.push(attachment);
}
}
// If we have image attachments, show gallery view
if (this.imageAttachments.length > 0) {
this.setupGalleryView();
}
// Render non-image attachments in the traditional list
for (const attachment of this.otherAttachments) {
const attachmentDetailWidget = new AttachmentDetailWidget(attachment, false);
this.child(attachmentDetailWidget);
this.renderedAttachmentIds.add(attachment.attachmentId);
this.$list.append(attachmentDetailWidget.render());
}
}
setupGalleryView() {
// Show gallery toolbar
this.$galleryToolbar.show();
// Add gallery action buttons
const $viewAllButton = $(`
<button class="btn btn-sm view-gallery-btn">
<span class="bx bx-images"></span>
View as Gallery (${this.imageAttachments.length} images)
</button>
`);
const $slideshowButton = $(`
<button class="btn btn-sm slideshow-btn">
<span class="bx bx-play-circle"></span>
Start Slideshow
</button>
`);
this.$galleryToolbar.append($viewAllButton, $slideshowButton);
// Handle gallery view button
$viewAllButton.on('click', () => {
galleryManager.openGallery(this.imageAttachments, 0, {
showThumbnails: true,
showCounter: true,
enableKeyboardNav: true,
loop: true
});
});
// Handle slideshow button
$slideshowButton.on('click', () => {
galleryManager.openGallery(this.imageAttachments, 0, {
showThumbnails: false,
autoPlay: true,
slideInterval: 4000,
showCounter: true,
loop: true
});
});
// Create image grid
this.$imageGrid.show();
this.imageAttachments.forEach((item, index) => {
const $thumbnail = $(`
<div class="image-thumbnail"
data-index="${index}"
role="button"
tabindex="0"
aria-label="View ${item.alt || item.title || 'image'} in gallery">
<img src="${item.src}"
alt="${item.alt || item.title || `Image ${index + 1}`}"
loading="lazy"
aria-describedby="thumb-desc-${index}">
<div class="overlay" id="thumb-desc-${index}">${item.title || ''}</div>
</div>
`);
// Add click handler
$thumbnail.on('click', () => {
galleryManager.openGallery(this.imageAttachments, index, {
showThumbnails: true,
showCounter: true,
enableKeyboardNav: true
});
});
// Add keyboard support for accessibility
$thumbnail.on('keydown', (e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
galleryManager.openGallery(this.imageAttachments, index, {
showThumbnails: true,
showCounter: true,
enableKeyboardNav: true
});
}
});
this.$imageGrid.append($thumbnail);
});
}
async entitiesReloadedEvent({ loadResults }: EventData<"entitiesReloaded">) {
// updates and deletions are handled by the detail, for new attachments the whole list has to be refreshed
const attachmentsAdded = loadResults.getAttachmentRows().some((att) => att.attachmentId && !this.renderedAttachmentIds.has(att.attachmentId));
@@ -281,16 +104,4 @@ export default class AttachmentListTypeWidget extends TypeWidget {
this.refresh();
}
}
cleanup() {
// Clean up event handlers
if (this.$galleryToolbar) {
this.$galleryToolbar.find('button').off();
}
if (this.$imageGrid) {
this.$imageGrid.find('.image-thumbnail').off();
}
super.cleanup();
}
}

View File

@@ -14,7 +14,6 @@ import type FNote from "../../entities/fnote.js";
import { PopupEditor, ClassicEditor, EditorWatchdog, type CKTextEditor, type MentionFeed, type WatchdogConfig, EditorConfig } from "@triliumnext/ckeditor5";
import "@triliumnext/ckeditor5/index.css";
import { updateTemplateCache } from "./ckeditor/snippets.js";
import ckeditorPhotoSwipe from "../../services/ckeditor_photoswipe_integration.js";
const TPL = /*html*/`
<div class="note-detail-editable-text note-detail-printable">
@@ -164,19 +163,6 @@ export default class EditableTextTypeWidget extends AbstractTextTypeWidget {
};
const editor = await buildEditor(this.$editor[0], isClassicEditor, opts);
// Setup PhotoSwipe integration for images in the editor
setTimeout(() => {
const editorElement = this.$editor[0];
if (editorElement) {
ckeditorPhotoSwipe.setupContainer(editorElement, {
enableGalleryMode: true,
showHints: true,
hintDelay: 2000,
excludeSelector: '.cke_widget_element, .ck-widget'
});
}
}, 100);
const notificationsPlugin = editor.plugins.get("Notification");
notificationsPlugin.on("show:warning", (evt, data) => {
const title = data.title;
@@ -305,25 +291,11 @@ export default class EditableTextTypeWidget extends AbstractTextTypeWidget {
}
cleanup() {
// Cleanup PhotoSwipe integration
if (this.$editor?.[0]) {
ckeditorPhotoSwipe.cleanupContainer(this.$editor[0]);
}
if (this.watchdog?.editor) {
this.spacedUpdate.allowUpdateWithoutChange(() => {
this.watchdog.editor?.setData("");
});
}
// Destroy the watchdog to clean up all CKEditor resources
if (this.watchdog) {
this.watchdog.destroy().catch((error: any) => {
console.error('Error destroying CKEditor watchdog:', error);
});
}
super.cleanup();
}
insertDateTimeToTextCommand() {

View File

@@ -1,5 +1,5 @@
import openService from "../../services/open.js";
import { ImageViewerBase } from "./image_viewer_base.js";
import TypeWidget from "./type_widget.js";
import { t } from "../../services/i18n.js";
import type { EventData } from "../../components/app_context.js";
import type FNote from "../../entities/fnote.js";
@@ -23,8 +23,7 @@ const TPL = /*html*/`
}
.note-detail.full-height .note-detail-file[data-preview-type="pdf"],
.note-detail.full-height .note-detail-file[data-preview-type="video"],
.note-detail.full-height .note-detail-file[data-preview-type="image"] {
.note-detail.full-height .note-detail-file[data-preview-type="video"] {
overflow: hidden;
}
@@ -40,133 +39,6 @@ const TPL = /*html*/`
width: 100%;
height: 100%;
}
.image-file-preview {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 100%;
position: relative;
overflow: hidden;
}
.image-file-view {
max-width: 100%;
max-height: 90%;
cursor: zoom-in;
transition: opacity 0.2s ease;
}
.image-file-view:hover {
opacity: 0.95;
}
.image-file-controls {
position: absolute;
bottom: 20px;
right: 20px;
display: flex;
gap: 10px;
background: rgba(0, 0, 0, 0.6);
border-radius: 8px;
padding: 8px;
z-index: 10;
}
.image-file-control-btn {
background: rgba(255, 255, 255, 0.9);
border: none;
border-radius: 4px;
min-width: 44px;
min-height: 44px;
width: 44px;
height: 44px;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
transition: background 0.2s;
}
.image-file-control-btn:hover:not(:disabled) {
background: rgba(255, 255, 255, 1);
}
.image-file-control-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.image-file-control-btn i {
font-size: 20px;
color: #333;
}
.image-file-info {
position: absolute;
top: 10px;
left: 10px;
background: rgba(0, 0, 0, 0.7);
color: white;
padding: 8px 12px;
border-radius: 4px;
font-size: 12px;
z-index: 10;
}
/* Loading indicator */
.image-loading-indicator {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
z-index: 100;
}
/* Zoom indicator */
.zoom-indicator {
position: absolute;
bottom: 80px;
right: 20px;
background: rgba(0, 0, 0, 0.7);
color: white;
padding: 4px 8px;
border-radius: 4px;
font-size: 12px;
z-index: 10;
pointer-events: none;
}
/* Mobile optimizations */
@media (max-width: 768px) {
.image-file-controls {
bottom: 10px;
right: 10px;
padding: 6px;
gap: 8px;
}
.image-file-info {
font-size: 11px;
padding: 6px 10px;
}
}
/* High contrast mode support */
@media (prefers-contrast: high) {
.image-file-control-btn {
border: 2px solid currentColor;
}
}
/* Reduced motion support */
@media (prefers-reduced-motion: reduce) {
.image-file-view,
.image-file-control-btn {
transition: none;
}
}
</style>
<div class="file-preview-too-big alert alert-info hidden-ext">
@@ -184,66 +56,21 @@ const TPL = /*html*/`
<video class="video-preview" controls></video>
<audio class="audio-preview" controls></audio>
<div class="image-file-preview" style="display: none;">
<div class="image-file-info">
<span class="image-dimensions"></span>
</div>
<img class="image-file-view" />
<div class="image-file-controls">
<button class="image-file-control-btn zoom-in" type="button" aria-label="Zoom In" title="Zoom In (+ key)">
<i class="bx bx-zoom-in" aria-hidden="true"></i>
</button>
<button class="image-file-control-btn zoom-out" type="button" aria-label="Zoom Out" title="Zoom Out (- key)">
<i class="bx bx-zoom-out" aria-hidden="true"></i>
</button>
<button class="image-file-control-btn reset-zoom" type="button" aria-label="Reset Zoom" title="Reset Zoom (0 key or double-click)">
<i class="bx bx-reset" aria-hidden="true"></i>
</button>
<button class="image-file-control-btn fullscreen" type="button" aria-label="Open in Lightbox" title="Open in Lightbox (Enter or Space key)">
<i class="bx bx-fullscreen" aria-hidden="true"></i>
</button>
<button class="image-file-control-btn download" type="button" aria-label="Download" title="Download File">
<i class="bx bx-download" aria-hidden="true"></i>
</button>
</div>
</div>
</div>`;
export default class FileTypeWidget extends ImageViewerBase {
export default class FileTypeWidget extends TypeWidget {
private $previewContent!: JQuery<HTMLElement>;
private $previewNotAvailable!: JQuery<HTMLElement>;
private $previewTooBig!: JQuery<HTMLElement>;
private $pdfPreview!: JQuery<HTMLElement>;
private $videoPreview!: JQuery<HTMLElement>;
private $audioPreview!: JQuery<HTMLElement>;
private $imageFilePreview!: JQuery<HTMLElement>;
private $imageFileView!: JQuery<HTMLElement>;
private $imageDimensions!: JQuery<HTMLElement>;
private $fullscreenBtn!: JQuery<HTMLElement>;
private $downloadBtn!: JQuery<HTMLElement>;
private $zoomInBtn!: JQuery<HTMLElement>;
private $zoomOutBtn!: JQuery<HTMLElement>;
private $resetZoomBtn!: JQuery<HTMLElement>;
private wheelHandler?: (e: JQuery.TriggeredEvent) => void;
private currentPreviewType?: string;
static getType() {
return "file";
}
constructor() {
super();
// Apply custom configuration for file viewer
this.applyConfig({
minZoom: 0.5,
maxZoom: 5,
zoomStep: 0.25,
debounceDelay: 16,
touchTargetSize: 44
});
}
doRender() {
this.$widget = $(TPL);
this.$previewContent = this.$widget.find(".file-preview-content");
@@ -252,204 +79,60 @@ export default class FileTypeWidget extends ImageViewerBase {
this.$pdfPreview = this.$widget.find(".pdf-preview");
this.$videoPreview = this.$widget.find(".video-preview");
this.$audioPreview = this.$widget.find(".audio-preview");
this.$imageFilePreview = this.$widget.find(".image-file-preview");
this.$imageFileView = this.$widget.find(".image-file-view");
this.$imageDimensions = this.$widget.find(".image-dimensions");
// Image controls
this.$zoomInBtn = this.$widget.find(".zoom-in");
this.$zoomOutBtn = this.$widget.find(".zoom-out");
this.$resetZoomBtn = this.$widget.find(".reset-zoom");
this.$fullscreenBtn = this.$widget.find(".fullscreen");
this.$downloadBtn = this.$widget.find(".download");
// Set image wrapper and view for base class
this.$imageWrapper = this.$imageFilePreview;
this.$imageView = this.$imageFileView;
this.setupImageControls();
super.doRender();
}
private setupImageControls(): void {
// Image click to open lightbox
this.$imageFileView?.on("click", (e) => {
e.preventDefault();
this.openImageInLightbox();
});
// Control button handlers
this.$zoomInBtn?.on("click", () => this.zoomIn());
this.$zoomOutBtn?.on("click", () => this.zoomOut());
this.$resetZoomBtn?.on("click", () => this.resetZoom());
this.$fullscreenBtn?.on("click", () => this.openImageInLightbox());
this.$downloadBtn?.on("click", () => this.downloadFile());
// Mouse wheel zoom with focus check
this.wheelHandler = (e: JQuery.TriggeredEvent) => {
// Only handle if image preview is visible and has focus
if (!this.$imageFilePreview?.is(':visible') || !this.$widget?.is(':focus-within')) {
return;
}
e.preventDefault();
const originalEvent = e.originalEvent as WheelEvent | undefined;
const delta = originalEvent?.deltaY;
if (delta) {
if (delta < 0) {
this.zoomIn();
} else {
this.zoomOut();
}
}
};
this.$imageFilePreview?.on("wheel", this.wheelHandler);
}
async doRefresh(note: FNote) {
this.$widget?.show();
this.$widget.show();
const blob = await this.note?.getBlob();
// Hide all preview types
this.$previewContent?.empty().hide();
this.$pdfPreview?.attr("src", "").empty().hide();
this.$previewNotAvailable?.hide();
this.$previewTooBig?.addClass("hidden-ext");
this.$videoPreview?.hide();
this.$audioPreview?.hide();
this.$imageFilePreview?.hide();
this.$previewContent.empty().hide();
this.$pdfPreview.attr("src", "").empty().hide();
this.$previewNotAvailable.hide();
this.$previewTooBig.addClass("hidden-ext");
this.$videoPreview.hide();
this.$audioPreview.hide();
let previewType: string;
// Check if this is an image file
if (note.mime.startsWith("image/")) {
this.$imageFilePreview?.show();
const src = openService.getUrlForDownload(`api/notes/${this.noteId}/open`);
// Reset zoom for new image
this.resetZoom();
// Setup pan, keyboard navigation, and other features
this.setupPanFunctionality();
this.setupKeyboardNavigation();
this.setupDoubleClickReset();
this.setupContextMenu();
this.addAccessibilityLabels();
// Load image with loading state and error handling
try {
await this.setupImage(src, this.$imageFileView!);
await this.loadImageDimensions(src);
} catch (error) {
console.error("Failed to load image file:", error);
}
previewType = "image";
} else if (blob?.content) {
this.$previewContent?.show().scrollTop(0);
if (blob?.content) {
this.$previewContent.show().scrollTop(0);
const trimmedContent = blob.content.substring(0, TEXT_MAX_NUM_CHARS);
if (trimmedContent.length !== blob.content.length) {
this.$previewTooBig?.removeClass("hidden-ext");
this.$previewTooBig.removeClass("hidden-ext");
}
this.$previewContent?.text(trimmedContent);
this.$previewContent.text(trimmedContent);
previewType = "text";
} else if (note.mime === "application/pdf") {
this.$pdfPreview?.show().attr("src", openService.getUrlForDownload(`api/notes/${this.noteId}/open`));
this.$pdfPreview.show().attr("src", openService.getUrlForDownload(`api/notes/${this.noteId}/open`));
previewType = "pdf";
} else if (note.mime.startsWith("video/")) {
this.$videoPreview
?.show()
.show()
.attr("src", openService.getUrlForDownload(`api/notes/${this.noteId}/open-partial`))
.attr("type", this.note?.mime ?? "")
.css("width", this.$widget?.width() ?? 0);
.css("width", this.$widget.width() ?? 0);
previewType = "video";
} else if (note.mime.startsWith("audio/")) {
this.$audioPreview
?.show()
.show()
.attr("src", openService.getUrlForDownload(`api/notes/${this.noteId}/open-partial`))
.attr("type", this.note?.mime ?? "")
.css("width", this.$widget?.width() ?? 0);
.css("width", this.$widget.width() ?? 0);
previewType = "audio";
} else {
this.$previewNotAvailable?.show();
this.$previewNotAvailable.show();
previewType = "not-available";
}
this.currentPreviewType = previewType;
this.$widget?.attr("data-preview-type", previewType ?? "");
}
private async loadImageDimensions(src: string): Promise<void> {
try {
// Use a new Image object to get dimensions
const img = new Image();
await new Promise<void>((resolve, reject) => {
img.onload = () => {
this.$imageDimensions?.text(`${img.width} × ${img.height}px`);
resolve();
};
img.onerror = () => {
this.$imageDimensions?.text("Image");
reject(new Error("Failed to load image dimensions"));
};
img.src = src;
});
} catch (error) {
console.warn("Failed to get image dimensions:", error);
this.$imageDimensions?.text("Image");
}
}
private openImageInLightbox(): void {
if (!this.note || !this.$imageFileView?.length) return;
const src = this.$imageFileView.attr("src") || this.$imageFileView.prop("src");
if (!src) return;
this.openInLightbox(
src,
this.note.title || "Image File",
this.noteId,
this.$imageFileView.get(0)
);
}
private downloadFile(): void {
if (!this.note) return;
try {
const link = document.createElement('a');
link.href = openService.getUrlForDownload(`api/notes/${this.noteId}/open`);
link.download = this.note.title || 'file';
// Add to document, click, and remove
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
} catch (error) {
console.error("Failed to download file:", error);
alert("Failed to download file. Please try again.");
}
this.$widget.attr("data-preview-type", previewType ?? "");
}
async entitiesReloadedEvent({ loadResults }: EventData<"entitiesReloaded">) {
if (loadResults.isNoteReloaded(this.noteId)) {
await this.refresh();
this.refresh();
}
}
cleanup() {
// Remove wheel handler if it exists
if (this.wheelHandler && this.$imageFilePreview?.length) {
this.$imageFilePreview.off("wheel", this.wheelHandler);
}
// Call parent cleanup
super.cleanup();
}
}

View File

@@ -1,8 +1,10 @@
import utils from "../../services/utils.js";
import { ImageViewerBase } from "./image_viewer_base.js";
import TypeWidget from "./type_widget.js";
import imageContextMenuService from "../../menus/image_context_menu.js";
import imageService from "../../services/image.js";
import type FNote from "../../entities/fnote.js";
import type { EventData } from "../../components/app_context.js";
import WheelZoom from 'vanilla-js-wheel-zoom';
const TPL = /*html*/`
<div class="note-detail-image note-detail-printable">
@@ -13,7 +15,6 @@ const TPL = /*html*/`
.note-detail-image {
height: 100%;
position: relative;
}
.note-detail-image-wrapper {
@@ -27,314 +28,53 @@ const TPL = /*html*/`
.note-detail-image-view {
display: block;
max-width: 100%;
max-height: 100%;
width: auto;
height: auto;
align-self: center;
flex-shrink: 0;
cursor: zoom-in;
transition: opacity 0.2s ease;
}
.note-detail-image-view:hover {
opacity: 0.95;
}
.image-controls {
position: absolute;
bottom: 20px;
right: 20px;
display: flex;
gap: 10px;
z-index: 10;
background: rgba(0, 0, 0, 0.6);
border-radius: 8px;
padding: 8px;
}
.image-control-btn {
background: rgba(255, 255, 255, 0.9);
border: none;
border-radius: 4px;
min-width: 44px;
min-height: 44px;
width: 44px;
height: 44px;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
transition: background 0.2s;
}
.image-control-btn:hover:not(:disabled) {
background: rgba(255, 255, 255, 1);
}
.image-control-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.image-control-btn i {
font-size: 20px;
color: #333;
}
/* Keyboard hints overlay */
.keyboard-hints {
position: absolute;
top: 10px;
right: 10px;
background: rgba(0, 0, 0, 0.7);
color: white;
padding: 8px 12px;
border-radius: 4px;
font-size: 12px;
opacity: 0;
transition: opacity 0.3s;
pointer-events: none;
z-index: 10;
}
.note-detail-image:hover .keyboard-hints {
opacity: 0.8;
}
.keyboard-hints .hint {
margin: 2px 0;
}
.keyboard-hints .key {
background: rgba(255, 255, 255, 0.2);
padding: 2px 6px;
border-radius: 3px;
margin-right: 4px;
font-family: monospace;
}
/* Loading indicator */
.image-loading-indicator {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
z-index: 100;
}
/* Zoom indicator */
.zoom-indicator {
position: absolute;
bottom: 80px;
right: 20px;
background: rgba(0, 0, 0, 0.7);
color: white;
padding: 4px 8px;
border-radius: 4px;
font-size: 12px;
z-index: 10;
pointer-events: none;
}
/* Mobile optimizations */
@media (max-width: 768px) {
.image-controls {
bottom: 10px;
right: 10px;
padding: 6px;
gap: 8px;
}
.keyboard-hints {
display: none;
}
}
/* High contrast mode support */
@media (prefers-contrast: high) {
.image-control-btn {
border: 2px solid currentColor;
}
}
/* Reduced motion support */
@media (prefers-reduced-motion: reduce) {
.note-detail-image-view,
.image-control-btn {
transition: none;
}
}
</style>
<div class="note-detail-image-wrapper">
<img class="note-detail-image-view" />
</div>
<div class="image-controls">
<button class="image-control-btn zoom-in" type="button" aria-label="Zoom In" title="Zoom In (+ key)">
<i class="bx bx-zoom-in" aria-hidden="true"></i>
</button>
<button class="image-control-btn zoom-out" type="button" aria-label="Zoom Out" title="Zoom Out (- key)">
<i class="bx bx-zoom-out" aria-hidden="true"></i>
</button>
<button class="image-control-btn reset-zoom" type="button" aria-label="Reset Zoom" title="Reset Zoom (0 key or double-click)">
<i class="bx bx-reset" aria-hidden="true"></i>
</button>
<button class="image-control-btn fullscreen" type="button" aria-label="Fullscreen" title="Fullscreen (Enter or Space key)">
<i class="bx bx-fullscreen" aria-hidden="true"></i>
</button>
<button class="image-control-btn download" type="button" aria-label="Download" title="Download Image">
<i class="bx bx-download" aria-hidden="true"></i>
</button>
</div>
<div class="keyboard-hints" aria-hidden="true">
<div class="hint"><span class="key">Click</span> Open lightbox</div>
<div class="hint"><span class="key">Double-click</span> Reset zoom</div>
<div class="hint"><span class="key">Scroll</span> Zoom</div>
<div class="hint"><span class="key">+/-</span> Zoom in/out</div>
<div class="hint"><span class="key">0</span> Reset zoom</div>
<div class="hint"><span class="key">ESC</span> Close lightbox</div>
<div class="hint"><span class="key">Arrow keys</span> Pan (when zoomed)</div>
</div>
</div>`;
class ImageTypeWidget extends ImageViewerBase {
private $zoomInBtn!: JQuery<HTMLElement>;
private $zoomOutBtn!: JQuery<HTMLElement>;
private $resetZoomBtn!: JQuery<HTMLElement>;
private $fullscreenBtn!: JQuery<HTMLElement>;
private $downloadBtn!: JQuery<HTMLElement>;
private wheelHandler?: (e: JQuery.TriggeredEvent) => void;
class ImageTypeWidget extends TypeWidget {
private $imageWrapper!: JQuery<HTMLElement>;
private $imageView!: JQuery<HTMLElement>;
static getType() {
return "image";
}
constructor() {
super();
// Apply custom configuration if needed
this.applyConfig({
minZoom: 0.5,
maxZoom: 5,
zoomStep: 0.25,
debounceDelay: 16,
touchTargetSize: 44
});
}
doRender() {
this.$widget = $(TPL);
this.$imageWrapper = this.$widget.find(".note-detail-image-wrapper");
this.$imageView = this.$widget.find(".note-detail-image-view");
this.$imageView = this.$widget.find(".note-detail-image-view").attr("id", `image-view-${utils.randomString(10)}`);
// Generate unique ID for image element
const imageId = `image-view-${utils.randomString(10)}`;
this.$imageView.attr("id", imageId);
const initZoom = async () => {
const element = document.querySelector(`#${this.$imageView.attr("id")}`);
if (element) {
WheelZoom.create(`#${this.$imageView.attr("id")}`, {
maxScale: 50,
speed: 1.3,
zoomOnClick: false
});
} else {
requestAnimationFrame(initZoom);
}
};
initZoom();
// Get control buttons
this.$zoomInBtn = this.$widget.find(".zoom-in");
this.$zoomOutBtn = this.$widget.find(".zoom-out");
this.$resetZoomBtn = this.$widget.find(".reset-zoom");
this.$fullscreenBtn = this.$widget.find(".fullscreen");
this.$downloadBtn = this.$widget.find(".download");
this.setupEventHandlers();
this.setupPanFunctionality();
this.setupKeyboardNavigation();
this.setupDoubleClickReset();
this.setupContextMenu();
this.addAccessibilityLabels();
imageContextMenuService.setupContextMenu(this.$imageView);
super.doRender();
}
private setupEventHandlers(): void {
// Image click to open lightbox
this.$imageView?.on("click", async (e) => {
e.preventDefault();
await this.handleOpenLightbox();
});
// Control button handlers
this.$zoomInBtn?.on("click", () => this.zoomIn());
this.$zoomOutBtn?.on("click", () => this.zoomOut());
this.$resetZoomBtn?.on("click", () => this.resetZoom());
this.$fullscreenBtn?.on("click", async () => await this.handleOpenLightbox());
this.$downloadBtn?.on("click", () => this.downloadImage());
// Mouse wheel zoom with debouncing
this.wheelHandler = (e: JQuery.TriggeredEvent) => {
// Only handle if widget has focus
if (!this.$widget?.is(':focus-within')) {
return;
}
e.preventDefault();
const originalEvent = e.originalEvent as WheelEvent | undefined;
const delta = originalEvent?.deltaY;
if (delta) {
if (delta < 0) {
this.zoomIn();
} else {
this.zoomOut();
}
}
};
this.$imageWrapper?.on("wheel", this.wheelHandler);
}
private async handleOpenLightbox(): Promise<void> {
if (!this.$imageView?.length) return;
const src = this.$imageView.attr('src') || this.$imageView.prop('src');
if (!src) return;
await this.openInLightbox(
src,
this.note?.title,
this.noteId,
this.$imageView.get(0)
);
}
async doRefresh(note: FNote) {
const src = utils.createImageSrcUrl(note);
// Reset zoom when image changes
this.resetZoom();
// Refresh gallery items when note changes
await this.refreshGalleryItems();
// Setup image with loading state and error handling
try {
await this.setupImage(src, this.$imageView!);
} catch (error) {
console.error("Failed to load image:", error);
// Error message is already shown by setupImage
}
}
private downloadImage(): void {
if (!this.note) return;
try {
const link = document.createElement('a');
link.href = utils.createImageSrcUrl(this.note);
link.download = this.note.title || 'image';
// Add to document, click, and remove
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
} catch (error) {
console.error("Failed to download image:", error);
alert("Failed to download image. Please try again.");
}
this.$imageView.prop("src", utils.createImageSrcUrl(note));
}
copyImageReferenceToClipboardEvent({ ntxId }: EventData<"copyImageReferenceToClipboard">) {
@@ -342,26 +82,14 @@ class ImageTypeWidget extends ImageViewerBase {
return;
}
if (this.$imageWrapper?.length) {
imageService.copyImageReferenceToClipboard(this.$imageWrapper);
}
}
async entitiesReloadedEvent({ loadResults }: EventData<"entitiesReloaded">) {
if (loadResults.isNoteReloaded(this.noteId)) {
await this.refresh();
this.refresh();
}
}
cleanup() {
// Remove wheel handler if it exists
if (this.wheelHandler && this.$imageWrapper?.length) {
this.$imageWrapper.off("wheel", this.wheelHandler);
}
// Call parent cleanup
super.cleanup();
}
}
export default ImageTypeWidget;

View File

@@ -1,352 +0,0 @@
import { ImageViewerBase } from './image_viewer_base.js';
import mediaViewer from '../../services/media_viewer.js';
// Mock mediaViewer
jest.mock('../../services/media_viewer.js', () => ({
default: {
isOpen: jest.fn().mockReturnValue(false),
close: jest.fn(),
openSingle: jest.fn(),
getImageDimensions: jest.fn().mockResolvedValue({ width: 1920, height: 1080 })
}
}));
// Create a concrete test class
class TestImageViewer extends ImageViewerBase {
static getType() {
return 'test';
}
doRender() {
this.$widget = $('<div class="test-widget" tabindex="0"></div>');
this.$imageWrapper = $('<div class="image-wrapper"></div>');
this.$imageView = $('<img class="image-view" />');
this.$widget.append(this.$imageWrapper.append(this.$imageView));
super.doRender();
}
async doRefresh() {
// Test implementation
}
}
describe('ImageViewerBase', () => {
let widget: TestImageViewer;
let $container: JQuery<HTMLElement>;
beforeEach(() => {
// Setup DOM container
$container = $('<div id="test-container"></div>');
$('body').append($container);
widget = new TestImageViewer();
widget.doRender();
$container.append(widget.$widget!);
});
afterEach(() => {
widget.cleanup();
$container.remove();
jest.clearAllMocks();
});
describe('PhotoSwipe Verification', () => {
it('should verify PhotoSwipe availability on initialization', () => {
expect(widget['isPhotoSwipeAvailable']).toBe(true);
});
it('should handle PhotoSwipe not being available gracefully', () => {
const originalMediaViewer = mediaViewer;
// @ts-ignore - Temporarily set to undefined for testing
window.mediaViewer = undefined;
const newWidget = new TestImageViewer();
expect(newWidget['isPhotoSwipeAvailable']).toBe(false);
// @ts-ignore - Restore
window.mediaViewer = originalMediaViewer;
});
});
describe('Configuration', () => {
it('should have default configuration values', () => {
expect(widget['config'].minZoom).toBe(0.5);
expect(widget['config'].maxZoom).toBe(5);
expect(widget['config'].zoomStep).toBe(0.25);
expect(widget['config'].debounceDelay).toBe(16);
expect(widget['config'].touchTargetSize).toBe(44);
});
it('should allow configuration overrides', () => {
widget['applyConfig']({
minZoom: 0.2,
maxZoom: 10,
zoomStep: 0.5
});
expect(widget['config'].minZoom).toBe(0.2);
expect(widget['config'].maxZoom).toBe(10);
expect(widget['config'].zoomStep).toBe(0.5);
expect(widget['config'].debounceDelay).toBe(16); // Unchanged
});
});
describe('Loading States', () => {
it('should show loading indicator when loading image', () => {
widget['showLoadingIndicator']();
expect(widget.$imageWrapper?.find('.image-loading-indicator').length).toBe(1);
});
it('should hide loading indicator after loading', () => {
widget['showLoadingIndicator']();
widget['hideLoadingIndicator']();
expect(widget.$imageWrapper?.find('.image-loading-indicator').length).toBe(0);
});
it('should handle image load errors gracefully', async () => {
const mockImage = {
onload: null as any,
onerror: null as any,
src: ''
};
// @ts-ignore
global.Image = jest.fn(() => mockImage);
const setupPromise = widget['setupImage']('test.jpg', widget.$imageView!);
// Trigger error
mockImage.onerror(new Error('Failed to load'));
await expect(setupPromise).rejects.toThrow('Failed to load image');
expect(widget.$imageWrapper?.find('.alert-danger').length).toBe(1);
});
});
describe('Zoom Functionality', () => {
it('should zoom in correctly', () => {
const initialZoom = widget['currentZoom'];
widget['zoomIn']();
// Wait for debounce
jest.advanceTimersByTime(20);
expect(widget['currentZoom']).toBeGreaterThan(initialZoom);
});
it('should zoom out correctly', () => {
widget['currentZoom'] = 2;
widget['zoomOut']();
// Wait for debounce
jest.advanceTimersByTime(20);
expect(widget['currentZoom']).toBeLessThan(2);
});
it('should respect zoom limits', () => {
// Test max zoom
widget['currentZoom'] = widget['config'].maxZoom;
widget['zoomIn']();
jest.advanceTimersByTime(20);
expect(widget['currentZoom']).toBe(widget['config'].maxZoom);
// Test min zoom
widget['currentZoom'] = widget['config'].minZoom;
widget['zoomOut']();
jest.advanceTimersByTime(20);
expect(widget['currentZoom']).toBe(widget['config'].minZoom);
});
it('should reset zoom to 100%', () => {
widget['currentZoom'] = 3;
widget['resetZoom']();
expect(widget['currentZoom']).toBe(1);
});
it('should show zoom indicator when zooming', () => {
widget['updateZoomIndicator']();
expect(widget.$widget?.find('.zoom-indicator').length).toBe(1);
expect(widget.$widget?.find('.zoom-indicator').text()).toBe('100%');
});
});
describe('Keyboard Navigation', () => {
it('should only handle keyboard events when widget has focus', () => {
const preventDefaultSpy = jest.fn();
const stopPropagationSpy = jest.fn();
// Simulate widget not having focus
widget.$widget?.blur();
const event = $.Event('keydown', {
key: '+',
preventDefault: preventDefaultSpy,
stopPropagation: stopPropagationSpy
});
widget.$widget?.trigger(event);
expect(preventDefaultSpy).not.toHaveBeenCalled();
expect(stopPropagationSpy).not.toHaveBeenCalled();
});
it('should handle zoom keyboard shortcuts when focused', () => {
// Focus the widget
widget.$widget?.focus();
jest.spyOn(widget.$widget!, 'is').mockImplementation((selector) => {
if (selector === ':focus-within') return true;
return false;
});
const zoomInSpy = jest.spyOn(widget as any, 'zoomIn');
const event = $.Event('keydown', { key: '+' });
widget.$widget?.trigger(event);
expect(zoomInSpy).toHaveBeenCalled();
});
});
describe('Pan Functionality', () => {
it('should setup pan event handlers', () => {
widget['setupPanFunctionality']();
expect(widget['boundHandlers'].size).toBeGreaterThan(0);
});
it('should only allow panning when zoomed in', () => {
widget['currentZoom'] = 1; // Not zoomed
const mouseDownEvent = $.Event('mousedown', { pageX: 100, pageY: 100 });
widget.$imageWrapper?.trigger(mouseDownEvent);
expect(widget['isDragging']).toBe(false);
// Now zoom in and try again
widget['currentZoom'] = 2;
widget.$imageWrapper?.trigger(mouseDownEvent);
expect(widget['isDragging']).toBe(true);
});
});
describe('Accessibility', () => {
it('should add ARIA labels to buttons', () => {
const $button = $('<button class="zoom-in"></button>');
widget.$widget?.append($button);
widget['addAccessibilityLabels']();
expect($button.attr('aria-label')).toBe('Zoom in');
expect($button.attr('role')).toBe('button');
});
it('should make widget focusable with proper ARIA attributes', () => {
widget['setupKeyboardNavigation']();
expect(widget.$widget?.attr('tabindex')).toBe('0');
expect(widget.$widget?.attr('role')).toBe('application');
expect(widget.$widget?.attr('aria-label')).toBeTruthy();
});
});
describe('Lightbox Integration', () => {
it('should open lightbox when PhotoSwipe is available', () => {
const openSingleSpy = jest.spyOn(mediaViewer, 'openSingle');
widget['openInLightbox']('test.jpg', 'Test Image', 'note123');
expect(openSingleSpy).toHaveBeenCalledWith(
expect.objectContaining({
src: 'test.jpg',
alt: 'Test Image',
title: 'Test Image',
noteId: 'note123'
}),
expect.any(Object),
expect.any(Object)
);
});
it('should fallback to opening in new tab when PhotoSwipe is not available', () => {
widget['isPhotoSwipeAvailable'] = false;
const windowOpenSpy = jest.spyOn(window, 'open').mockImplementation();
widget['openInLightbox']('test.jpg', 'Test Image');
expect(windowOpenSpy).toHaveBeenCalledWith('test.jpg', '_blank');
expect(mediaViewer.openSingle).not.toHaveBeenCalled();
});
});
describe('Memory Leak Prevention', () => {
it('should cleanup all event handlers on cleanup', () => {
widget['setupPanFunctionality']();
widget['setupKeyboardNavigation']();
const initialHandlerCount = widget['boundHandlers'].size;
expect(initialHandlerCount).toBeGreaterThan(0);
widget.cleanup();
expect(widget['boundHandlers'].size).toBe(0);
});
it('should cancel animation frames on cleanup', () => {
const cancelAnimationFrameSpy = jest.spyOn(window, 'cancelAnimationFrame');
widget['rafId'] = 123;
widget.cleanup();
expect(cancelAnimationFrameSpy).toHaveBeenCalledWith(123);
expect(widget['rafId']).toBeNull();
});
it('should clear timers on cleanup', () => {
const clearTimeoutSpy = jest.spyOn(window, 'clearTimeout');
widget['zoomDebounceTimer'] = 456;
widget.cleanup();
expect(clearTimeoutSpy).toHaveBeenCalledWith(456);
expect(widget['zoomDebounceTimer']).toBeNull();
});
it('should close lightbox if open on cleanup', () => {
jest.spyOn(mediaViewer, 'isOpen').mockReturnValue(true);
const closeSpy = jest.spyOn(mediaViewer, 'close');
widget.cleanup();
expect(closeSpy).toHaveBeenCalled();
});
});
describe('Double-click Reset', () => {
it('should reset zoom on double-click', () => {
widget['currentZoom'] = 3;
widget['setupDoubleClickReset']();
const dblClickEvent = $.Event('dblclick');
widget.$imageView?.trigger(dblClickEvent);
expect(widget['currentZoom']).toBe(1);
});
});
describe('Error Handling', () => {
it('should show error message to user on failure', () => {
widget['showErrorMessage']('Test error message');
const $error = widget.$imageWrapper?.find('.alert-danger');
expect($error?.length).toBe(1);
expect($error?.text()).toBe('Test error message');
});
it('should handle null/undefined elements safely', () => {
widget.$imageView = undefined;
// Should not throw
expect(() => widget['setupImage']('test.jpg', widget.$imageView!)).not.toThrow();
});
});
});

View File

@@ -1,787 +0,0 @@
/**
* Base class for widgets that display images with zoom, pan, and lightbox functionality.
* Provides shared image viewing logic to avoid code duplication.
*/
import TypeWidget from "./type_widget.js";
import mediaViewer from "../../services/media_viewer.js";
import type { MediaItem, MediaViewerCallbacks } from "../../services/media_viewer.js";
import imageContextMenuService from "../../menus/image_context_menu.js";
import galleryManager from "../../services/gallery_manager.js";
import type { GalleryItem, GalleryConfig } from "../../services/gallery_manager.js";
export interface ImageViewerConfig {
minZoom?: number;
maxZoom?: number;
zoomStep?: number;
debounceDelay?: number;
touchTargetSize?: number;
}
export abstract class ImageViewerBase extends TypeWidget {
// Configuration
protected config: Required<ImageViewerConfig> = {
minZoom: 0.5,
maxZoom: 5,
zoomStep: 0.25,
debounceDelay: 16, // ~60fps
touchTargetSize: 44 // WCAG recommended minimum
};
// State
protected currentZoom: number = 1;
protected isDragging: boolean = false;
protected startX: number = 0;
protected startY: number = 0;
protected scrollLeft: number = 0;
protected scrollTop: number = 0;
protected isPhotoSwipeAvailable: boolean = false;
protected isLoadingImage: boolean = false;
protected galleryItems: GalleryItem[] = [];
protected currentImageIndex: number = 0;
// Elements
protected $imageWrapper?: JQuery<HTMLElement>;
protected $imageView?: JQuery<HTMLElement>;
protected $zoomIndicator?: JQuery<HTMLElement>;
protected $loadingIndicator?: JQuery<HTMLElement>;
// Event handler references for cleanup
private boundHandlers: Map<string, Function> = new Map();
private rafId: number | null = null;
private zoomDebounceTimer: number | null = null;
constructor() {
super();
this.verifyPhotoSwipe();
}
/**
* Verify PhotoSwipe is available
*/
protected verifyPhotoSwipe(): void {
try {
// Check if PhotoSwipe is loaded
if (typeof mediaViewer !== 'undefined' && mediaViewer) {
this.isPhotoSwipeAvailable = true;
} else {
console.warn("PhotoSwipe/mediaViewer not available, lightbox features disabled");
this.isPhotoSwipeAvailable = false;
}
} catch (error) {
console.error("Error checking PhotoSwipe availability:", error);
this.isPhotoSwipeAvailable = false;
}
}
/**
* Apply configuration overrides
*/
protected applyConfig(overrides?: ImageViewerConfig): void {
if (overrides) {
this.config = { ...this.config, ...overrides };
}
}
/**
* Show loading indicator
*/
protected showLoadingIndicator(): void {
if (!this.$loadingIndicator) {
this.$loadingIndicator = $('<div class="image-loading-indicator">')
.html('<div class="spinner-border spinner-border-sm" role="status"><span class="sr-only">Loading...</span></div>')
.css({
position: 'absolute',
top: '50%',
left: '50%',
transform: 'translate(-50%, -50%)',
zIndex: 100
});
}
this.$imageWrapper?.append(this.$loadingIndicator);
this.isLoadingImage = true;
}
/**
* Hide loading indicator
*/
protected hideLoadingIndicator(): void {
this.$loadingIndicator?.remove();
this.isLoadingImage = false;
}
/**
* Setup image with loading state and error handling
*/
protected async setupImage(src: string, $image: JQuery<HTMLElement>): Promise<void> {
if (!$image || !$image.length) {
console.error("Image element not provided");
return;
}
this.showLoadingIndicator();
return new Promise((resolve, reject) => {
const img = new Image();
img.onload = () => {
this.hideLoadingIndicator();
$image.attr('src', src);
// Preload dimensions for PhotoSwipe if available
if (this.isPhotoSwipeAvailable) {
this.preloadImageDimensions(src).catch(console.warn);
}
resolve();
};
img.onerror = (error) => {
this.hideLoadingIndicator();
console.error("Failed to load image:", error);
this.showErrorMessage("Failed to load image");
reject(new Error("Failed to load image"));
};
img.src = src;
});
}
/**
* Show error message to user
*/
protected showErrorMessage(message: string): void {
const $error = $('<div class="alert alert-danger">')
.text(message)
.css({
position: 'absolute',
top: '50%',
left: '50%',
transform: 'translate(-50%, -50%)',
maxWidth: '80%'
});
this.$imageWrapper?.empty().append($error);
}
/**
* Preload image dimensions for PhotoSwipe
*/
protected async preloadImageDimensions(src: string): Promise<void> {
if (!this.isPhotoSwipeAvailable) return;
try {
await mediaViewer.getImageDimensions(src);
} catch (error) {
console.warn("Failed to preload image dimensions:", error);
}
}
/**
* Detect and collect gallery items from the current context
*/
protected async detectGalleryItems(): Promise<GalleryItem[]> {
// Default implementation - can be overridden by subclasses
if (this.note && this.note.type === 'text') {
// For text notes, scan for all images
return await galleryManager.createGalleryFromNote(this.note);
}
// For single image notes, return just the current image
const src = this.$imageView?.attr('src') || this.$imageView?.prop('src');
if (src) {
return [{
src: src,
alt: this.note?.title || 'Image',
title: this.note?.title,
noteId: this.noteId,
index: 0
}];
}
return [];
}
/**
* Open image in lightbox with gallery support
*/
protected async openInLightbox(src: string, title?: string, noteId?: string, element?: HTMLElement): Promise<void> {
if (!this.isPhotoSwipeAvailable) {
console.warn("PhotoSwipe not available, cannot open lightbox");
// Fallback: open image in new tab
window.open(src, '_blank');
return;
}
if (!src) {
console.error("No image source provided for lightbox");
return;
}
try {
// Detect if we should open as a gallery
if (this.galleryItems.length === 0) {
this.galleryItems = await this.detectGalleryItems();
}
// Find the index of the current image in the gallery
let startIndex = 0;
if (this.galleryItems.length > 1) {
startIndex = this.galleryItems.findIndex(item => item.src === src);
if (startIndex === -1) startIndex = 0;
}
// Open as gallery if multiple items, otherwise single image
if (this.galleryItems.length > 1) {
// Open gallery with all images
const galleryConfig: GalleryConfig = {
showThumbnails: true,
thumbnailHeight: 80,
autoPlay: false,
slideInterval: 4000,
showCounter: true,
enableKeyboardNav: true,
enableSwipeGestures: true,
preloadCount: 2,
loop: true
};
const callbacks: MediaViewerCallbacks = {
onOpen: () => {
console.log("Gallery opened with", this.galleryItems.length, "images");
},
onClose: () => {
console.log("Gallery closed");
// Restore focus to the image element
element?.focus();
},
onChange: (index) => {
console.log("Gallery slide changed to:", index);
this.currentImageIndex = index;
},
onImageLoad: (index, mediaItem) => {
console.log("Gallery image loaded:", mediaItem.title);
},
onImageError: (index, mediaItem, error) => {
console.error("Failed to load gallery image:", error);
}
};
galleryManager.openGallery(this.galleryItems, startIndex, galleryConfig, callbacks);
} else {
// Open single image
const item: MediaItem = {
src: src,
alt: title || "Image",
title: title,
noteId: noteId,
element: element
};
const callbacks: MediaViewerCallbacks = {
onOpen: () => {
console.log("Image lightbox opened");
},
onClose: () => {
console.log("Image lightbox closed");
// Restore focus to the image element
element?.focus();
},
onImageLoad: (index, mediaItem) => {
console.log("Image loaded in lightbox:", mediaItem.title);
},
onImageError: (index, mediaItem, error) => {
console.error("Failed to load image in lightbox:", error);
}
};
// Open with enhanced configuration
mediaViewer.openSingle(item, {
bgOpacity: 0.95,
showHideOpacity: true,
pinchToClose: true,
closeOnScroll: false,
closeOnVerticalDrag: true,
wheelToZoom: true,
arrowKeys: false,
loop: false,
maxSpreadZoom: 10,
getThumbBoundsFn: (index: number) => {
// Get position of thumbnail for zoom animation
if (element) {
const rect = element.getBoundingClientRect();
return {
x: rect.left,
y: rect.top,
w: rect.width
};
}
return undefined;
}
}, callbacks);
}
} catch (error) {
console.error("Failed to open lightbox:", error);
// Fallback: open image in new tab
window.open(src, '_blank');
}
}
/**
* Zoom in with debouncing
*/
protected zoomIn(): void {
if (this.zoomDebounceTimer) {
clearTimeout(this.zoomDebounceTimer);
}
this.zoomDebounceTimer = window.setTimeout(() => {
this.currentZoom = Math.min(this.currentZoom + this.config.zoomStep, this.config.maxZoom);
this.applyZoom();
}, this.config.debounceDelay);
}
/**
* Zoom out with debouncing
*/
protected zoomOut(): void {
if (this.zoomDebounceTimer) {
clearTimeout(this.zoomDebounceTimer);
}
this.zoomDebounceTimer = window.setTimeout(() => {
this.currentZoom = Math.max(this.currentZoom - this.config.zoomStep, this.config.minZoom);
this.applyZoom();
}, this.config.debounceDelay);
}
/**
* Reset zoom to 100%
*/
protected resetZoom(): void {
this.currentZoom = 1;
this.applyZoom();
if (this.$imageWrapper?.length) {
this.$imageWrapper.scrollLeft(0).scrollTop(0);
}
}
/**
* Apply zoom with requestAnimationFrame for smooth performance
*/
protected applyZoom(): void {
if (this.rafId) {
cancelAnimationFrame(this.rafId);
}
this.rafId = requestAnimationFrame(() => {
if (!this.$imageView?.length) return;
this.$imageView.css({
transform: `scale(${this.currentZoom})`,
transformOrigin: 'center center'
});
// Update zoom indicator
this.updateZoomIndicator();
// Update button states
this.updateZoomButtonStates();
// Update cursor based on zoom level
if (this.currentZoom > 1) {
this.$imageView.css('cursor', 'move');
} else {
this.$imageView.css('cursor', 'zoom-in');
}
});
}
/**
* Update zoom percentage indicator
*/
protected updateZoomIndicator(): void {
const percentage = Math.round(this.currentZoom * 100);
if (!this.$zoomIndicator) {
this.$zoomIndicator = $('<div class="zoom-indicator">')
.css({
position: 'absolute',
bottom: '60px',
right: '20px',
background: 'rgba(0, 0, 0, 0.7)',
color: 'white',
padding: '4px 8px',
borderRadius: '4px',
fontSize: '12px',
zIndex: 10
})
.attr('aria-live', 'polite')
.attr('aria-label', 'Zoom level');
this.$widget?.append(this.$zoomIndicator);
}
this.$zoomIndicator.text(`${percentage}%`);
// Hide indicator after 2 seconds
if (this.$zoomIndicator.data('hideTimer')) {
clearTimeout(this.$zoomIndicator.data('hideTimer'));
}
this.$zoomIndicator.show();
const hideTimer = setTimeout(() => {
this.$zoomIndicator?.fadeOut();
}, 2000);
this.$zoomIndicator.data('hideTimer', hideTimer);
}
/**
* Update zoom button states
*/
protected updateZoomButtonStates(): void {
const $zoomInBtn = this.$widget?.find('.zoom-in, .image-control-btn.zoom-in');
const $zoomOutBtn = this.$widget?.find('.zoom-out, .image-control-btn.zoom-out');
if ($zoomInBtn?.length) {
$zoomInBtn.prop('disabled', this.currentZoom >= this.config.maxZoom);
$zoomInBtn.attr('aria-disabled', (this.currentZoom >= this.config.maxZoom).toString());
}
if ($zoomOutBtn?.length) {
$zoomOutBtn.prop('disabled', this.currentZoom <= this.config.minZoom);
$zoomOutBtn.attr('aria-disabled', (this.currentZoom <= this.config.minZoom).toString());
}
}
/**
* Setup pan functionality with proper event cleanup
*/
protected setupPanFunctionality(): void {
if (!this.$imageWrapper?.length) return;
// Create bound handlers for cleanup
const handleMouseDown = this.handleMouseDown.bind(this);
const handleMouseMove = this.handleMouseMove.bind(this);
const handleMouseUp = this.handleMouseUp.bind(this);
const handleTouchStart = this.handleTouchStart.bind(this);
const handleTouchMove = this.handleTouchMove.bind(this);
const handlePinchZoom = this.handlePinchZoom.bind(this);
// Store references for cleanup
this.boundHandlers.set('mousedown', handleMouseDown);
this.boundHandlers.set('mousemove', handleMouseMove);
this.boundHandlers.set('mouseup', handleMouseUp);
this.boundHandlers.set('touchstart', handleTouchStart);
this.boundHandlers.set('touchmove', handleTouchMove);
this.boundHandlers.set('pinchzoom', handlePinchZoom);
// Mouse events
this.$imageWrapper.on('mousedown', handleMouseDown);
// Document-level mouse events (for dragging outside wrapper)
$(document).on('mousemove', handleMouseMove);
$(document).on('mouseup', handleMouseUp);
// Touch events
this.$imageWrapper.on('touchstart', handleTouchStart);
this.$imageWrapper.on('touchmove', handleTouchMove);
// Pinch zoom
this.$imageWrapper.on('touchstart', handlePinchZoom);
this.$imageWrapper.on('touchmove', handlePinchZoom);
}
private handleMouseDown(e: JQuery.MouseDownEvent): void {
if (this.currentZoom <= 1 || !this.$imageWrapper) return;
this.isDragging = true;
const offset = this.$imageWrapper.offset();
if (offset) {
this.startX = e.pageX - offset.left;
this.startY = e.pageY - offset.top;
}
this.scrollLeft = this.$imageWrapper.scrollLeft() ?? 0;
this.scrollTop = this.$imageWrapper.scrollTop() ?? 0;
this.$imageWrapper.css('cursor', 'grabbing');
e.preventDefault();
}
private handleMouseMove(e: JQuery.MouseMoveEvent): void {
if (!this.isDragging || !this.$imageWrapper) return;
e.preventDefault();
const offset = this.$imageWrapper.offset();
if (offset) {
const x = e.pageX - offset.left;
const y = e.pageY - offset.top;
const walkX = (x - this.startX) * 2;
const walkY = (y - this.startY) * 2;
this.$imageWrapper.scrollLeft(this.scrollLeft - walkX);
this.$imageWrapper.scrollTop(this.scrollTop - walkY);
}
}
private handleMouseUp(): void {
if (this.isDragging) {
this.isDragging = false;
if (this.currentZoom > 1 && this.$imageWrapper) {
this.$imageWrapper.css('cursor', 'move');
}
}
}
private handleTouchStart(e: JQuery.TouchStartEvent): void {
if (this.currentZoom <= 1 || !this.$imageWrapper) return;
const touch = e.originalEvent?.touches[0];
if (touch) {
this.startX = touch.clientX;
this.startY = touch.clientY;
this.scrollLeft = this.$imageWrapper.scrollLeft() ?? 0;
this.scrollTop = this.$imageWrapper.scrollTop() ?? 0;
}
}
private handleTouchMove(e: JQuery.TouchMoveEvent): void {
if (this.currentZoom <= 1 || !this.$imageWrapper) return;
const touches = e.originalEvent?.touches;
if (touches && touches.length === 1) {
e.preventDefault();
const touch = touches[0];
const deltaX = this.startX - touch.clientX;
const deltaY = this.startY - touch.clientY;
this.$imageWrapper.scrollLeft(this.scrollLeft + deltaX);
this.$imageWrapper.scrollTop(this.scrollTop + deltaY);
}
}
private initialDistance: number = 0;
private initialZoom: number = 1;
private handlePinchZoom(e: JQuery.TriggeredEvent): void {
const touches = e.originalEvent?.touches;
if (!touches || touches.length !== 2) return;
if (e.type === 'touchstart') {
this.initialDistance = Math.hypot(
touches[0].clientX - touches[1].clientX,
touches[0].clientY - touches[1].clientY
);
this.initialZoom = this.currentZoom;
} else if (e.type === 'touchmove') {
e.preventDefault();
const distance = Math.hypot(
touches[0].clientX - touches[1].clientX,
touches[0].clientY - touches[1].clientY
);
const scale = distance / this.initialDistance;
this.currentZoom = Math.min(Math.max(this.initialZoom * scale, this.config.minZoom), this.config.maxZoom);
this.applyZoom();
}
}
/**
* Setup keyboard navigation with focus check
*/
protected setupKeyboardNavigation(): void {
if (!this.$widget?.length) return;
// Make widget focusable
this.$widget.attr('tabindex', '0');
this.$widget.attr('role', 'application');
this.$widget.attr('aria-label', 'Image viewer with zoom controls');
const handleKeyDown = (e: JQuery.KeyDownEvent) => {
// Only handle keyboard events when widget has focus
if (!this.$widget?.is(':focus-within')) {
return;
}
switch(e.key) {
case '+':
case '=':
e.preventDefault();
e.stopPropagation();
this.zoomIn();
break;
case '-':
case '_':
e.preventDefault();
e.stopPropagation();
this.zoomOut();
break;
case '0':
e.preventDefault();
e.stopPropagation();
this.resetZoom();
break;
case 'Enter':
case ' ':
if (this.isPhotoSwipeAvailable && this.$imageView?.length) {
e.preventDefault();
e.stopPropagation();
const src = this.$imageView.attr('src') || this.$imageView.prop('src');
if (src) {
this.openInLightbox(src, this.note?.title, this.noteId, this.$imageView.get(0));
}
}
break;
case 'Escape':
if (this.isPhotoSwipeAvailable && mediaViewer.isOpen()) {
e.preventDefault();
e.stopPropagation();
mediaViewer.close();
}
break;
case 'ArrowLeft':
if (this.currentZoom > 1 && this.$imageWrapper) {
e.preventDefault();
e.stopPropagation();
this.$imageWrapper.scrollLeft((this.$imageWrapper.scrollLeft() ?? 0) - 50);
}
break;
case 'ArrowRight':
if (this.currentZoom > 1 && this.$imageWrapper) {
e.preventDefault();
e.stopPropagation();
this.$imageWrapper.scrollLeft((this.$imageWrapper.scrollLeft() ?? 0) + 50);
}
break;
case 'ArrowUp':
if (this.currentZoom > 1 && this.$imageWrapper) {
e.preventDefault();
e.stopPropagation();
this.$imageWrapper.scrollTop((this.$imageWrapper.scrollTop() ?? 0) - 50);
}
break;
case 'ArrowDown':
if (this.currentZoom > 1 && this.$imageWrapper) {
e.preventDefault();
e.stopPropagation();
this.$imageWrapper.scrollTop((this.$imageWrapper.scrollTop() ?? 0) + 50);
}
break;
}
};
this.boundHandlers.set('keydown', handleKeyDown);
this.$widget.on('keydown', handleKeyDown);
}
/**
* Refresh gallery items when content changes
*/
protected async refreshGalleryItems(): Promise<void> {
this.galleryItems = await this.detectGalleryItems();
this.currentImageIndex = 0;
}
/**
* Setup double-click to reset zoom
*/
protected setupDoubleClickReset(): void {
if (!this.$imageView?.length) return;
this.$imageView.on('dblclick', (e) => {
e.preventDefault();
this.resetZoom();
});
}
/**
* Setup context menu for image
*/
protected setupContextMenu(): void {
if (this.$imageView?.length) {
imageContextMenuService.setupContextMenu(this.$imageView);
}
}
/**
* Add ARIA labels for accessibility
*/
protected addAccessibilityLabels(): void {
// Add ARIA labels to control buttons
this.$widget?.find('.zoom-in, .image-control-btn.zoom-in')
.attr('aria-label', 'Zoom in')
.attr('role', 'button');
this.$widget?.find('.zoom-out, .image-control-btn.zoom-out')
.attr('aria-label', 'Zoom out')
.attr('role', 'button');
this.$widget?.find('.fullscreen, .image-control-btn.fullscreen')
.attr('aria-label', 'Open in fullscreen lightbox')
.attr('role', 'button');
this.$widget?.find('.download, .image-control-btn.download')
.attr('aria-label', 'Download image')
.attr('role', 'button');
// Add alt text to image
if (this.$imageView?.length && this.note?.title) {
this.$imageView.attr('alt', this.note.title);
}
}
/**
* Cleanup all event handlers and resources
*/
cleanup() {
// Close gallery or lightbox if open
if (this.isPhotoSwipeAvailable) {
if (galleryManager.isGalleryOpen()) {
galleryManager.closeGallery();
} else if (mediaViewer.isOpen()) {
mediaViewer.close();
}
}
// Clear gallery items
this.galleryItems = [];
this.currentImageIndex = 0;
// Remove document-level event listeners
if (this.boundHandlers.has('mousemove')) {
$(document).off('mousemove', this.boundHandlers.get('mousemove') as any);
}
if (this.boundHandlers.has('mouseup')) {
$(document).off('mouseup', this.boundHandlers.get('mouseup') as any);
}
// Clear all bound handlers
this.boundHandlers.clear();
// Cancel any pending animations
if (this.rafId) {
cancelAnimationFrame(this.rafId);
this.rafId = null;
}
// Clear zoom debounce timer
if (this.zoomDebounceTimer) {
clearTimeout(this.zoomDebounceTimer);
this.zoomDebounceTimer = null;
}
// Clear zoom indicator timer
if (this.$zoomIndicator?.data('hideTimer')) {
clearTimeout(this.$zoomIndicator.data('hideTimer'));
}
super.cleanup();
}
}
export default ImageViewerBase;

View File

@@ -6,7 +6,6 @@ import { getLocaleById } from "../../services/i18n.js";
import appContext from "../../components/app_context.js";
import { getMermaidConfig } from "../../services/mermaid.js";
import { renderMathInElement } from "../../services/math.js";
import ckeditorPhotoSwipe from "../../services/ckeditor_photoswipe_integration.js";
const TPL = /*html*/`
<div class="note-detail-readonly-text note-detail-printable" tabindex="100">
@@ -94,21 +93,9 @@ export default class ReadOnlyTextTypeWidget extends AbstractTextTypeWidget {
}
cleanup() {
// Cleanup PhotoSwipe integration
if (this.$content?.[0]) {
ckeditorPhotoSwipe.cleanupContainer(this.$content[0]);
}
// Remove all event handlers from content
if (this.$content) {
this.$content.off();
this.$content.find('*').off();
this.$content.html("");
}
super.cleanup();
}
async doRefresh(note: FNote) {
// we load CKEditor also for read only notes because they contain content styles required for correct rendering of even read only notes
// we could load just ckeditor-content.css but that causes CSS conflicts when both build CSS and this content CSS is loaded at the same time
@@ -121,18 +108,6 @@ export default class ReadOnlyTextTypeWidget extends AbstractTextTypeWidget {
this.$content.html(blob?.content ?? "");
// Setup PhotoSwipe integration for images in read-only content
setTimeout(() => {
if (this.$content[0]) {
ckeditorPhotoSwipe.setupContainer(this.$content[0], {
enableGalleryMode: true,
showHints: true,
hintDelay: 2000,
excludeSelector: '.no-lightbox'
});
}
}, 100);
this.$content.find("a.reference-link").each((_, el) => {
this.loadReferenceLinkTitle($(el));
});

View File

@@ -674,6 +674,8 @@ export async function getFullCalendarLocale(locale: string) {
return (await import("@fullcalendar/core/locales/ro")).default;
case "ru":
return (await import("@fullcalendar/core/locales/ru")).default;
case "ja":
return (await import("@fullcalendar/core/locales/ja")).default;
case "en":
default:
return undefined;

View File

@@ -3,21 +3,17 @@
"version": "0.97.2",
"description": "Build your personal knowledge base with Trilium Notes",
"private": true,
"main": "main.cjs",
"main": "dist/main.cjs",
"dependencies": {
"@electron/remote": "2.1.3",
"better-sqlite3": "^12.0.0",
"electron-debug": "4.1.0",
"electron-dl": "4.0.0",
"electron-squirrel-startup": "1.0.1",
"jquery.fancytree": "2.38.5",
"jquery-hotkeys": "0.2.2"
"jquery-hotkeys": "0.2.2",
"jquery.fancytree": "2.38.5"
},
"devDependencies": {
"@types/electron-squirrel-startup": "1.0.2",
"@triliumnext/server": "workspace:*",
"copy-webpack-plugin": "13.0.1",
"electron": "37.2.6",
"@electron-forge/cli": "7.8.3",
"@electron-forge/maker-deb": "7.8.3",
"@electron-forge/maker-dmg": "7.8.3",
@@ -26,6 +22,11 @@
"@electron-forge/maker-squirrel": "7.8.3",
"@electron-forge/maker-zip": "7.8.3",
"@electron-forge/plugin-auto-unpack-natives": "7.8.3",
"@triliumnext/server": "workspace:*",
"@types/electron-squirrel-startup": "1.0.2",
"copy-webpack-plugin": "13.0.1",
"electron": "37.2.6",
"electron-builder": "26.0.12",
"prebuild-install": "^7.1.1"
},
"config": {
@@ -196,5 +197,14 @@
"command": "pnpm -C apps/desktop exec cross-env NODE_INSTALLER=npm TRILIUM_DATA_DIR=./data electron-forge start dist"
}
}
},
"build": {
"appId": "org.triliumnotes.desktop",
"npmRebuild": false,
"directories": {
"app": "",
"output": "out",
"buildResources": "build_resources"
}
}
}

View File

@@ -98,7 +98,12 @@
"forward-in-note-history": "Naviguer a la note suivante dans l'historique",
"open-command-palette": "Ouvrir la palette de commandes",
"clone-notes-to": "Cloner les nœuds sélectionnés",
"move-notes-to": "Déplacer les nœuds sélectionnés"
"move-notes-to": "Déplacer les nœuds sélectionnés",
"scroll-to-active-note": "Faire défiler larborescence des notes jusquà la note active",
"quick-search": "Activer la barre de recherche rapide",
"create-note-after": "Créer une note après la note active",
"create-note-into": "Créer une note enfant de la note active",
"find-in-text": "Afficher/Masquer le panneau de recherche"
},
"login": {
"title": "Connexion",
@@ -129,7 +134,7 @@
},
"setup_sync-from-desktop": {
"heading": "Synchroniser depuis une application de bureau",
"description": "Cette procédure doit être réalisée depuis l'application de bureau installée sur votre ordinateur:",
"description": "Cette procédure doit être réalisée depuis l'application de bureau:",
"step1": "Ouvrez l'application Trilium Notes.",
"step2": "Dans le menu Trilium, cliquez sur Options.",
"step3": "Cliquez sur la catégorie Synchroniser.",
@@ -282,6 +287,29 @@
},
"keyboard_action_names": {
"command-palette": "Palette de commandes",
"quick-search": "Recherche rapide"
"quick-search": "Recherche rapide",
"back-in-note-history": "Revenir dans lhistorique des notes",
"forward-in-note-history": "Suivant dans lhistorique des notes",
"jump-to-note": "Aller à…",
"scroll-to-active-note": "Faire défiler jusquà la note active",
"search-in-subtree": "Rechercher dans la sous-arborescence",
"expand-subtree": "Développer la sous-arborescence",
"collapse-tree": "Réduire larborescence",
"collapse-subtree": "Réduire la sous-arborescence",
"sort-child-notes": "Trier les notes enfants",
"create-note-after": "Créer une note après",
"create-note-into": "Créer une note dans",
"create-note-into-inbox": "Créer une note dans Inbox",
"delete-notes": "Supprimer les notes",
"move-note-up": "Remonter la note",
"move-note-down": "Descendre la note",
"move-note-up-in-hierarchy": "Monter la note dans la hiérarchie",
"move-note-down-in-hierarchy": "Descendre la note dans la hiérarchie",
"edit-note-title": "Modifier le titre de la note",
"edit-branch-prefix": "Modifier le préfixe de la branche",
"clone-notes-to": "Cloner les notes vers",
"move-notes-to": "Déplacer les notes vers",
"copy-notes-to-clipboard": "Copier les notes dans le presse-papiers",
"paste-notes-from-clipboard": "Coller les notes depuis le presse-papiers"
}
}

View File

@@ -53,7 +53,7 @@
"show-help": "内蔵のユーザーガイドを開く",
"show-cheatsheet": "よく使うキーボードショートカットをモーダルで表示する",
"text-note-operations": "テキストノート操作",
"add-link-to-text": "テキストにリンクを追加するダイアログを開く",
"add-link-to-text": "テキストにリンクを追加ダイアログを開く",
"follow-link-under-cursor": "カーソル下のリンク先へ移動",
"insert-date-and-time-to-text": "現在の日時を挿入する",
"paste-markdown-into-text": "クリップボードからMarkdownをテキストートに貼り付けます",
@@ -70,7 +70,7 @@
"open-note-externally": "デフォルトのアプリケーションでノートをファイルとして開く",
"render-active-note": "アクティブなノートを再描画(再レンダリング)する",
"run-active-note": "アクティブなJavaScriptフロントエンド/バックエンド)のコードノートを実行する",
"reload-frontend-app": "フロントエンドリロード",
"reload-frontend-app": "フロントエンドリロード",
"open-dev-tools": "開発者ツールを開く",
"find-in-text": "検索パネルの切り替え",
"toggle-left-note-tree-panel": "左パネルの切り替え (ノートツリー)",
@@ -85,7 +85,22 @@
"sort-child-notes": "子ノートを並べ替える",
"create-note-into-inbox": "inbox定義されている場合またはデイートにートを作成する",
"note-clipboard": "ノートクリップボード",
"duplicate-subtree": "サブツリーの複製"
"duplicate-subtree": "サブツリーの複製",
"edit-branch-prefix": "「ブランチ接頭辞の編集」ダイアログを表示",
"show-revisions": "「ノート変更履歴」ダイアログを表示",
"attributes-labels-and-relations": "属性(ラベルと関係)",
"add-new-label": "新しいラベルを作成する",
"create-new-relation": "新しい関係を作成する",
"toggle-basic-properties": "基本属性切り替え",
"toggle-file-properties": "ファイル属性切り替え",
"toggle-image-properties": "画像属性切り替え",
"toggle-owned-attributes": "所有属性切り替え",
"toggle-inherited-attributes": "継承属性切り替え",
"toggle-note-hoisting": "ノートホイスト切り替え",
"unhoist": "すべてのホイストを無効にする",
"toggle-book-properties": "コレクションプロパティ切り替え",
"toggle-zen-mode": "ゼンモード集中した編集のための最小限のUIを有効/無効にする",
"add-include-note-to-text": "ノートを埋め込むダイアログを開く"
},
"keyboard_action_names": {
"back-in-note-history": "ノートの履歴を戻る",
@@ -156,7 +171,31 @@
"toggle-left-pane": "左ペイン切り替え",
"toggle-full-screen": "フルスクリーンの切り替え",
"copy-without-formatting": "書式なしでコピー",
"duplicate-subtree": "サブツリーの複製"
"duplicate-subtree": "サブツリーの複製",
"create-note-into-inbox": "Inboxにートを作成",
"toggle-zen-mode": "禅モードの切り替え",
"reset-zoom-level": "ズームレベルのリセット",
"zoom-out": "ズームアウト",
"zoom-in": "ズームイン",
"jump-to-note": "ジャンプ先…",
"edit-branch-prefix": "ブランチ接頭辞の編集",
"show-revisions": "変更履歴を表示",
"add-new-label": "ラベルを追加",
"add-new-relation": "関係を追加",
"toggle-ribbon-tab-basic-properties": "リボンタブ切り替え:基本属性",
"toggle-ribbon-tab-book-properties": "リボンタブ切り替え:書籍属性",
"toggle-ribbon-tab-file-properties": "リボンタブ切り替え:ファイル属性",
"toggle-ribbon-tab-image-properties": "リボンタブ切り替え:画像属性",
"toggle-ribbon-tab-owned-attributes": "リボンタブ切り替え:自有属性",
"toggle-ribbon-tab-inherited-attributes": "リボンタブ切り替え:継承属性",
"toggle-ribbon-tab-note-map": "リボンタブ切り替え:ノートマップ",
"toggle-ribbon-tab-note-info": "リボンタブ切り替え:ノート情報",
"toggle-ribbon-tab-note-paths": "リボンタブ切り替え:ノートパス",
"toggle-ribbon-tab-similar-notes": "リボンタブ切り替え:類似ノート",
"toggle-note-hoisting": "ノートホイスト切り替え",
"unhoist-note": "ノートホイストを無効にする",
"force-save-revision": "強制保存リビジョン",
"add-include-note-to-text": "埋め込みノートを追加"
},
"login": {
"title": "ログイン",
@@ -164,14 +203,17 @@
"incorrect-totp": "TOTPが正しくありません。もう一度お試しください。",
"incorrect-password": "パスワードが正しくありません。もう一度お試しください。",
"password": "パスワード",
"button": "ログイン"
"button": "ログイン",
"remember-me": "ログイン情報を記憶する",
"sign_in_with_sso": "{{ ssoIssuerName }}でログイン"
},
"set_password": {
"title": "パスワードの設定",
"heading": "パスワードの設定",
"description": "ウェブからTriliumを始めるには、パスワードを設定する必要があります。設定したパスワードを使ってログインします。",
"password": "パスワード",
"button": "パスワードの設定"
"button": "パスワードの設定",
"password-confirmation": "パスワードの再入力"
},
"javascript-required": "Triliumを使用するにはJavaScriptを有効にする必要があります。",
"setup": {
@@ -180,7 +222,9 @@
"sync-from-desktop": "すでにデスクトップ版のインスタンスがあり、同期を設定したい",
"sync-from-server": "すでにサーバー版のインスタンスがあり、同期を設定したい",
"init-in-progress": "ドキュメントの初期化処理を実行中",
"redirecting": "まもなくアプリケーションにリダイレクトされます。"
"redirecting": "まもなくアプリケーションにリダイレクトされます。",
"next": "次へ",
"title": "セットアップ"
},
"setup_sync-from-desktop": {
"heading": "デスクトップから同期",
@@ -190,7 +234,8 @@
"step3": "同期をクリックします。",
"step4": "サーバーインスタンスアドレスを {{- host}} に変更し、保存をクリックします。",
"step5": "「同期テスト」をクリックして、接続が成功したか確認してください。",
"step6": "これらのステップを完了したら、{{- link}} をクリックしてください。"
"step6": "これらのステップを完了したら、{{- link}} をクリックしてください。",
"step6-here": "ここ"
},
"setup_sync-from-server": {
"heading": "サーバーから同期",
@@ -202,7 +247,8 @@
"proxy-instruction": "プロキシ設定を空欄にすると、システムプロキシが使用されます(デスクトップアプリケーションにのみ適用されます)",
"password": "パスワード",
"password-placeholder": "パスワード",
"finish-setup": "セットアップ完了"
"finish-setup": "セットアップ完了",
"back": "戻る"
},
"setup_sync-in-progress": {
"heading": "同期中",
@@ -237,7 +283,8 @@
"search_prefix": "検索:"
},
"test_sync": {
"not-configured": "同期サーバーホストが設定されていません。最初に同期を設定してください。"
"not-configured": "同期サーバーホストが設定されていません。最初に同期を設定してください。",
"successful": "同期サーバーとのハンドシェイクが成功しました。同期が開始されました。"
},
"hidden-subtree": {
"search-history-title": "検索履歴",
@@ -254,7 +301,40 @@
"other": "その他",
"advanced-title": "高度",
"user-guide": "ユーザーガイド",
"localization": "言語と地域"
"localization": "言語と地域",
"sql-console-history-title": "SQLコンソール履歴",
"new-note-title": "新しいノート",
"bookmarks-title": "ブックマーク",
"open-today-journal-note-title": "今日の日記を開く",
"quick-search-title": "クイックサーチ",
"recent-changes-title": "最近の変更",
"root-title": "隠されたノート",
"note-map-title": "ノートマップ",
"shared-notes-title": "共有ノート",
"bulk-action-title": "一括操作",
"backend-log-title": "バックエンドログ",
"user-hidden-title": "非表示のユーザー",
"launch-bar-templates-title": "ランチャーバーテンプレート",
"command-launcher-title": "コマンドランチャー",
"note-launcher-title": "ノートランチャー",
"script-launcher-title": "スクリプトランチャー",
"built-in-widget-title": "内蔵のウィジェット",
"spacer-title": "スペーサー",
"custom-widget-title": "カスタムウィジェット",
"launch-bar-title": "ランチャーバー",
"available-launchers-title": "利用可能なランチャー",
"go-to-previous-note-title": "前のノートに移動",
"go-to-next-note-title": "次のノートに移動",
"search-notes-title": "検索ノート",
"jump-to-note-title": "ジャンプ先…",
"calendar-title": "カレンダー",
"protected-session-title": "保護されたセッション",
"sync-status-title": "同期状態",
"settings-title": "設定",
"llm-chat-title": "ノートとチャット",
"options-title": "オプション",
"multi-factor-authentication-title": "多要素認証",
"etapi-title": "ETAPI"
},
"notes": {
"new-note": "新しいノート",
@@ -296,7 +376,10 @@
"site-theme": "サイトのテーマ",
"search_placeholder": "検索...",
"last-updated": "最終更新日 {{- date}}",
"subpages": "サブページ:"
"subpages": "サブページ:",
"image_alt": "記事画像",
"on-this-page": "このページの内容",
"expand": "展開"
},
"hidden_subtree_templates": {
"text-snippet": "テキストスニペット",
@@ -315,6 +398,19 @@
"board_note_second": "2番目のート",
"board_note_third": "3番目のート",
"board_status_progress": "進行中",
"board_status_done": "完了"
}
"board_status_done": "完了",
"geo-map": "ジオマップ",
"geolocation": "ジオロケーション",
"built-in-templates": "内蔵のテンプレート",
"board_status_todo": "未完了"
},
"share_404": {
"title": "該当なし",
"heading": "該当なし"
},
"share_page": {
"clipped-from": "このノートは元々{{- url}}から切り取られたものです",
"no-content": "このノートには内容がありません。"
},
"weekdayNumber": "第{weekNumber}週"
}

View File

@@ -1,10 +1,10 @@
{
"keyboard_actions": {
"open-jump-to-note-dialog": "打開「跳轉筆記」對話方塊",
"open-jump-to-note-dialog": "打開「跳轉筆記」對話方塊",
"search-in-subtree": "在目前筆記的子階層中搜尋筆記",
"expand-subtree": "展開目前筆記的子階層",
"collapse-tree": "收全部的筆記樹",
"collapse-subtree": "收目前筆記的子階層",
"collapse-tree": "收全部的筆記樹",
"collapse-subtree": "收目前筆記的子階層",
"sort-child-notes": "排序子筆記",
"creating-and-moving-notes": "新增和移動筆記",
"create-note-into-inbox": "在收件匣(如果已定義)或日記中新增筆記",
@@ -13,10 +13,10 @@
"move-note-down": "下移筆記",
"move-note-up-in-hierarchy": "將筆記層級上移",
"move-note-down-in-hierarchy": "將筆記層級下移",
"edit-note-title": "從筆記樹跳轉筆記詳情並編輯標題",
"edit-note-title": "從筆記樹跳轉筆記詳情並編輯標題",
"edit-branch-prefix": "顯示編輯分支前綴對話方塊",
"note-clipboard": "筆記剪貼簿",
"copy-notes-to-clipboard": "複製選定的筆記剪貼簿",
"copy-notes-to-clipboard": "複製選定的筆記剪貼簿",
"paste-notes-from-clipboard": "從剪貼簿貼上筆記至目前筆記中",
"cut-notes-to-clipboard": "剪下選定的筆記至剪貼簿",
"select-all-notes-in-parent": "選擇當前筆記級別的所有筆記",
@@ -30,24 +30,24 @@
"activate-next-tab": "切換至右側分頁",
"activate-previous-tab": "切換至左側分頁",
"open-new-window": "打開新空白視窗",
"toggle-tray": "從系統顯示/隱藏應用程式",
"first-tab": "切換至列表中第一個分頁",
"second-tab": "切換至列表中第二個分頁",
"third-tab": "切換至列表中第三個分頁",
"fourth-tab": "切換至列表中第四個分頁",
"fifth-tab": "切換至列表中第五個分頁",
"sixth-tab": "切換至列表中第六個分頁",
"seventh-tab": "切換至列表中第七個分頁",
"eight-tab": "切換至列表中第八個分頁",
"ninth-tab": "切換至列表中第九個分頁",
"last-tab": "切換至列表中最後一個分頁",
"toggle-tray": "從系統顯示/隱藏應用程式",
"first-tab": "切換至列表中第一個分頁",
"second-tab": "切換至列表中第二個分頁",
"third-tab": "切換至列表中第三個分頁",
"fourth-tab": "切換至列表中第四個分頁",
"fifth-tab": "切換至列表中第五個分頁",
"sixth-tab": "切換至列表中第六個分頁",
"seventh-tab": "切換至列表中第七個分頁",
"eight-tab": "切換至列表中第八個分頁",
"ninth-tab": "切換至列表中第九個分頁",
"last-tab": "切換至列表中最後一個分頁",
"dialogs": "對話方塊",
"show-note-source": "顯示筆記來源對話方塊",
"show-options": "打開選項頁面",
"show-revisions": "顯示筆記修改歷史對話方塊",
"show-recent-changes": "顯示最近更改對話方塊",
"show-sql-console": "顯示 SQL 控制台對話方塊",
"show-backend-log": "顯示後端日誌對話方塊",
"show-sql-console": "打開 SQL 控制台頁面",
"show-backend-log": "打開後端日誌頁面",
"text-note-operations": "文字筆記操作",
"add-link-to-text": "打開對話方塊以插入連結",
"follow-link-under-cursor": "開啟游標處的連結",
@@ -58,7 +58,7 @@
"edit-readonly-note": "編輯唯讀筆記",
"attributes-labels-and-relations": "屬性(標籤和關係)",
"add-new-label": "新增新標籤",
"create-new-relation": "新增新關",
"create-new-relation": "新增新關",
"ribbon-tabs": "功能區分頁",
"toggle-basic-properties": "顯示基本屬性",
"toggle-file-properties": "顯示文件屬性",
@@ -76,8 +76,8 @@
"open-note-externally": "以預設應用程式打開筆記文件",
"render-active-note": "渲染(重新渲染)目前筆記",
"run-active-note": "執行目前的 JavaScript前端/後端)程式碼筆記",
"toggle-note-hoisting": "提升目前筆記",
"unhoist": "從任何地方取消提升",
"toggle-note-hoisting": "聚焦目前筆記",
"unhoist": "取消任何聚焦",
"reload-frontend-app": "重新載入前端應用",
"open-dev-tools": "打開開發者工具",
"toggle-left-note-tree-panel": "顯示左側(筆記樹)面板",
@@ -88,16 +88,16 @@
"reset-zoom-level": "重設縮放比例",
"copy-without-formatting": "以純文字複製選定文字",
"force-save-revision": "強制新增/儲存目前筆記的新版本",
"show-help": "顯示用戶指南",
"toggle-book-properties": "顯示書籍屬性",
"show-help": "顯示用戶說明",
"toggle-book-properties": "顯示集合屬性",
"back-in-note-history": "跳轉至歷史記錄中的上一個筆記",
"forward-in-note-history": "跳轉至歷史記錄中的下一個筆記",
"open-command-palette": "打開命令面板",
"scroll-to-active-note": "滾動筆記樹目前筆記",
"scroll-to-active-note": "滾動筆記樹目前筆記",
"quick-search": "開啟快速搜尋列",
"create-note-after": "新增筆記於目前筆記之後",
"create-note-into": "新增目前筆記的子筆記",
"clone-notes-to": "複製選定筆記的複本至",
"clone-notes-to": "克隆選定筆記至",
"move-notes-to": "移動選定的筆記至",
"show-cheatsheet": "顯示常用鍵盤快捷鍵",
"find-in-text": "顯示搜尋面板",
@@ -137,10 +137,10 @@
"setup_sync-from-desktop": {
"heading": "從桌面版同步",
"description": "此設定需要從桌面版本啟動:",
"step1": "打開您的桌面版 TriliumNext 筆記。",
"step1": "打開您的桌面版 Trilium 筆記。",
"step2": "從 Trilium 選單中,點擊「選項」。",
"step3": "點擊「同步」類別。",
"step4": "將伺服器版網址更改為:{{- host}} 並點擊存。",
"step4": "將伺服器版網址更改為:{{- host}} 並點擊存。",
"step5": "點擊「測試同步」以驗證連接是否成功。",
"step6": "完成這些步驟後,點擊 {{- link}}。",
"step6-here": "這裡"
@@ -160,7 +160,7 @@
"finish-setup": "完成設定"
},
"setup_sync-in-progress": {
"heading": "同步",
"heading": "正在同步",
"successful": "已正確設定同步。初次同步可能需要一些時間。完成後,您將被重新導向至登入頁面。",
"outstanding-items": "未完成的同步項目:",
"outstanding-items-default": "無"
@@ -214,12 +214,12 @@
"back-in-note-history": "返回筆記歷史",
"forward-in-note-history": "前進筆記歷史",
"jump-to-note": "跳轉至…",
"scroll-to-active-note": "滾動目前筆記",
"scroll-to-active-note": "滾動目前筆記",
"quick-search": "快速搜尋",
"search-in-subtree": "在子階層中搜尋",
"expand-subtree": "展開子階層",
"collapse-tree": "收筆記樹",
"collapse-subtree": "收子階層",
"collapse-tree": "收筆記樹",
"collapse-subtree": "收子階層",
"sort-child-notes": "排序子筆記",
"create-note-after": "於後面新建筆記",
"create-note-into": "新建筆記至",
@@ -231,7 +231,7 @@
"move-note-down-in-hierarchy": "下移筆記階層",
"edit-note-title": "編輯筆記標題",
"edit-branch-prefix": "編輯分支前綴",
"clone-notes-to": "複製筆記至",
"clone-notes-to": "克隆筆記至",
"move-notes-to": "移動筆記至",
"copy-notes-to-clipboard": "複製筆記至剪貼簿",
"paste-notes-from-clipboard": "從剪貼簿貼上筆記",
@@ -246,7 +246,7 @@
"activate-next-tab": "切換至下一分頁",
"activate-previous-tab": "切換至上一分頁",
"open-new-window": "打開新視窗",
"toggle-system-tray-icon": "顯示/隱藏系統圖示",
"toggle-system-tray-icon": "顯示/隱藏系統圖示",
"toggle-zen-mode": "啟用/禁用禪模式",
"switch-to-first-tab": "切換至第一個分頁",
"switch-to-second-tab": "切換至第二個分頁",
@@ -264,17 +264,17 @@
"show-recent-changes": "顯示最近更改",
"show-sql-console": "顯示 SQL 控制台",
"show-backend-log": "顯示後端日誌",
"show-help": "顯示幫助",
"show-help": "顯示說明",
"show-cheatsheet": "顯示快捷鍵指南",
"add-link-to-text": "插入連結",
"follow-link-under-cursor": "開啟游標處的連結",
"insert-date-and-time-to-text": "插入日期和時間",
"paste-markdown-into-text": "貼上 Markdown 文字",
"cut-into-note": "剪下至筆記",
"add-include-note-to-text": "添加包含筆記",
"add-include-note-to-text": "新增包含筆記",
"edit-read-only-note": "編輯唯讀筆記",
"add-new-label": "新增標籤",
"add-new-relation": "新增關",
"add-new-relation": "新增關",
"toggle-ribbon-tab-classic-editor": "顯示功能區分頁:經典編輯器",
"toggle-ribbon-tab-basic-properties": "顯示功能區分頁:基本屬性",
"toggle-ribbon-tab-book-properties": "顯示功能區分頁:書籍屬性",
@@ -293,8 +293,8 @@
"open-note-externally": "於外部打開筆記",
"render-active-note": "渲染目前筆記",
"run-active-note": "執行目前筆記",
"toggle-note-hoisting": "提升筆記",
"unhoist-note": "取消提升筆記",
"toggle-note-hoisting": "聚焦筆記",
"unhoist-note": "取消聚焦筆記",
"reload-frontend-app": "重新載入前端程式",
"open-developer-tools": "打開開發者工具",
"find-in-text": "在文字中尋找",
@@ -313,18 +313,18 @@
"bulk-action-title": "批次操作",
"backend-log-title": "後端日誌",
"user-hidden-title": "隱藏的用戶",
"launch-bar-templates-title": "啟動模版",
"launch-bar-templates-title": "啟動模版",
"base-abstract-launcher-title": "基礎摘要啟動器",
"command-launcher-title": "命令啟動器",
"note-launcher-title": "筆記啟動器",
"script-launcher-title": "腳本啟動器",
"built-in-widget-title": "內建小工具",
"spacer-title": "空白占位",
"custom-widget-title": "自定義小工具",
"launch-bar-title": "啟動",
"custom-widget-title": "自小工具",
"launch-bar-title": "啟動",
"available-launchers-title": "可用啟動器",
"go-to-previous-note-title": "跳轉前一筆記",
"go-to-next-note-title": "跳轉後一筆記",
"go-to-previous-note-title": "跳轉前一筆記",
"go-to-next-note-title": "跳轉後一筆記",
"new-note-title": "新增筆記",
"search-notes-title": "搜尋筆記",
"jump-to-note-title": "跳轉至…",
@@ -353,7 +353,7 @@
"other": "其他",
"advanced-title": "進階",
"visible-launchers-title": "可見啟動器",
"user-guide": "使用指南",
"user-guide": "用戶說明",
"localization": "語言和區域",
"inbox-title": "收件匣"
},

View File

@@ -19,7 +19,8 @@ const DAYJS_LOADER: Record<LOCALE_IDS, () => Promise<typeof import("dayjs/locale
"ku": () => import("dayjs/locale/ku.js"),
"ro": () => import("dayjs/locale/ro.js"),
"ru": () => import("dayjs/locale/ru.js"),
"tw": () => import("dayjs/locale/zh-tw.js")
"tw": () => import("dayjs/locale/zh-tw.js"),
"ja": () => import("dayjs/locale/ja.js")
}
export async function initializeTranslations() {

View File

@@ -38,6 +38,9 @@ async function cleanupOldLogFiles() {
const customRetentionDays = config.Logging.retentionDays;
if (customRetentionDays > 0) {
retentionDays = customRetentionDays;
} else if (customRetentionDays <= -1){
info(`Log cleanup: keeping all log files, as specified by configuration.`);
return
}
const cutoffDate = new Date();

1
docs/CNAME vendored Normal file
View File

@@ -0,0 +1 @@
triliumnotes.org

197
docs/README-ZH_CN.md vendored
View File

@@ -1,97 +1,178 @@
# Trilium Notes
[English](../README.md) | [Chinese](./README-ZH_CN.md) | [Russian](./README.ru.md) | [Japanese](./README.ja.md) | [Italian](./README.it.md) | [Spanish](./README.es.md)
![GitHub Sponsors](https://img.shields.io/github/sponsors/eliandoran) ![LiberaPay patrons](https://img.shields.io/liberapay/patrons/ElianDoran)
![Docker Pulls](https://img.shields.io/docker/pulls/triliumnext/notes)
![GitHub Downloads (all assets, all releases)](https://img.shields.io/github/downloads/triliumnext/notes/total)
[![RelativeCI](https://badges.relative-ci.com/badges/Di5q7dz9daNDZ9UXi0Bp?branch=develop)](https://app.relative-ci.com/projects/Di5q7dz9daNDZ9UXi0Bp) [![Translation status](https://hosted.weblate.org/widget/trilium/svg-badge.svg)](https://hosted.weblate.org/engage/trilium/)
Trilium Notes 是一个层次化的笔记应用程序,专注于建立大型个人知识库。请参阅[屏幕截图](https://triliumnext.github.io/Docs/Wiki/screenshot-tour)以快速了解:
[英文](../README.md) | [简体中文](./README-ZH_CN.md) | [正体中文](./README-ZH_TW.md) | [俄文](./README.ru.md) | [日文](./README.ja.md) | [意大利文](./README.it.md) | [西班牙文](./README.es.md)
Trilium Notes 是一款免费且开源、跨平台的阶层式笔记应用程序,专注于建立大型个人知识库。
想快速了解,请查看[屏幕截图](https://triliumnext.github.io/Docs/Wiki/screenshot-tour)
<a href="https://triliumnext.github.io/Docs/Wiki/screenshot-tour"><img src="./app.png" alt="Trilium Screenshot" width="1000"></a>
## ⚠️ 为什么选择TriliumNext
## 🎁 功能
[原始的Trilium项目目前处于维护模式](https://github.com/zadam/trilium/issues/4620)
* 笔记可组织成任意深度的树形结构。单一笔记可放在树中的多个位置(参见[笔记复制/克隆](https://triliumnext.github.io/Docs/Wiki/cloning-notes))。
* 丰富的所见即所得WYSIWYG笔记编辑器支持表格、图片与[数学公式](https://triliumnext.github.io/Docs/Wiki/text-notes),并具备 Markdown 的[自动格式](https://triliumnext.github.io/Docs/Wiki/text-notes#autoformat)。
* 支持编辑[程序代码笔记](https://triliumnext.github.io/Docs/Wiki/code-notes),包含语法高亮。
* 快速、轻松地在笔记间[导航](https://triliumnext.github.io/Docs/Wiki/note-navigation)、全文搜索,以及[笔记聚焦hoisting](https://triliumnext.github.io/Docs/Wiki/note-hoisting)。
* 无缝的[笔记版本管理](https://triliumnext.github.io/Docs/Wiki/note-revisions)。
* 笔记[属性](https://triliumnext.github.io/Docs/Wiki/attributes)可用于笔记的组织、查询与高级[脚本](https://triliumnext.github.io/Docs/Wiki/scripts)。
* 接口提供英文、德文、西班牙文、法文、罗马尼亚文与中文(简体与正体)。
* 直接整合 [OpenID 与 TOTP](./User%20Guide/User%20Guide/Installation%20%26%20Setup/Server%20Installation/Multi-Factor%20Authentication.md) 以实现更安全的登录。
* 与自架的同步服务器进行[同步](https://triliumnext.github.io/Docs/Wiki/synchronization)
* 另有[第三方同步服务器托管服务](https://trilium.cc/paid-hosting)。
* 将笔记[分享](https://triliumnext.github.io/Docs/Wiki/sharing)(公开发布)到互联网。
* 以每则笔记为粒度的强大[笔记加密](https://triliumnext.github.io/Docs/Wiki/protected-notes)。
* 手绘/示意图:基于 [Excalidraw](https://excalidraw.com/)笔记类型为「canvas」
* 用于可视化笔记及其关系的[关系图](https://triliumnext.github.io/Docs/Wiki/relation-map)与[链接图](https://triliumnext.github.io/Docs/Wiki/link-map)。
* 思维导图:基于 [Mind Elixir](https://docs.mind-elixir.com/)。
* 具有定位钉与 GPX 轨迹的[地图](./User%20Guide/User%20Guide/Note%20Types/Geo%20Map.md)。
* [脚本](https://triliumnext.github.io/Docs/Wiki/scripts)——参见[高级展示](https://triliumnext.github.io/Docs/Wiki/advanced-showcases)。
* 用于自动化的 [REST API](https://triliumnext.github.io/Docs/Wiki/etapi)。
* 在可用性与效能上均可良好扩展,支持超过 100,000 笔笔记。
* 为手机与平板优化的[移动前端](https://triliumnext.github.io/Docs/Wiki/mobile-frontend)。
* 内置[深色主题](https://triliumnext.github.io/Docs/Wiki/themes),并支持用户主题。
* [Evernote 导入](https://triliumnext.github.io/Docs/Wiki/evernote-import)与 [Markdown 导入与导出](https://triliumnext.github.io/Docs/Wiki/markdown)。
* 用于快速保存网页内容的 [Web Clipper](https://triliumnext.github.io/Docs/Wiki/web-clipper)。
* 可自定义的 UI侧边栏按钮、用户自定义小组件等
* [度量指标Metrics](./User%20Guide/User%20Guide/Advanced%20Usage/Metrics.md),并附有 [Grafana 仪表板](./User%20Guide/User%20Guide/Advanced%20Usage/Metrics/grafana-dashboard.json)。
## 🗭 与我们讨论
✨ 想要更多 TriliumNext 的主题、脚本、外挂与资源,亦可参考以下第三方资源/社群:
欢迎加入我们的官方讨论和社区。我们专注于Trilium的开发乐于听取您对功能、建议或问题的意见
- [awesome-trilium](https://github.com/Nriver/awesome-trilium)(第三方主题、脚本、外挂与更多)。
- [TriliumRocks!](https://trilium.rocks/)(教学、指南等等)。
- [Matrix](https://matrix.to/#/#triliumnext:matrix.org)(用于同步讨论)
- [Github Discussions](https://github.com/TriliumNext/Trilium/discussions)(用于异步讨论)
- [Wiki](https://triliumnext.github.io/Docs/)(用于常见操作问题和用户指南)
## ⚠️ 为什么是 TriliumNext
上面链接的两个房间是镜像的所以您可以在任意平台上使用XMPP或者Matrix来和我们交流
[原本的 Trilium 项目目前处于维护模式](https://github.com/zadam/trilium/issues/4620)
### 非官方社区
### 从 Trilium 迁移?
[Trilium Rocks](https://discord.gg/aqdX9mXX4r)
从既有的 zadam/Trilium 例项迁移到 TriliumNext/Notes 不需要特别的迁移步骤。只要[照一般方式安装 TriliumNext/Notes](#-安装),它就会直接使用你现有的数据库。
## 🎁 特性
版本至多至 [v0.90.4](https://github.com/TriliumNext/Notes/releases/tag/v0.90.4) 与 zadam/trilium 最新版本 [v0.63.7](https://github.com/zadam/trilium/releases/tag/v0.63.7) 兼容。之后的 TriliumNext 版本已提升同步版本号(与上述不再兼容)。
* 笔记可以排列成任意深的树。单个笔记可以放在树中的多个位置(请参阅[克隆](https://triliumnext.github.io/Docs/Wiki/cloning-notes)
* 丰富的所见即所得笔记编辑功能,包括带有 Markdown [自动格式化功能的](https://triliumnext.github.io/Docs/Wiki/text-notes#autoformat)表格,图像和[数学公式](https://triliumnext.github.io/Docs/Wiki/text-notes#math-support)
* 支持编辑[使用源代码的笔记](https://triliumnext.github.io/Docs/Wiki/code-notes),包括语法高亮显示
* 笔记之间快速[导航](https://triliumnext.github.io/Docs/Wiki/note-navigation),全文搜索和[提升笔记](https://triliumnext.github.io/Docs/Wiki/note-hoisting)
* 无缝[笔记版本控制](https://triliumnext.github.io/Docs/Wiki/note-revisions)
* 笔记[属性](https://triliumnext.github.io/Docs/Wiki/attributes)可用于笔记组织,查询和高级[脚本编写](https://triliumnext.github.io/Docs/Wiki/scripts)
* [同步](https://triliumnext.github.io/Docs/Wiki/synchronization)与自托管同步服务器
* 有一个[第三方提供的同步服务器托管服务](https://trilium.cc/paid-hosting)
* 公开地[分享](https://triliumnext.github.io/Docs/Wiki/sharing)(发布)笔记到互联网
* 具有按笔记粒度的强大的[笔记加密](https://triliumnext.github.io/Docs/Wiki/protected-notes)
* 使用自带的 Excalidraw 来绘制图表(笔记类型“画布”)
* [关系图](https://triliumnext.github.io/Docs/Wiki/relation-map)和[链接图](https://triliumnext.github.io/Docs/Wiki/link-map),用于可视化笔记及其关系
* [脚本](https://triliumnext.github.io/Docs/Wiki/scripts) - 请参阅[高级功能展示](https://triliumnext.github.io/Docs/Wiki/advanced-showcases)
* 可用于自动化的 [REST API](https://triliumnext.github.io/Docs/Wiki/etapi)
* 在拥有超过 10 万条笔记时仍能保持良好的可用性和性能
* 针对智能手机和平板电脑进行优化的[用于移动设备的前端](https://triliumnext.github.io/Docs/Wiki/mobile-frontend)
* [夜间主题](https://triliumnext.github.io/Docs/Wiki/themes)
* [Evernote](https://triliumnext.github.io/Docs/Wiki/evernote-import) 和 [Markdown 导入导出](https://triliumnext.github.io/Docs/Wiki/markdown)功能
* 使用[网页剪藏](https://triliumnext.github.io/Docs/Wiki/web-clipper)轻松保存互联网上的内容
## 📖 文件
✨ 查看以下第三方资源获取更多关于TriliumNext的好东西
我们目前正将文件搬移至应用程序内(在 Trilium 中按 `F1`)。在完成前,文件中可能会有缺漏。如果你想在 GitHub 上查看,也可以直接查看[使用说明](./User%20Guide/User%20Guide/)。
- [awesome-trilium](https://github.com/Nriver/awesome-trilium):提供第三方主题、脚本、插件等资源的列表。
- [TriliumRocks!](https://trilium.rocks/):提供教程、指南等更多内容。
以下提供一些快速连结,方便你导览文件:
- [服务器安装](./User%20Guide/User%20Guide/Installation%20&%20Setup/Server%20Installation.md)
- [Docker 安装](./User%20Guide/User%20Guide/Installation%20&%20Setup/Server%20Installation/1.%20Installing%20the%20server/Using%20Docker.md)
- [升级 TriliumNext](./User%20Guide/User%20Guide/Installation%20%26%20Setup/Upgrading%20TriliumNext.md)
- [基本概念与功能-笔记](./User%20Guide/User%20Guide/Basic%20Concepts%20and%20Features/Notes.md)
- [个人知识库的模式](https://triliumnext.github.io/Docs/Wiki/patterns-of-personal-knowledge)
## 🏗 构建
在我们完成重新整理文件架构之前,你也可以[查看旧版文件](https://triliumnext.github.io/Docs)。
Trilium 可以用作桌面应用程序Linux 和 Windows或服务器Linux上托管的 Web 应用程序。虽然有 macOS 版本的桌面应用程序,但它[不受支持](https://triliumnext.github.io/Docs/Wiki/faq#mac-os-support)。
## 💬 与我们交流
* 如果要在桌面上使用 Trilium请从[最新版本](https://github.com/TriliumNext/Trilium/releases/latest)下载适用于您平台的二进制版本,解压缩该软件包并运行`trilium`可执行文件。
* 如果要在服务器上安装 Trilium请参考[此页面](https://triliumnext.github.io/Docs/Wiki/server-installation)。
* 当前仅支持(测试过)最近发布的 Chrome 和 Firefox 浏览器。
欢迎加入官方社群。我们很乐意听到你对功能、建议或问题的想法!
Trilium 也提供 Flatpak
- [Matrix](https://matrix.to/#/#triliumnext:matrix.org)(同步讨论)
- `General` Matrix 房间也桥接到 [XMPP](xmpp:discuss@trilium.thisgreat.party?join)
- [GitHub Discussions](https://github.com/TriliumNext/Notes/discussions)(异步讨论)。
- [GitHub Issues](https://github.com/TriliumNext/Notes/issues)(回报错误与提出功能需求)。
[<img width="240" src="https://flathub.org/assets/badges/flathub-badge-en.png">](https://flathub.org/apps/details/com.github.zadam.trilium)
## 🏗 安装
## 📝 文档
### Windows / macOS
[有关文档页面的完整列表,请参见 Wiki。](https://triliumnext.github.io/Docs/)
[最新释出页面](https://github.com/TriliumNext/Trilium/releases/latest)下载你平台的二进制文件,解压缩后执行 `trilium` 可执行文件。
* [Wiki 的中文翻译版本](https://github.com/baddate/trilium/wiki/)
### Linux
您还可以阅读[个人知识库模式](https://triliumnext.github.io/Docs/Wiki/patterns-of-personal-knowledge),以获取有关如何使用 Trilium 的灵感
如果你的发行版如下表所列,请使用该发行版的套件
[![Packaging status](https://repology.org/badge/vertical-allrepos/triliumnext.svg)](https://repology.org/project/triliumnext/versions)
你也可以从[最新释出页面](https://github.com/TriliumNext/Trilium/releases/latest)下载对应平台的二进制文件,解压缩后执行 `trilium` 可执行文件。
TriliumNext 也提供 Flatpak惟尚未发布到 FlatHub。
### 查看器(任何操作系统)
若你有(如下所述的)服务器安装,便可直接存取网页界面(其与桌面应用几乎相同)。
目前仅支持(并实测)最新版的 Chrome 与 Firefox。
### 移动装置
若要在行动装置上使用 TriliumNext你可以透过移动查看器存取服务器安装的移动版接口见下
如果你偏好原生 Android 应用,可使用 [TriliumDroid](https://apt.izzysoft.de/fdroid/index/apk/eu.fliegendewurst.triliumdroid)。回报问题或缺少的功能,请至[其储存库](https://github.com/FliegendeWurst/TriliumDroid)。
更多关于移动应用支持的信息请见议题https://github.com/TriliumNext/Notes/issues/72。
### 服务器
若要在你自己的服务器上安装 TriliumNext包括从 [Docker Hub](https://hub.docker.com/r/triliumnext/trilium) 使用 Docker 部署),请遵循[服务器安装文件](https://triliumnext.github.io/Docs/Wiki/server-installation)。
## 💻 贡献
### 翻译
或者克隆本仓库到本地,并运行
如果你是母语人士,欢迎前往我们的 [Weblate 页面](https://hosted.weblate.org/engage/trilium/)协助翻译 Trilium。
以下是目前的语言覆盖状态:
[![Translation status](https://hosted.weblate.org/widget/trilium/multi-auto.svg)](https://hosted.weblate.org/engage/trilium/)
### 程序代码
下载储存库,使用 `pnpm` 安装相依套件,接着启动服务器(于 http://localhost:8080 提供服务):
```shell
npm install
npm run server:start
git clone https://github.com/TriliumNext/Trilium.git
cd Trilium
pnpm install
pnpm run server:start
```
## 👏 致谢
### 文件
* [CKEditor 5](https://github.com/ckeditor/ckeditor5) - 市面上最好的所见即所得编辑器,拥有互动性强且聆听能力强的团队
* [FancyTree](https://github.com/mar10/fancytree) - 一个非常丰富的关于树的库强大到没有对手。没有它Trilium Notes 将不会如此。
* [CodeMirror](https://github.com/codemirror/CodeMirror) - 支持大量语言的代码编辑器
* [jsPlumb](https://github.com/jsplumb/jsplumb) - 强大的可视化连接库。用于[关系图](https://triliumnext.github.io/Docs/Wiki/relation-map)和[链接图](https://triliumnext.github.io/Docs/Wiki/link-map)
下载储存库,使用 `pnpm` 安装相依套件,接着启动编辑文件所需的环境:
```shell
git clone https://github.com/TriliumNext/Trilium.git
cd Trilium
pnpm install
pnpm nx run edit-docs:edit-docs
```
## 🤝 捐赠
### 建置桌面可执行文件
你可以通过 GitHub Sponsors[PayPal](https://paypal.me/za4am) 或者比特币 (bitcoin:bc1qv3svjn40v89mnkre5vyvs2xw6y8phaltl385d2) 来捐赠。
下载储存库,使用 `pnpm` 安装相依套件,然后为 Windows 建置桌面应用:
```shell
git clone https://github.com/TriliumNext/Trilium.git
cd Trilium
pnpm install
pnpm nx --project=desktop electron-forge:make -- --arch=x64 --platform=win32
```
## 🔑 许可证
更多细节请参见[开发文件](https://github.com/TriliumNext/Notes/blob/develop/docs/Developer%20Guide/Developer%20Guide/Building%20and%20deployment/Running%20a%20development%20build.md)。
本程序是自由软件:你可以再发布本软件和/或修改本软件,只要你遵循 Free Software Foundation 发布的 GNU Affero General Public License 的第三版或者任何(由你选择)更晚的版本。
### 开发者文件
请参阅[环境设定指南](./Developer%20Guide/Developer%20Guide/Environment%20Setup.md)。若有更多疑问,欢迎透过上方「与我们交流」章节所列连结与我们联系。
## 👏 鸣谢
* [CKEditor 5](https://github.com/ckeditor/ckeditor5) —— 业界最佳的所见即所得编辑器,团队互动积极。
* [FancyTree](https://github.com/mar10/fancytree) —— 功能非常丰富的树状元件几乎没有对手。没有它Trilium Notes 将不会是今天的样子。
* [CodeMirror](https://github.com/codemirror/CodeMirror) —— 支持大量语言的程序代码编辑器。
* [jsPlumb](https://github.com/jsplumb/jsplumb) —— 无可匹敌的视觉联机函式库。用于[关系图](https://triliumnext.github.io/Docs/Wiki/relation-map.html)与[连结图](https://triliumnext.github.io/Docs/Wiki/note-map.html#link-map)。
## 🤝 支持我们
目前尚无法直接赞助 TriliumNext 组织。不过你可以:
- 透过赞助我们的开发者来支持 TriliumNext 的持续开发:[eliandoran](https://github.com/sponsors/eliandoran)(完整清单请见 [repository insights]([developers]([url](https://github.com/TriliumNext/Notes/graphs/contributors)))
- 透过 [PayPal](https://paypal.me/za4am) 或比特币bitcoin:bc1qv3svjn40v89mnkre5vyvs2xw6y8phaltl385d2向原始的 Trilium 开发者([zadam](https://github.com/sponsors/zadam))表达支持。
## 🔑 授权条款
Copyright 20172025 zadam、Elian Doran 与其他贡献者。
本程序系自由软件你可以在自由软件基金会Free Software Foundation所发布的 GNU Affero 通用公众授权条款GNU AGPL第 3 版或(由你选择)任何后续版本之条款下重新散布或修改本程序。

178
docs/README-ZH_TW.md vendored Normal file
View File

@@ -0,0 +1,178 @@
# Trilium Notes
![GitHub Sponsors](https://img.shields.io/github/sponsors/eliandoran) ![LiberaPay patrons](https://img.shields.io/liberapay/patrons/ElianDoran)
![Docker Pulls](https://img.shields.io/docker/pulls/triliumnext/notes)
![GitHub Downloads (all assets, all releases)](https://img.shields.io/github/downloads/triliumnext/notes/total)
[![RelativeCI](https://badges.relative-ci.com/badges/Di5q7dz9daNDZ9UXi0Bp?branch=develop)](https://app.relative-ci.com/projects/Di5q7dz9daNDZ9UXi0Bp) [![Translation status](https://hosted.weblate.org/widget/trilium/svg-badge.svg)](https://hosted.weblate.org/engage/trilium/)
[英文](../README.md) | [簡體中文](./README-ZH_CN.md) | [正體中文](./README-ZH_TW.md) | [俄文](./README.ru.md) | [日文](./README.ja.md) | [義大利文](./README.it.md) | [西班牙文](./README.es.md)
Trilium Notes 是一款免費且開源、跨平台的階層式筆記應用程式,專注於建立大型個人知識庫。
想快速了解,請查看[螢幕截圖](https://triliumnext.github.io/Docs/Wiki/screenshot-tour)
<a href="https://triliumnext.github.io/Docs/Wiki/screenshot-tour"><img src="./app.png" alt="Trilium Screenshot" width="1000"></a>
## 🎁 功能
* 筆記可組織成任意深度的樹狀結構。單一筆記可放在樹中的多個位置(參見[筆記複製/克隆](https://triliumnext.github.io/Docs/Wiki/cloning-notes))。
* 豐富的所見即所得WYSIWYG筆記編輯器支援表格、圖片與[數學公式](https://triliumnext.github.io/Docs/Wiki/text-notes),並具備 Markdown 的[自動格式化](https://triliumnext.github.io/Docs/Wiki/text-notes#autoformat)。
* 支援編輯[程式碼筆記](https://triliumnext.github.io/Docs/Wiki/code-notes),包含語法高亮。
* 快速、輕鬆地在筆記間[導航](https://triliumnext.github.io/Docs/Wiki/note-navigation)、全文搜尋,以及[筆記聚焦hoisting](https://triliumnext.github.io/Docs/Wiki/note-hoisting)。
* 無縫的[筆記版本管理](https://triliumnext.github.io/Docs/Wiki/note-revisions)。
* 筆記[屬性](https://triliumnext.github.io/Docs/Wiki/attributes)可用於筆記的組織、查詢與進階[腳本](https://triliumnext.github.io/Docs/Wiki/scripts)。
* 介面提供英文、德文、西班牙文、法文、羅馬尼亞文與中文(簡體與正體)。
* 直接整合 [OpenID 與 TOTP](./User%20Guide/User%20Guide/Installation%20%26%20Setup/Server%20Installation/Multi-Factor%20Authentication.md) 以實現更安全的登入。
* 與自架的同步伺服器進行[同步](https://triliumnext.github.io/Docs/Wiki/synchronization)
* 另有[第三方同步伺服器託管服務](https://trilium.cc/paid-hosting)。
* 將筆記[分享](https://triliumnext.github.io/Docs/Wiki/sharing)(公開發布)到網際網路。
* 以每則筆記為粒度的強大[筆記加密](https://triliumnext.github.io/Docs/Wiki/protected-notes)。
* 手繪/示意圖:基於 [Excalidraw](https://excalidraw.com/)筆記類型為「canvas」
* 用於視覺化筆記及其關係的[關聯圖](https://triliumnext.github.io/Docs/Wiki/relation-map)與[連結圖](https://triliumnext.github.io/Docs/Wiki/link-map)。
* 心智圖:基於 [Mind Elixir](https://docs.mind-elixir.com/)。
* 具有定位釘與 GPX 軌跡的[地圖](./User%20Guide/User%20Guide/Note%20Types/Geo%20Map.md)。
* [腳本](https://triliumnext.github.io/Docs/Wiki/scripts)——參見[進階展示](https://triliumnext.github.io/Docs/Wiki/advanced-showcases)。
* 用於自動化的 [REST API](https://triliumnext.github.io/Docs/Wiki/etapi)。
* 在可用性與效能上均可良好擴展,支援超過 100,000 筆筆記。
* 為手機與平板最佳化的[行動前端](https://triliumnext.github.io/Docs/Wiki/mobile-frontend)。
* 內建[深色主題](https://triliumnext.github.io/Docs/Wiki/themes),並支援使用者主題。
* [Evernote 匯入](https://triliumnext.github.io/Docs/Wiki/evernote-import)與 [Markdown 匯入與匯出](https://triliumnext.github.io/Docs/Wiki/markdown)。
* 用於快速保存網頁內容的 [Web Clipper](https://triliumnext.github.io/Docs/Wiki/web-clipper)。
* 可自訂的 UI側邊欄按鈕、使用者自訂小工具等
* [度量指標Metrics](./User%20Guide/User%20Guide/Advanced%20Usage/Metrics.md),並附有 [Grafana 儀表板](./User%20Guide/User%20Guide/Advanced%20Usage/Metrics/grafana-dashboard.json)。
✨ 想要更多 TriliumNext 的主題、腳本、外掛與資源,亦可參考以下第三方資源/社群:
- [awesome-trilium](https://github.com/Nriver/awesome-trilium)(第三方主題、腳本、外掛與更多)。
- [TriliumRocks!](https://trilium.rocks/)(教學、指南等等)。
## ⚠️ 為什麼是 TriliumNext
[原本的 Trilium 專案目前處於維護模式](https://github.com/zadam/trilium/issues/4620)。
### 從 Trilium 遷移?
從既有的 zadam/Trilium 例項遷移到 TriliumNext/Notes 不需要特別的遷移步驟。只要[照一般方式安裝 TriliumNext/Notes](#-安裝),它就會直接使用你現有的資料庫。
版本至多至 [v0.90.4](https://github.com/TriliumNext/Notes/releases/tag/v0.90.4) 與 zadam/trilium 最新版本 [v0.63.7](https://github.com/zadam/trilium/releases/tag/v0.63.7) 相容。之後的 TriliumNext 版本已提升同步版本號(與上述不再相容)。
## 📖 文件
我們目前正將文件搬移至應用程式內(在 Trilium 中按 `F1`)。在完成前,文件中可能會有缺漏。如果你想在 GitHub 上瀏覽,也可以直接查看[使用說明](./User%20Guide/User%20Guide/)。
以下提供一些快速連結,方便你導覽文件:
- [伺服器安裝](./User%20Guide/User%20Guide/Installation%20&%20Setup/Server%20Installation.md)
- [Docker 安裝](./User%20Guide/User%20Guide/Installation%20&%20Setup/Server%20Installation/1.%20Installing%20the%20server/Using%20Docker.md)
- [升級 TriliumNext](./User%20Guide/User%20Guide/Installation%20%26%20Setup/Upgrading%20TriliumNext.md)
- [基本概念與功能-筆記](./User%20Guide/User%20Guide/Basic%20Concepts%20and%20Features/Notes.md)
- [個人知識庫的模式](https://triliumnext.github.io/Docs/Wiki/patterns-of-personal-knowledge)
在我們完成重新整理文件架構之前,你也可以[瀏覽舊版文件](https://triliumnext.github.io/Docs)。
## 💬 與我們交流
歡迎加入官方社群。我們很樂意聽到你對功能、建議或問題的想法!
- [Matrix](https://matrix.to/#/#triliumnext:matrix.org)(同步討論)
- `General` Matrix 房間也橋接到 [XMPP](xmpp:discuss@trilium.thisgreat.party?join)
- [GitHub Discussions](https://github.com/TriliumNext/Notes/discussions)(非同步討論)。
- [GitHub Issues](https://github.com/TriliumNext/Notes/issues)(回報錯誤與提出功能需求)。
## 🏗 安裝
### Windows / macOS
從[最新釋出頁面](https://github.com/TriliumNext/Trilium/releases/latest)下載你平台的二進位檔,解壓縮後執行 `trilium` 可執行檔。
### Linux
如果你的發行版如下表所列,請使用該發行版的套件。
[![Packaging status](https://repology.org/badge/vertical-allrepos/triliumnext.svg)](https://repology.org/project/triliumnext/versions)
你也可以從[最新釋出頁面](https://github.com/TriliumNext/Trilium/releases/latest)下載對應平台的二進位檔,解壓縮後執行 `trilium` 可執行檔。
TriliumNext 也提供 Flatpak惟尚未發佈到 FlatHub。
### 瀏覽器(任何作業系統)
若你有(如下所述的)伺服器安裝,便可直接存取網頁介面(其與桌面應用幾乎相同)。
目前僅支援(並實測)最新版的 Chrome 與 Firefox。
### 行動裝置
若要在行動裝置上使用 TriliumNext你可以透過行動瀏覽器存取伺服器安裝的行動版介面見下
如果你偏好原生 Android 應用,可使用 [TriliumDroid](https://apt.izzysoft.de/fdroid/index/apk/eu.fliegendewurst.triliumdroid)。回報問題或缺少的功能,請至[其儲存庫](https://github.com/FliegendeWurst/TriliumDroid)。
更多關於行動應用支援的資訊請見議題https://github.com/TriliumNext/Notes/issues/72。
### 伺服器
若要在你自己的伺服器上安裝 TriliumNext包括從 [Docker Hub](https://hub.docker.com/r/triliumnext/trilium) 使用 Docker 部署),請遵循[伺服器安裝文件](https://triliumnext.github.io/Docs/Wiki/server-installation)。
## 💻 貢獻
### 翻譯
如果你是母語人士,歡迎前往我們的 [Weblate 頁面](https://hosted.weblate.org/engage/trilium/)協助翻譯 Trilium。
以下是目前的語言覆蓋狀態:
[![Translation status](https://hosted.weblate.org/widget/trilium/multi-auto.svg)](https://hosted.weblate.org/engage/trilium/)
### 程式碼
下載儲存庫,使用 `pnpm` 安裝相依套件,接著啟動伺服器(將於 http://localhost:8080 提供服務):
```shell
git clone https://github.com/TriliumNext/Trilium.git
cd Trilium
pnpm install
pnpm run server:start
```
### 文件
下載儲存庫,使用 `pnpm` 安裝相依套件,接著啟動編輯文件所需的環境:
```shell
git clone https://github.com/TriliumNext/Trilium.git
cd Trilium
pnpm install
pnpm nx run edit-docs:edit-docs
```
### 建置桌面可執行檔
下載儲存庫,使用 `pnpm` 安裝相依套件,然後為 Windows 建置桌面應用:
```shell
git clone https://github.com/TriliumNext/Trilium.git
cd Trilium
pnpm install
pnpm nx --project=desktop electron-forge:make -- --arch=x64 --platform=win32
```
更多細節請參見[開發文件](https://github.com/TriliumNext/Notes/blob/develop/docs/Developer%20Guide/Developer%20Guide/Building%20and%20deployment/Running%20a%20development%20build.md)。
### 開發者文件
請參閱[環境設定指南](./Developer%20Guide/Developer%20Guide/Environment%20Setup.md)。若有更多疑問,歡迎透過上方「與我們交流」章節所列連結與我們聯繫。
## 👏 鳴謝
* [CKEditor 5](https://github.com/ckeditor/ckeditor5) —— 業界最佳的所見即所得編輯器,團隊互動積極。
* [FancyTree](https://github.com/mar10/fancytree) —— 功能非常豐富的樹狀元件幾乎沒有對手。沒有它Trilium Notes 將不會是今天的樣子。
* [CodeMirror](https://github.com/codemirror/CodeMirror) —— 支援大量語言的程式碼編輯器。
* [jsPlumb](https://github.com/jsplumb/jsplumb) —— 無可匹敵的視覺連線函式庫。用於[關聯圖](https://triliumnext.github.io/Docs/Wiki/relation-map.html)與[連結圖](https://triliumnext.github.io/Docs/Wiki/note-map.html#link-map)。
## 🤝 支援我們
目前尚無法直接贊助 TriliumNext 組織。不過你可以:
- 透過贊助我們的開發者來支持 TriliumNext 的持續開發:[eliandoran](https://github.com/sponsors/eliandoran)(完整清單請見 [repository insights]([developers]([url](https://github.com/TriliumNext/Notes/graphs/contributors)))
- 透過 [PayPal](https://paypal.me/za4am) 或比特幣bitcoin:bc1qv3svjn40v89mnkre5vyvs2xw6y8phaltl385d2向原始的 Trilium 開發者([zadam](https://github.com/sponsors/zadam))表達支持。
## 🔑 授權條款
Copyright 20172025 zadam、Elian Doran 與其他貢獻者。
本程式係自由軟體你可以在自由軟體基金會Free Software Foundation所發佈的 GNU Affero 通用公眾授權條款GNU AGPL第 3 版或(由你選擇)任何後續版本之條款下重新散布或修改本程式。

14
docs/RPM-GPG-KEY-trilium vendored Normal file
View File

@@ -0,0 +1,14 @@
-----BEGIN PGP PUBLIC KEY BLOCK-----
mDMEaJ2E2RYJKwYBBAHaRw8BAQdAz7cpW/2YGtzJxkY1/ruwfDcVRv1sQQdjVrg3
u1YWhyq0NFRyaWxpdW0gTm90ZXMgU2lnbmluZyBLZXkgPHRyaWxpdW1ub3Rlc0Bv
dXRsb29rLmNvbT6ImQQTFgoAQRYhBJM5P1tHTrWujPnuCW11K/PW56WUBQJonYTZ
AhsDBQkFo5qABQsJCAcCAiICBhUKCQgLAgQWAgMBAh4HAheAAAoJEG11K/PW56WU
ND4BAN9Uhl8/g7GnIZuZU2M+HUVtrY5b9SBiCYBEHmaA4CiqAQCY5xynB8T7jM/Z
zRpMmSseDMYNHVvNjy3FvBe3D6YoDrg4BGidhNkSCisGAQQBl1UBBQEBB0C0i0Ns
8lCfDPY479cOgP9Yj1yaZAKyT6qsgzuznf1EGAMBCAeIfgQYFgoAJhYhBJM5P1tH
TrWujPnuCW11K/PW56WUBQJonYTZAhsMBQkFo5qAAAoJEG11K/PW56WUgpIA+gMn
BgzRQHqm8ttf5ry155l1JtuhIx/q6UjsgqO0L3aEAP9KbJ3Vh8+bcoXymskozrVm
yTglNDkROupmyJahcKlpBQ==
=g5kb
-----END PGP PUBLIC KEY BLOCK-----

View File

@@ -24,5 +24,11 @@ retentionDays=7
Or via the environment variable `TRILIUM_LOGGING_RETENTION_DAYS`.
Special cases:
* Positive values indicate the number of days worth of logs to keep
* A value of 0 results with the default value (90 days) to be used
* Negative values (e.g. `-1`) result with all logs to be kept, irrespective how ancient and numerous (and
> [!NOTE]
> If you set the retention days to a low number, you might notice that not all the log files are being deleted. This is because a minimum number of logs (7 at the time of writing) is maintained at all times.

View File

@@ -50,6 +50,11 @@ const UNSORTED_LOCALES: Locale[] = [
name: "Русский",
electronLocale: "ru"
},
{
id: "ja",
name: "日本語",
electronLocale: "ja"
},
/*
* Right to left languages

566
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff