mirror of
https://github.com/zadam/trilium.git
synced 2025-10-28 16:56:34 +01:00
Compare commits
40 Commits
feat/bette
...
feature/el
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8f6912cd57 | ||
|
|
d32dbf40f8 | ||
|
|
1dfcf960d3 | ||
|
|
9bdc51a3fb | ||
|
|
dbf3bcfacf | ||
|
|
3d5b269315 | ||
|
|
48f97da9cc | ||
|
|
9c954fbd81 | ||
|
|
c6bd41654f | ||
|
|
d65a74bb23 | ||
|
|
ff08bca042 | ||
|
|
a5d3d2e3b4 | ||
|
|
496a0667ee | ||
|
|
9be688b667 | ||
|
|
f3d9008c61 | ||
|
|
649a43c978 | ||
|
|
50568704ca | ||
|
|
b66b4dec83 | ||
|
|
8d0e807435 | ||
|
|
bf05ed7caf | ||
|
|
b5080eff00 | ||
|
|
c474769dd6 | ||
|
|
a6ae01da0b | ||
|
|
2bf4c44dbf | ||
|
|
5ca0fbba13 | ||
|
|
4cd84b2019 | ||
|
|
c502a45cf5 | ||
|
|
9e66914306 | ||
|
|
d33d27ee82 | ||
|
|
e2b13573ae | ||
|
|
ec74f5f1de | ||
|
|
5dee56debc | ||
|
|
5623fc992d | ||
|
|
1d28bfc570 | ||
|
|
084327e973 | ||
|
|
b2885efdc1 | ||
|
|
b65a75f138 | ||
|
|
0ee7f50bb4 | ||
|
|
02ce21bc18 | ||
|
|
3ba487bb00 |
22
.github/actions/build-electron/action.yml
vendored
22
.github/actions/build-electron/action.yml
vendored
@@ -162,3 +162,25 @@ runs:
|
|||||||
echo "Found ZIP: $zip_file"
|
echo "Found ZIP: $zip_file"
|
||||||
echo "Note: ZIP files are not code signed, but their contents should be"
|
echo "Note: ZIP files are not code signed, but their contents should be"
|
||||||
fi
|
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
|
||||||
|
|||||||
3
.github/workflows/nightly.yml
vendored
3
.github/workflows/nightly.yml
vendored
@@ -76,6 +76,7 @@ jobs:
|
|||||||
APPLE_ID: ${{ secrets.APPLE_ID }}
|
APPLE_ID: ${{ secrets.APPLE_ID }}
|
||||||
APPLE_ID_PASSWORD: ${{ secrets.APPLE_ID_PASSWORD }}
|
APPLE_ID_PASSWORD: ${{ secrets.APPLE_ID_PASSWORD }}
|
||||||
WINDOWS_SIGN_EXECUTABLE: ${{ vars.WINDOWS_SIGN_EXECUTABLE }}
|
WINDOWS_SIGN_EXECUTABLE: ${{ vars.WINDOWS_SIGN_EXECUTABLE }}
|
||||||
|
GPG_SIGNING_KEY: ${{ secrets.GPG_SIGN_KEY }}
|
||||||
|
|
||||||
- name: Publish release
|
- name: Publish release
|
||||||
uses: softprops/action-gh-release@v2.3.2
|
uses: softprops/action-gh-release@v2.3.2
|
||||||
@@ -97,7 +98,7 @@ jobs:
|
|||||||
path: apps/desktop/upload
|
path: apps/desktop/upload
|
||||||
|
|
||||||
nightly-server:
|
nightly-server:
|
||||||
if: github.repository == 'TriliumNext/Trilium'
|
if: github.repository == 'TriliumNext/Trilium'
|
||||||
name: Deploy server nightly
|
name: Deploy server nightly
|
||||||
strategy:
|
strategy:
|
||||||
fail-fast: false
|
fail-fast: false
|
||||||
|
|||||||
1
.github/workflows/release.yml
vendored
1
.github/workflows/release.yml
vendored
@@ -58,6 +58,7 @@ jobs:
|
|||||||
APPLE_ID: ${{ secrets.APPLE_ID }}
|
APPLE_ID: ${{ secrets.APPLE_ID }}
|
||||||
APPLE_ID_PASSWORD: ${{ secrets.APPLE_ID_PASSWORD }}
|
APPLE_ID_PASSWORD: ${{ secrets.APPLE_ID_PASSWORD }}
|
||||||
WINDOWS_SIGN_EXECUTABLE: ${{ vars.WINDOWS_SIGN_EXECUTABLE }}
|
WINDOWS_SIGN_EXECUTABLE: ${{ vars.WINDOWS_SIGN_EXECUTABLE }}
|
||||||
|
GPG_SIGNING_KEY: ${{ secrets.GPG_SIGN_KEY }}
|
||||||
|
|
||||||
- name: Upload the artifact
|
- name: Upload the artifact
|
||||||
uses: actions/upload-artifact@v4
|
uses: actions/upload-artifact@v4
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||

|

|
||||||
[](https://app.relative-ci.com/projects/Di5q7dz9daNDZ9UXi0Bp) [](https://hosted.weblate.org/engage/trilium/)
|
[](https://app.relative-ci.com/projects/Di5q7dz9daNDZ9UXi0Bp) [](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.
|
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
|
### 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
|
## 💻 Contribute
|
||||||
|
|||||||
@@ -55,8 +55,7 @@
|
|||||||
"split.js": "1.6.5",
|
"split.js": "1.6.5",
|
||||||
"svg-pan-zoom": "3.6.2",
|
"svg-pan-zoom": "3.6.2",
|
||||||
"tabulator-tables": "6.3.1",
|
"tabulator-tables": "6.3.1",
|
||||||
"vanilla-js-wheel-zoom": "9.0.4",
|
"vanilla-js-wheel-zoom": "9.0.4"
|
||||||
"photoswipe": "^5.4.4"
|
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@ckeditor/ckeditor5-inspector": "5.0.0",
|
"@ckeditor/ckeditor5-inspector": "5.0.0",
|
||||||
|
|||||||
@@ -13,8 +13,6 @@ import type ElectronRemote from "@electron/remote";
|
|||||||
import type Electron from "electron";
|
import type Electron from "electron";
|
||||||
import "./stylesheets/bootstrap.scss";
|
import "./stylesheets/bootstrap.scss";
|
||||||
import "boxicons/css/boxicons.min.css";
|
import "boxicons/css/boxicons.min.css";
|
||||||
import "./stylesheets/media-viewer.css";
|
|
||||||
import "./styles/gallery.css";
|
|
||||||
import "autocomplete.js/index_jquery.js";
|
import "autocomplete.js/index_jquery.js";
|
||||||
|
|
||||||
await appContext.earlyInit();
|
await appContext.earlyInit();
|
||||||
|
|||||||
@@ -2,8 +2,6 @@ import { t } from "../services/i18n.js";
|
|||||||
import utils from "../services/utils.js";
|
import utils from "../services/utils.js";
|
||||||
import contextMenu from "./context_menu.js";
|
import contextMenu from "./context_menu.js";
|
||||||
import imageService from "../services/image.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";
|
const PROP_NAME = "imageContextMenuInstalled";
|
||||||
|
|
||||||
@@ -20,12 +18,6 @@ function setupContextMenu($image: JQuery<HTMLElement>) {
|
|||||||
x: e.pageX,
|
x: e.pageX,
|
||||||
y: e.pageY,
|
y: e.pageY,
|
||||||
items: [
|
items: [
|
||||||
{
|
|
||||||
title: "View in Lightbox",
|
|
||||||
command: "viewInLightbox",
|
|
||||||
uiIcon: "bx bx-expand",
|
|
||||||
enabled: true
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
title: t("image_context_menu.copy_reference_to_clipboard"),
|
title: t("image_context_menu.copy_reference_to_clipboard"),
|
||||||
command: "copyImageReferenceToClipboard",
|
command: "copyImageReferenceToClipboard",
|
||||||
@@ -38,48 +30,7 @@ function setupContextMenu($image: JQuery<HTMLElement>) {
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
selectMenuItemHandler: async ({ command }) => {
|
selectMenuItemHandler: async ({ command }) => {
|
||||||
if (command === "viewInLightbox") {
|
if (command === "copyImageReferenceToClipboard") {
|
||||||
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") {
|
|
||||||
imageService.copyImageReferenceToClipboard($image);
|
imageService.copyImageReferenceToClipboard($image);
|
||||||
} else if (command === "copyImageToClipboard") {
|
} else if (command === "copyImageToClipboard") {
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ import noteAutocompleteService from "./services/note_autocomplete.js";
|
|||||||
import glob from "./services/glob.js";
|
import glob from "./services/glob.js";
|
||||||
import "./stylesheets/bootstrap.scss";
|
import "./stylesheets/bootstrap.scss";
|
||||||
import "boxicons/css/boxicons.min.css";
|
import "boxicons/css/boxicons.min.css";
|
||||||
import "./stylesheets/media-viewer.css";
|
|
||||||
import "autocomplete.js/index_jquery.js";
|
import "autocomplete.js/index_jquery.js";
|
||||||
|
|
||||||
glob.setupGlobs();
|
glob.setupGlobs();
|
||||||
|
|||||||
@@ -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();
|
|
||||||
@@ -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);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -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();
|
|
||||||
@@ -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();
|
|
||||||
@@ -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();
|
|
||||||
@@ -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();
|
|
||||||
@@ -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
|
|
||||||
};
|
|
||||||
@@ -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();
|
|
||||||
@@ -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();
|
|
||||||
@@ -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();
|
|
||||||
@@ -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
@@ -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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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);
|
|
||||||
}
|
|
||||||
@@ -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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -967,7 +967,7 @@
|
|||||||
},
|
},
|
||||||
"protected_session": {
|
"protected_session": {
|
||||||
"enter_password_instruction": "显示受保护的笔记需要输入您的密码:",
|
"enter_password_instruction": "显示受保护的笔记需要输入您的密码:",
|
||||||
"start_session_button": "开始受保护的会话",
|
"start_session_button": "开始受保护的会话 <kbd>Enter</kbd>",
|
||||||
"started": "受保护的会话已启动。",
|
"started": "受保护的会话已启动。",
|
||||||
"wrong_password": "密码错误。",
|
"wrong_password": "密码错误。",
|
||||||
"protecting-finished-successfully": "保护操作已成功完成。",
|
"protecting-finished-successfully": "保护操作已成功完成。",
|
||||||
@@ -1028,7 +1028,7 @@
|
|||||||
"error_creating_anonymized_database": "无法创建匿名化数据库,请检查后端日志以获取详细信息",
|
"error_creating_anonymized_database": "无法创建匿名化数据库,请检查后端日志以获取详细信息",
|
||||||
"successfully_created_fully_anonymized_database": "成功创建完全匿名化的数据库,路径为 {{anonymizedFilePath}}",
|
"successfully_created_fully_anonymized_database": "成功创建完全匿名化的数据库,路径为 {{anonymizedFilePath}}",
|
||||||
"successfully_created_lightly_anonymized_database": "成功创建轻度匿名化的数据库,路径为 {{anonymizedFilePath}}",
|
"successfully_created_lightly_anonymized_database": "成功创建轻度匿名化的数据库,路径为 {{anonymizedFilePath}}",
|
||||||
"no_anonymized_database_yet": "尚无匿名化数据库"
|
"no_anonymized_database_yet": "尚无匿名化数据库。"
|
||||||
},
|
},
|
||||||
"database_integrity_check": {
|
"database_integrity_check": {
|
||||||
"title": "数据库完整性检查",
|
"title": "数据库完整性检查",
|
||||||
@@ -1333,9 +1333,9 @@
|
|||||||
"oauth_title": "OAuth/OpenID 认证",
|
"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": "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_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_account": "用户账号: ",
|
||||||
"oauth_user_email": "用户邮箱:",
|
"oauth_user_email": "用户邮箱: ",
|
||||||
"oauth_user_not_logged_in": "未登录!"
|
"oauth_user_not_logged_in": "未登录!"
|
||||||
},
|
},
|
||||||
"shortcuts": {
|
"shortcuts": {
|
||||||
@@ -1357,7 +1357,7 @@
|
|||||||
"enable": "启用拼写检查",
|
"enable": "启用拼写检查",
|
||||||
"language_code_label": "语言代码",
|
"language_code_label": "语言代码",
|
||||||
"language_code_placeholder": "例如 \"en-US\", \"de-AT\"",
|
"language_code_placeholder": "例如 \"en-US\", \"de-AT\"",
|
||||||
"multiple_languages_info": "多种语言可以用逗号分隔,例如 \"en-US, de-DE, cs\"。",
|
"multiple_languages_info": "多种语言可以用逗号分隔,例如 \"en-US, de-DE, cs\"。 ",
|
||||||
"available_language_codes_label": "可用的语言代码:",
|
"available_language_codes_label": "可用的语言代码:",
|
||||||
"restart-required": "拼写检查选项的更改将在应用重启后生效。"
|
"restart-required": "拼写检查选项的更改将在应用重启后生效。"
|
||||||
},
|
},
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -55,7 +55,11 @@
|
|||||||
"show_help": "ヘルプを表示",
|
"show_help": "ヘルプを表示",
|
||||||
"about": "Trilium Notesについて",
|
"about": "Trilium Notesについて",
|
||||||
"logout": "ログアウト",
|
"logout": "ログアウト",
|
||||||
"show-cheatsheet": "チートシートを表示"
|
"show-cheatsheet": "チートシートを表示",
|
||||||
|
"zoom_out": "ズームアウト",
|
||||||
|
"zoom_in": "ズームイン",
|
||||||
|
"advanced": "高度",
|
||||||
|
"toggle-zen-mode": "禅モード"
|
||||||
},
|
},
|
||||||
"left_pane_toggle": {
|
"left_pane_toggle": {
|
||||||
"show_panel": "パネルを表示",
|
"show_panel": "パネルを表示",
|
||||||
@@ -66,23 +70,26 @@
|
|||||||
"move_right": "右に移動"
|
"move_right": "右に移動"
|
||||||
},
|
},
|
||||||
"clone_to": {
|
"clone_to": {
|
||||||
"notes_to_clone": "複製するノート",
|
"notes_to_clone": "クローンするノート",
|
||||||
"target_parent_note": "ターゲットの親ノート",
|
"target_parent_note": "ターゲットの親ノート",
|
||||||
"search_for_note_by_its_name": "ノート名で検索",
|
"search_for_note_by_its_name": "ノート名で検索",
|
||||||
"cloned_note_prefix_title": "複製されたノートは、指定された接頭辞を付けてノートツリーに表示されます",
|
"cloned_note_prefix_title": "クローンされたノートは、指定された接頭辞を付けてノートツリーに表示されます",
|
||||||
"prefix_optional": "接頭辞(任意)",
|
"prefix_optional": "接頭辞(任意)",
|
||||||
"clone_to_selected_note": "選択したノートに複製",
|
"clone_to_selected_note": "選択したノートにクローン",
|
||||||
"no_path_to_clone_to": "複製先のパスが存在しません。",
|
"no_path_to_clone_to": "クローン先のパスが存在しません。",
|
||||||
"note_cloned": "ノート \"{{clonedTitle}}\" は \"{{targetTitle}}\" に複製されました"
|
"note_cloned": "ノート \"{{clonedTitle}}\" は \"{{targetTitle}}\" にクローンされました",
|
||||||
|
"clone_notes_to": "ノートをクローンして...",
|
||||||
|
"help_on_links": "ヘルプへのリンク"
|
||||||
},
|
},
|
||||||
"delete_notes": {
|
"delete_notes": {
|
||||||
"delete_all_clones_description": "すべての複製も削除する(最近の変更では元に戻すことができる)",
|
"delete_all_clones_description": "すべてのクローンも削除する(最近の変更では元に戻すことができる)",
|
||||||
"erase_notes_description": "通常の(ソフト)削除では、ノートは削除されたものとしてマークされ、一定期間内に(最近の変更で)削除を取り消すことができます。このオプションをオンにすると、ノートは即座に削除され、削除を取り消すことはできません。",
|
"erase_notes_description": "通常の(ソフト)削除では、ノートは削除されたものとしてマークされ、一定期間内に(最近の変更で)削除を取り消すことができます。このオプションをオンにすると、ノートは即座に削除され、削除を取り消すことはできません。",
|
||||||
"erase_notes_warning": "すべての複製を含め、ノートを完全に消去します(元に戻せません)。これにより、アプリケーションは強制的にリロードされます。",
|
"erase_notes_warning": "すべてのクローンを含め、ノートを完全に消去します(元に戻せません)。これにより、アプリケーションは強制的にリロードされます。",
|
||||||
"notes_to_be_deleted": "以下のノートが削除されます ({{notesCount}})",
|
"notes_to_be_deleted": "以下のノートが削除されます ({{notesCount}})",
|
||||||
"no_note_to_delete": "ノートは削除されません(複製のみ)。",
|
"no_note_to_delete": "ノートは削除されません(クローンのみ)。",
|
||||||
"cancel": "キャンセル",
|
"cancel": "キャンセル",
|
||||||
"ok": "OK"
|
"ok": "OK",
|
||||||
|
"close": "閉じる"
|
||||||
},
|
},
|
||||||
"calendar": {
|
"calendar": {
|
||||||
"mon": "月",
|
"mon": "月",
|
||||||
@@ -113,14 +120,24 @@
|
|||||||
},
|
},
|
||||||
"basic_properties": {
|
"basic_properties": {
|
||||||
"note_type": "ノートタイプ",
|
"note_type": "ノートタイプ",
|
||||||
"editable": "編集可能"
|
"editable": "編集可能",
|
||||||
|
"basic_properties": "基本プロパティ",
|
||||||
|
"language": "言語"
|
||||||
},
|
},
|
||||||
"i18n": {
|
"i18n": {
|
||||||
"title": "ローカライゼーション",
|
"title": "ローカライゼーション",
|
||||||
"language": "言語",
|
"language": "言語",
|
||||||
"first-day-of-the-week": "週の最初",
|
"first-day-of-the-week": "週の最初",
|
||||||
"sunday": "日曜日",
|
"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": {
|
"tab_row": {
|
||||||
"close_tab": "タブを閉じる",
|
"close_tab": "タブを閉じる",
|
||||||
@@ -159,7 +176,11 @@
|
|||||||
"action": "アクション",
|
"action": "アクション",
|
||||||
"search_button": "検索 <kbd>Enter</kbd>",
|
"search_button": "検索 <kbd>Enter</kbd>",
|
||||||
"search_execute": "検索とアクションの実行",
|
"search_execute": "検索とアクションの実行",
|
||||||
"save_to_note": "ノートに保存"
|
"save_to_note": "ノートに保存",
|
||||||
|
"search_parameters": "検索パラメータ",
|
||||||
|
"unknown_search_option": "不明な検索オプション {{searchOptionName}}",
|
||||||
|
"search_note_saved": "検索ノートが {{- notePathTitle}} に保存されました",
|
||||||
|
"actions_executed": "アクションが実行されました。"
|
||||||
},
|
},
|
||||||
"shortcuts": {
|
"shortcuts": {
|
||||||
"multiple_shortcuts": "同じアクションに対して複数のショートカットを設定する場合、カンマで区切ることができます。",
|
"multiple_shortcuts": "同じアクションに対して複数のショートカットを設定する場合、カンマで区切ることができます。",
|
||||||
@@ -171,6 +192,675 @@
|
|||||||
"description": "説明",
|
"description": "説明",
|
||||||
"reload_app": "リロードして変更を適用する",
|
"reload_app": "リロードして変更を適用する",
|
||||||
"set_all_to_default": "すべてのショートカットをデフォルトに戻す",
|
"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": "変更を保存して適用"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -249,7 +249,7 @@
|
|||||||
},
|
},
|
||||||
"prompt": {
|
"prompt": {
|
||||||
"title": "Prompt",
|
"title": "Prompt",
|
||||||
"ok": "OK <kbd>enter</kbd>",
|
"ok": "OK",
|
||||||
"defaultTitle": "Prompt"
|
"defaultTitle": "Prompt"
|
||||||
},
|
},
|
||||||
"protected_session_password": {
|
"protected_session_password": {
|
||||||
@@ -257,7 +257,7 @@
|
|||||||
"help_title": "Ajuda sobre notas protegidas",
|
"help_title": "Ajuda sobre notas protegidas",
|
||||||
"close_label": "Fechar",
|
"close_label": "Fechar",
|
||||||
"form_label": "Para prosseguir com a ação solicitada, você precisa iniciar uma sessão protegida digitando a senha:",
|
"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": {
|
"recent_changes": {
|
||||||
"title": "Alterações recentes",
|
"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.",
|
"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",
|
"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.",
|
"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": {
|
||||||
"upload_attachments_to_note": "Enviar anexos para a nota",
|
"upload_attachments_to_note": "Enviar anexos para a nota",
|
||||||
"choose_files": "Escolher arquivos",
|
"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",
|
"options": "Opções",
|
||||||
"shrink_images": "Reduzir imagens",
|
"shrink_images": "Reduzir imagens",
|
||||||
"upload": "Enviar",
|
"upload": "Enviar",
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -9,9 +9,6 @@ import contentRenderer from "../services/content_renderer.js";
|
|||||||
import toastService from "../services/toast.js";
|
import toastService from "../services/toast.js";
|
||||||
import type FAttachment from "../entities/fattachment.js";
|
import type FAttachment from "../entities/fattachment.js";
|
||||||
import type { EventData } from "../components/app_context.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*/`
|
const TPL = /*html*/`
|
||||||
<div class="attachment-detail-widget">
|
<div class="attachment-detail-widget">
|
||||||
@@ -68,12 +65,6 @@ const TPL = /*html*/`
|
|||||||
|
|
||||||
.attachment-content-wrapper img {
|
.attachment-content-wrapper img {
|
||||||
margin: 10px;
|
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 {
|
.attachment-detail-wrapper.list-view .attachment-content-wrapper img, .attachment-detail-wrapper.list-view .attachment-content-wrapper video {
|
||||||
@@ -86,24 +77,6 @@ const TPL = /*html*/`
|
|||||||
max-width: 90%;
|
max-width: 90%;
|
||||||
object-fit: contain;
|
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 {
|
.attachment-detail-wrapper.scheduled-for-deletion .attachment-content-wrapper img {
|
||||||
filter: contrast(10%);
|
filter: contrast(10%);
|
||||||
@@ -115,9 +88,7 @@ const TPL = /*html*/`
|
|||||||
<div class="attachment-actions-container"></div>
|
<div class="attachment-actions-container"></div>
|
||||||
<h4 class="attachment-title"></h4>
|
<h4 class="attachment-title"></h4>
|
||||||
<div class="attachment-details"></div>
|
<div class="attachment-details"></div>
|
||||||
<button class="btn btn-sm back-to-note-btn" style="margin-left: auto;" title="Back to Note">
|
<div style="flex: 1 1;"></div>
|
||||||
<span class="bx bx-arrow-back"></span> Back to Note
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="attachment-deletion-warning alert alert-info" style="margin-top: 15px;"></div>
|
<div class="attachment-deletion-warning alert alert-info" style="margin-top: 15px;"></div>
|
||||||
@@ -153,14 +124,6 @@ export default class AttachmentDetailWidget extends BasicWidget {
|
|||||||
this.$widget.find(".attachment-detail-wrapper").empty().append($(TPL).find(".attachment-detail-wrapper").html());
|
this.$widget.find(".attachment-detail-wrapper").empty().append($(TPL).find(".attachment-detail-wrapper").html());
|
||||||
this.$wrapper = this.$widget.find(".attachment-detail-wrapper");
|
this.$wrapper = this.$widget.find(".attachment-detail-wrapper");
|
||||||
this.$wrapper.addClass(this.isFullDetail ? "full-detail" : "list-view");
|
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) {
|
if (!this.isFullDetail) {
|
||||||
const $link = await linkService.createLink(this.attachment.ownerId, {
|
const $link = await linkService.createLink(this.attachment.ownerId, {
|
||||||
@@ -207,92 +170,7 @@ export default class AttachmentDetailWidget extends BasicWidget {
|
|||||||
this.$wrapper.find(".attachment-actions-container").append(this.attachmentActionsWidget.render());
|
this.$wrapper.find(".attachment-actions-container").append(this.attachmentActionsWidget.render());
|
||||||
|
|
||||||
const { $renderedContent } = await contentRenderer.getRenderedContent(this.attachment, { imageHasZoom: this.isFullDetail });
|
const { $renderedContent } = await contentRenderer.getRenderedContent(this.attachment, { imageHasZoom: this.isFullDetail });
|
||||||
const $contentWrapper = this.$wrapper.find(".attachment-content-wrapper");
|
this.$wrapper.find(".attachment-content-wrapper").append($renderedContent);
|
||||||
$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');
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async copyAttachmentLinkToClipboard() {
|
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();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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 = ``;
|
|
||||||
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;
|
|
||||||
@@ -6,7 +6,6 @@ import contentRenderer from "../../services/content_renderer.js";
|
|||||||
import utils from "../../services/utils.js";
|
import utils from "../../services/utils.js";
|
||||||
import options from "../../services/options.js";
|
import options from "../../services/options.js";
|
||||||
import attributes from "../../services/attributes.js";
|
import attributes from "../../services/attributes.js";
|
||||||
import ckeditorPhotoswipeIntegration from "../../services/ckeditor_photoswipe_integration.js";
|
|
||||||
|
|
||||||
export default class AbstractTextTypeWidget extends TypeWidget {
|
export default class AbstractTextTypeWidget extends TypeWidget {
|
||||||
doRender() {
|
doRender() {
|
||||||
@@ -36,29 +35,7 @@ export default class AbstractTextTypeWidget extends TypeWidget {
|
|||||||
const parsedImage = await this.parseFromImage($img);
|
const parsedImage = await this.parseFromImage($img);
|
||||||
|
|
||||||
if (parsedImage) {
|
if (parsedImage) {
|
||||||
// Check if this is an attachment image and PhotoSwipe is available
|
appContext.tabManager.getActiveContext()?.setNote(parsedImage.noteId, { viewScope: parsedImage.viewScope });
|
||||||
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 {
|
} else {
|
||||||
window.open($img.prop("src"), "_blank");
|
window.open($img.prop("src"), "_blank");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,8 +4,6 @@ import linkService from "../../services/link.js";
|
|||||||
import utils from "../../services/utils.js";
|
import utils from "../../services/utils.js";
|
||||||
import { t } from "../../services/i18n.js";
|
import { t } from "../../services/i18n.js";
|
||||||
import type { EventData } from "../../components/app_context.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*/`
|
const TPL = /*html*/`
|
||||||
<div class="attachment-list note-detail-printable">
|
<div class="attachment-list note-detail-printable">
|
||||||
@@ -22,81 +20,17 @@ const TPL = /*html*/`
|
|||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
align-items: baseline;
|
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>
|
</style>
|
||||||
|
|
||||||
<div class="links-wrapper"></div>
|
<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 class="attachment-list-wrapper"></div>
|
||||||
</div>`;
|
</div>`;
|
||||||
|
|
||||||
export default class AttachmentListTypeWidget extends TypeWidget {
|
export default class AttachmentListTypeWidget extends TypeWidget {
|
||||||
$list!: JQuery<HTMLElement>;
|
$list!: JQuery<HTMLElement>;
|
||||||
$linksWrapper!: JQuery<HTMLElement>;
|
$linksWrapper!: JQuery<HTMLElement>;
|
||||||
$galleryToolbar!: JQuery<HTMLElement>;
|
|
||||||
$imageGrid!: JQuery<HTMLElement>;
|
|
||||||
renderedAttachmentIds!: Set<string>;
|
renderedAttachmentIds!: Set<string>;
|
||||||
imageAttachments: GalleryItem[] = [];
|
|
||||||
otherAttachments: any[] = [];
|
|
||||||
|
|
||||||
static getType() {
|
static getType() {
|
||||||
return "attachmentList";
|
return "attachmentList";
|
||||||
@@ -106,8 +40,6 @@ export default class AttachmentListTypeWidget extends TypeWidget {
|
|||||||
this.$widget = $(TPL);
|
this.$widget = $(TPL);
|
||||||
this.$list = this.$widget.find(".attachment-list-wrapper");
|
this.$list = this.$widget.find(".attachment-list-wrapper");
|
||||||
this.$linksWrapper = this.$widget.find(".links-wrapper");
|
this.$linksWrapper = this.$widget.find(".links-wrapper");
|
||||||
this.$galleryToolbar = this.$widget.find(".gallery-toolbar");
|
|
||||||
this.$imageGrid = this.$widget.find(".image-grid");
|
|
||||||
|
|
||||||
super.doRender();
|
super.doRender();
|
||||||
}
|
}
|
||||||
@@ -143,12 +75,8 @@ export default class AttachmentListTypeWidget extends TypeWidget {
|
|||||||
);
|
);
|
||||||
|
|
||||||
this.$list.empty();
|
this.$list.empty();
|
||||||
this.$imageGrid.empty().hide();
|
|
||||||
this.$galleryToolbar.empty().hide();
|
|
||||||
this.children = [];
|
this.children = [];
|
||||||
this.renderedAttachmentIds = new Set();
|
this.renderedAttachmentIds = new Set();
|
||||||
this.imageAttachments = [];
|
|
||||||
this.otherAttachments = [];
|
|
||||||
|
|
||||||
const attachments = await note.getAttachments();
|
const attachments = await note.getAttachments();
|
||||||
|
|
||||||
@@ -157,122 +85,17 @@ export default class AttachmentListTypeWidget extends TypeWidget {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Separate image and non-image attachments
|
|
||||||
for (const attachment of 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);
|
const attachmentDetailWidget = new AttachmentDetailWidget(attachment, false);
|
||||||
|
|
||||||
this.child(attachmentDetailWidget);
|
this.child(attachmentDetailWidget);
|
||||||
|
|
||||||
this.renderedAttachmentIds.add(attachment.attachmentId);
|
this.renderedAttachmentIds.add(attachment.attachmentId);
|
||||||
|
|
||||||
this.$list.append(attachmentDetailWidget.render());
|
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">) {
|
async entitiesReloadedEvent({ loadResults }: EventData<"entitiesReloaded">) {
|
||||||
// updates and deletions are handled by the detail, for new attachments the whole list has to be refreshed
|
// 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));
|
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();
|
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();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 { PopupEditor, ClassicEditor, EditorWatchdog, type CKTextEditor, type MentionFeed, type WatchdogConfig, EditorConfig } from "@triliumnext/ckeditor5";
|
||||||
import "@triliumnext/ckeditor5/index.css";
|
import "@triliumnext/ckeditor5/index.css";
|
||||||
import { updateTemplateCache } from "./ckeditor/snippets.js";
|
import { updateTemplateCache } from "./ckeditor/snippets.js";
|
||||||
import ckeditorPhotoSwipe from "../../services/ckeditor_photoswipe_integration.js";
|
|
||||||
|
|
||||||
const TPL = /*html*/`
|
const TPL = /*html*/`
|
||||||
<div class="note-detail-editable-text note-detail-printable">
|
<div class="note-detail-editable-text note-detail-printable">
|
||||||
@@ -163,19 +162,6 @@ export default class EditableTextTypeWidget extends AbstractTextTypeWidget {
|
|||||||
isClassicEditor
|
isClassicEditor
|
||||||
};
|
};
|
||||||
const editor = await buildEditor(this.$editor[0], isClassicEditor, opts);
|
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");
|
const notificationsPlugin = editor.plugins.get("Notification");
|
||||||
notificationsPlugin.on("show:warning", (evt, data) => {
|
notificationsPlugin.on("show:warning", (evt, data) => {
|
||||||
@@ -305,25 +291,11 @@ export default class EditableTextTypeWidget extends AbstractTextTypeWidget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
cleanup() {
|
cleanup() {
|
||||||
// Cleanup PhotoSwipe integration
|
|
||||||
if (this.$editor?.[0]) {
|
|
||||||
ckeditorPhotoSwipe.cleanupContainer(this.$editor[0]);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (this.watchdog?.editor) {
|
if (this.watchdog?.editor) {
|
||||||
this.spacedUpdate.allowUpdateWithoutChange(() => {
|
this.spacedUpdate.allowUpdateWithoutChange(() => {
|
||||||
this.watchdog.editor?.setData("");
|
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() {
|
insertDateTimeToTextCommand() {
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import openService from "../../services/open.js";
|
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 { t } from "../../services/i18n.js";
|
||||||
import type { EventData } from "../../components/app_context.js";
|
import type { EventData } from "../../components/app_context.js";
|
||||||
import type FNote from "../../entities/fnote.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="pdf"],
|
||||||
.note-detail.full-height .note-detail-file[data-preview-type="video"],
|
.note-detail.full-height .note-detail-file[data-preview-type="video"] {
|
||||||
.note-detail.full-height .note-detail-file[data-preview-type="image"] {
|
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -40,133 +39,6 @@ const TPL = /*html*/`
|
|||||||
width: 100%;
|
width: 100%;
|
||||||
height: 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>
|
</style>
|
||||||
|
|
||||||
<div class="file-preview-too-big alert alert-info hidden-ext">
|
<div class="file-preview-too-big alert alert-info hidden-ext">
|
||||||
@@ -184,66 +56,21 @@ const TPL = /*html*/`
|
|||||||
<video class="video-preview" controls></video>
|
<video class="video-preview" controls></video>
|
||||||
|
|
||||||
<audio class="audio-preview" controls></audio>
|
<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>`;
|
</div>`;
|
||||||
|
|
||||||
export default class FileTypeWidget extends ImageViewerBase {
|
export default class FileTypeWidget extends TypeWidget {
|
||||||
|
|
||||||
private $previewContent!: JQuery<HTMLElement>;
|
private $previewContent!: JQuery<HTMLElement>;
|
||||||
private $previewNotAvailable!: JQuery<HTMLElement>;
|
private $previewNotAvailable!: JQuery<HTMLElement>;
|
||||||
private $previewTooBig!: JQuery<HTMLElement>;
|
private $previewTooBig!: JQuery<HTMLElement>;
|
||||||
private $pdfPreview!: JQuery<HTMLElement>;
|
private $pdfPreview!: JQuery<HTMLElement>;
|
||||||
private $videoPreview!: JQuery<HTMLElement>;
|
private $videoPreview!: JQuery<HTMLElement>;
|
||||||
private $audioPreview!: 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() {
|
static getType() {
|
||||||
return "file";
|
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() {
|
doRender() {
|
||||||
this.$widget = $(TPL);
|
this.$widget = $(TPL);
|
||||||
this.$previewContent = this.$widget.find(".file-preview-content");
|
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.$pdfPreview = this.$widget.find(".pdf-preview");
|
||||||
this.$videoPreview = this.$widget.find(".video-preview");
|
this.$videoPreview = this.$widget.find(".video-preview");
|
||||||
this.$audioPreview = this.$widget.find(".audio-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();
|
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) {
|
async doRefresh(note: FNote) {
|
||||||
this.$widget?.show();
|
this.$widget.show();
|
||||||
|
|
||||||
const blob = await this.note?.getBlob();
|
const blob = await this.note?.getBlob();
|
||||||
|
|
||||||
// Hide all preview types
|
this.$previewContent.empty().hide();
|
||||||
this.$previewContent?.empty().hide();
|
this.$pdfPreview.attr("src", "").empty().hide();
|
||||||
this.$pdfPreview?.attr("src", "").empty().hide();
|
this.$previewNotAvailable.hide();
|
||||||
this.$previewNotAvailable?.hide();
|
this.$previewTooBig.addClass("hidden-ext");
|
||||||
this.$previewTooBig?.addClass("hidden-ext");
|
this.$videoPreview.hide();
|
||||||
this.$videoPreview?.hide();
|
this.$audioPreview.hide();
|
||||||
this.$audioPreview?.hide();
|
|
||||||
this.$imageFilePreview?.hide();
|
|
||||||
|
|
||||||
let previewType: string;
|
let previewType: string;
|
||||||
|
|
||||||
// Check if this is an image file
|
if (blob?.content) {
|
||||||
if (note.mime.startsWith("image/")) {
|
this.$previewContent.show().scrollTop(0);
|
||||||
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);
|
|
||||||
const trimmedContent = blob.content.substring(0, TEXT_MAX_NUM_CHARS);
|
const trimmedContent = blob.content.substring(0, TEXT_MAX_NUM_CHARS);
|
||||||
if (trimmedContent.length !== blob.content.length) {
|
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";
|
previewType = "text";
|
||||||
} else if (note.mime === "application/pdf") {
|
} 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";
|
previewType = "pdf";
|
||||||
} else if (note.mime.startsWith("video/")) {
|
} else if (note.mime.startsWith("video/")) {
|
||||||
this.$videoPreview
|
this.$videoPreview
|
||||||
?.show()
|
.show()
|
||||||
.attr("src", openService.getUrlForDownload(`api/notes/${this.noteId}/open-partial`))
|
.attr("src", openService.getUrlForDownload(`api/notes/${this.noteId}/open-partial`))
|
||||||
.attr("type", this.note?.mime ?? "")
|
.attr("type", this.note?.mime ?? "")
|
||||||
.css("width", this.$widget?.width() ?? 0);
|
.css("width", this.$widget.width() ?? 0);
|
||||||
previewType = "video";
|
previewType = "video";
|
||||||
} else if (note.mime.startsWith("audio/")) {
|
} else if (note.mime.startsWith("audio/")) {
|
||||||
this.$audioPreview
|
this.$audioPreview
|
||||||
?.show()
|
.show()
|
||||||
.attr("src", openService.getUrlForDownload(`api/notes/${this.noteId}/open-partial`))
|
.attr("src", openService.getUrlForDownload(`api/notes/${this.noteId}/open-partial`))
|
||||||
.attr("type", this.note?.mime ?? "")
|
.attr("type", this.note?.mime ?? "")
|
||||||
.css("width", this.$widget?.width() ?? 0);
|
.css("width", this.$widget.width() ?? 0);
|
||||||
previewType = "audio";
|
previewType = "audio";
|
||||||
} else {
|
} else {
|
||||||
this.$previewNotAvailable?.show();
|
this.$previewNotAvailable.show();
|
||||||
previewType = "not-available";
|
previewType = "not-available";
|
||||||
}
|
}
|
||||||
|
|
||||||
this.currentPreviewType = previewType;
|
this.$widget.attr("data-preview-type", 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.");
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async entitiesReloadedEvent({ loadResults }: EventData<"entitiesReloaded">) {
|
async entitiesReloadedEvent({ loadResults }: EventData<"entitiesReloaded">) {
|
||||||
if (loadResults.isNoteReloaded(this.noteId)) {
|
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();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
import utils from "../../services/utils.js";
|
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 imageService from "../../services/image.js";
|
||||||
import type FNote from "../../entities/fnote.js";
|
import type FNote from "../../entities/fnote.js";
|
||||||
import type { EventData } from "../../components/app_context.js";
|
import type { EventData } from "../../components/app_context.js";
|
||||||
|
import WheelZoom from 'vanilla-js-wheel-zoom';
|
||||||
|
|
||||||
const TPL = /*html*/`
|
const TPL = /*html*/`
|
||||||
<div class="note-detail-image note-detail-printable">
|
<div class="note-detail-image note-detail-printable">
|
||||||
@@ -13,7 +15,6 @@ const TPL = /*html*/`
|
|||||||
|
|
||||||
.note-detail-image {
|
.note-detail-image {
|
||||||
height: 100%;
|
height: 100%;
|
||||||
position: relative;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.note-detail-image-wrapper {
|
.note-detail-image-wrapper {
|
||||||
@@ -27,314 +28,53 @@ const TPL = /*html*/`
|
|||||||
|
|
||||||
.note-detail-image-view {
|
.note-detail-image-view {
|
||||||
display: block;
|
display: block;
|
||||||
max-width: 100%;
|
|
||||||
max-height: 100%;
|
|
||||||
width: auto;
|
width: auto;
|
||||||
height: auto;
|
height: auto;
|
||||||
align-self: center;
|
align-self: center;
|
||||||
flex-shrink: 0;
|
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>
|
</style>
|
||||||
|
|
||||||
<div class="note-detail-image-wrapper">
|
<div class="note-detail-image-wrapper">
|
||||||
<img class="note-detail-image-view" />
|
<img class="note-detail-image-view" />
|
||||||
</div>
|
</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>`;
|
</div>`;
|
||||||
|
|
||||||
class ImageTypeWidget extends ImageViewerBase {
|
class ImageTypeWidget extends TypeWidget {
|
||||||
private $zoomInBtn!: JQuery<HTMLElement>;
|
|
||||||
private $zoomOutBtn!: JQuery<HTMLElement>;
|
private $imageWrapper!: JQuery<HTMLElement>;
|
||||||
private $resetZoomBtn!: JQuery<HTMLElement>;
|
private $imageView!: JQuery<HTMLElement>;
|
||||||
private $fullscreenBtn!: JQuery<HTMLElement>;
|
|
||||||
private $downloadBtn!: JQuery<HTMLElement>;
|
|
||||||
private wheelHandler?: (e: JQuery.TriggeredEvent) => void;
|
|
||||||
|
|
||||||
static getType() {
|
static getType() {
|
||||||
return "image";
|
return "image";
|
||||||
}
|
}
|
||||||
|
|
||||||
constructor() {
|
|
||||||
super();
|
|
||||||
// Apply custom configuration if needed
|
|
||||||
this.applyConfig({
|
|
||||||
minZoom: 0.5,
|
|
||||||
maxZoom: 5,
|
|
||||||
zoomStep: 0.25,
|
|
||||||
debounceDelay: 16,
|
|
||||||
touchTargetSize: 44
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
doRender() {
|
doRender() {
|
||||||
this.$widget = $(TPL);
|
this.$widget = $(TPL);
|
||||||
this.$imageWrapper = this.$widget.find(".note-detail-image-wrapper");
|
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);
|
|
||||||
|
|
||||||
// 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();
|
const initZoom = async () => {
|
||||||
this.setupPanFunctionality();
|
const element = document.querySelector(`#${this.$imageView.attr("id")}`);
|
||||||
this.setupKeyboardNavigation();
|
if (element) {
|
||||||
this.setupDoubleClickReset();
|
WheelZoom.create(`#${this.$imageView.attr("id")}`, {
|
||||||
this.setupContextMenu();
|
maxScale: 50,
|
||||||
this.addAccessibilityLabels();
|
speed: 1.3,
|
||||||
|
zoomOnClick: false
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
requestAnimationFrame(initZoom);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
initZoom();
|
||||||
|
|
||||||
|
imageContextMenuService.setupContextMenu(this.$imageView);
|
||||||
|
|
||||||
super.doRender();
|
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) {
|
async doRefresh(note: FNote) {
|
||||||
const src = utils.createImageSrcUrl(note);
|
this.$imageView.prop("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.");
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
copyImageReferenceToClipboardEvent({ ntxId }: EventData<"copyImageReferenceToClipboard">) {
|
copyImageReferenceToClipboardEvent({ ntxId }: EventData<"copyImageReferenceToClipboard">) {
|
||||||
@@ -342,26 +82,14 @@ class ImageTypeWidget extends ImageViewerBase {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (this.$imageWrapper?.length) {
|
imageService.copyImageReferenceToClipboard(this.$imageWrapper);
|
||||||
imageService.copyImageReferenceToClipboard(this.$imageWrapper);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async entitiesReloadedEvent({ loadResults }: EventData<"entitiesReloaded">) {
|
async entitiesReloadedEvent({ loadResults }: EventData<"entitiesReloaded">) {
|
||||||
if (loadResults.isNoteReloaded(this.noteId)) {
|
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;
|
export default ImageTypeWidget;
|
||||||
|
|||||||
@@ -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();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -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;
|
|
||||||
@@ -6,7 +6,6 @@ import { getLocaleById } from "../../services/i18n.js";
|
|||||||
import appContext from "../../components/app_context.js";
|
import appContext from "../../components/app_context.js";
|
||||||
import { getMermaidConfig } from "../../services/mermaid.js";
|
import { getMermaidConfig } from "../../services/mermaid.js";
|
||||||
import { renderMathInElement } from "../../services/math.js";
|
import { renderMathInElement } from "../../services/math.js";
|
||||||
import ckeditorPhotoSwipe from "../../services/ckeditor_photoswipe_integration.js";
|
|
||||||
|
|
||||||
const TPL = /*html*/`
|
const TPL = /*html*/`
|
||||||
<div class="note-detail-readonly-text note-detail-printable" tabindex="100">
|
<div class="note-detail-readonly-text note-detail-printable" tabindex="100">
|
||||||
@@ -94,19 +93,7 @@ export default class ReadOnlyTextTypeWidget extends AbstractTextTypeWidget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
cleanup() {
|
cleanup() {
|
||||||
// Cleanup PhotoSwipe integration
|
this.$content.html("");
|
||||||
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) {
|
async doRefresh(note: FNote) {
|
||||||
@@ -120,18 +107,6 @@ export default class ReadOnlyTextTypeWidget extends AbstractTextTypeWidget {
|
|||||||
const blob = await note.getBlob();
|
const blob = await note.getBlob();
|
||||||
|
|
||||||
this.$content.html(blob?.content ?? "");
|
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.$content.find("a.reference-link").each((_, el) => {
|
||||||
this.loadReferenceLinkTitle($(el));
|
this.loadReferenceLinkTitle($(el));
|
||||||
|
|||||||
@@ -674,6 +674,8 @@ export async function getFullCalendarLocale(locale: string) {
|
|||||||
return (await import("@fullcalendar/core/locales/ro")).default;
|
return (await import("@fullcalendar/core/locales/ro")).default;
|
||||||
case "ru":
|
case "ru":
|
||||||
return (await import("@fullcalendar/core/locales/ru")).default;
|
return (await import("@fullcalendar/core/locales/ru")).default;
|
||||||
|
case "ja":
|
||||||
|
return (await import("@fullcalendar/core/locales/ja")).default;
|
||||||
case "en":
|
case "en":
|
||||||
default:
|
default:
|
||||||
return undefined;
|
return undefined;
|
||||||
|
|||||||
@@ -3,21 +3,17 @@
|
|||||||
"version": "0.97.2",
|
"version": "0.97.2",
|
||||||
"description": "Build your personal knowledge base with Trilium Notes",
|
"description": "Build your personal knowledge base with Trilium Notes",
|
||||||
"private": true,
|
"private": true,
|
||||||
"main": "main.cjs",
|
"main": "dist/main.cjs",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@electron/remote": "2.1.3",
|
"@electron/remote": "2.1.3",
|
||||||
"better-sqlite3": "^12.0.0",
|
"better-sqlite3": "^12.0.0",
|
||||||
"electron-debug": "4.1.0",
|
"electron-debug": "4.1.0",
|
||||||
"electron-dl": "4.0.0",
|
"electron-dl": "4.0.0",
|
||||||
"electron-squirrel-startup": "1.0.1",
|
"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": {
|
"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/cli": "7.8.3",
|
||||||
"@electron-forge/maker-deb": "7.8.3",
|
"@electron-forge/maker-deb": "7.8.3",
|
||||||
"@electron-forge/maker-dmg": "7.8.3",
|
"@electron-forge/maker-dmg": "7.8.3",
|
||||||
@@ -26,6 +22,11 @@
|
|||||||
"@electron-forge/maker-squirrel": "7.8.3",
|
"@electron-forge/maker-squirrel": "7.8.3",
|
||||||
"@electron-forge/maker-zip": "7.8.3",
|
"@electron-forge/maker-zip": "7.8.3",
|
||||||
"@electron-forge/plugin-auto-unpack-natives": "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"
|
"prebuild-install": "^7.1.1"
|
||||||
},
|
},
|
||||||
"config": {
|
"config": {
|
||||||
@@ -196,5 +197,14 @@
|
|||||||
"command": "pnpm -C apps/desktop exec cross-env NODE_INSTALLER=npm TRILIUM_DATA_DIR=./data electron-forge start dist"
|
"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"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,287 +1,315 @@
|
|||||||
{
|
{
|
||||||
"keyboard_actions": {
|
"keyboard_actions": {
|
||||||
"open-jump-to-note-dialog": "Ouvrir la boîte de dialogue \"Aller à la note\"",
|
"open-jump-to-note-dialog": "Ouvrir la boîte de dialogue \"Aller à la note\"",
|
||||||
"search-in-subtree": "Rechercher des notes dans les sous-arbres de la note active",
|
"search-in-subtree": "Rechercher des notes dans les sous-arbres de la note active",
|
||||||
"expand-subtree": "Développer le sous-arbre de la note actuelle",
|
"expand-subtree": "Développer le sous-arbre de la note actuelle",
|
||||||
"collapse-tree": "Réduire toute l'arborescence des notes",
|
"collapse-tree": "Réduire toute l'arborescence des notes",
|
||||||
"collapse-subtree": "Réduire le sous-arbre de la note actuelle",
|
"collapse-subtree": "Réduire le sous-arbre de la note actuelle",
|
||||||
"sort-child-notes": "Trier les notes enfants",
|
"sort-child-notes": "Trier les notes enfants",
|
||||||
"creating-and-moving-notes": "Créer et déplacer des notes",
|
"creating-and-moving-notes": "Créer et déplacer des notes",
|
||||||
"create-note-into-inbox": "Créer une note dans l'emplacement par défaut (si défini) ou une note journalière",
|
"create-note-into-inbox": "Créer une note dans l'emplacement par défaut (si défini) ou une note journalière",
|
||||||
"delete-note": "Supprimer la note",
|
"delete-note": "Supprimer la note",
|
||||||
"move-note-up": "Déplacer la note vers le haut",
|
"move-note-up": "Déplacer la note vers le haut",
|
||||||
"move-note-down": "Déplacer la note vers le bas",
|
"move-note-down": "Déplacer la note vers le bas",
|
||||||
"move-note-up-in-hierarchy": "Déplacer la note vers le haut dans la hiérarchie",
|
"move-note-up-in-hierarchy": "Déplacer la note vers le haut dans la hiérarchie",
|
||||||
"move-note-down-in-hierarchy": "Déplacer la note vers le bas dans la hiérarchie",
|
"move-note-down-in-hierarchy": "Déplacer la note vers le bas dans la hiérarchie",
|
||||||
"edit-note-title": "Passer de l'arborescence aux détails d'une note et éditer le titre",
|
"edit-note-title": "Passer de l'arborescence aux détails d'une note et éditer le titre",
|
||||||
"edit-branch-prefix": "Afficher la fenêtre Éditer le préfixe de branche",
|
"edit-branch-prefix": "Afficher la fenêtre Éditer le préfixe de branche",
|
||||||
"note-clipboard": "Note presse-papiers",
|
"note-clipboard": "Note presse-papiers",
|
||||||
"copy-notes-to-clipboard": "Copier les notes sélectionnées dans le presse-papiers",
|
"copy-notes-to-clipboard": "Copier les notes sélectionnées dans le presse-papiers",
|
||||||
"paste-notes-from-clipboard": "Coller les notes depuis le presse-papiers dans la note active",
|
"paste-notes-from-clipboard": "Coller les notes depuis le presse-papiers dans la note active",
|
||||||
"cut-notes-to-clipboard": "Couper les notes sélectionnées dans le presse-papiers",
|
"cut-notes-to-clipboard": "Couper les notes sélectionnées dans le presse-papiers",
|
||||||
"select-all-notes-in-parent": "Sélectionner toutes les notes du niveau de la note active",
|
"select-all-notes-in-parent": "Sélectionner toutes les notes du niveau de la note active",
|
||||||
"add-note-above-to-the-selection": "Ajouter la note au-dessus de la sélection",
|
"add-note-above-to-the-selection": "Ajouter la note au-dessus de la sélection",
|
||||||
"add-note-below-to-selection": "Ajouter la note en dessous de la sélection",
|
"add-note-below-to-selection": "Ajouter la note en dessous de la sélection",
|
||||||
"duplicate-subtree": "Dupliquer le sous-arbre",
|
"duplicate-subtree": "Dupliquer le sous-arbre",
|
||||||
"tabs-and-windows": "Onglets et fenêtres",
|
"tabs-and-windows": "Onglets et fenêtres",
|
||||||
"open-new-tab": "Ouvrir un nouvel onglet",
|
"open-new-tab": "Ouvrir un nouvel onglet",
|
||||||
"close-active-tab": "Fermer l'onglet actif",
|
"close-active-tab": "Fermer l'onglet actif",
|
||||||
"reopen-last-tab": "Rouvrir le dernier onglet fermé",
|
"reopen-last-tab": "Rouvrir le dernier onglet fermé",
|
||||||
"activate-next-tab": "Basculer vers l'onglet à droite de l'onglet actif",
|
"activate-next-tab": "Basculer vers l'onglet à droite de l'onglet actif",
|
||||||
"activate-previous-tab": "Basculer vers l'onglet à gauche de l'onglet actif",
|
"activate-previous-tab": "Basculer vers l'onglet à gauche de l'onglet actif",
|
||||||
"open-new-window": "Ouvrir une nouvelle fenêtre vide",
|
"open-new-window": "Ouvrir une nouvelle fenêtre vide",
|
||||||
"toggle-tray": "Afficher/masquer l'application dans la barre des tâches",
|
"toggle-tray": "Afficher/masquer l'application dans la barre des tâches",
|
||||||
"first-tab": "Basculer vers le premier onglet dans la liste",
|
"first-tab": "Basculer vers le premier onglet dans la liste",
|
||||||
"second-tab": "Basculer vers le deuxième onglet dans la liste",
|
"second-tab": "Basculer vers le deuxième onglet dans la liste",
|
||||||
"third-tab": "Basculer vers le troisième onglet dans la liste",
|
"third-tab": "Basculer vers le troisième onglet dans la liste",
|
||||||
"fourth-tab": "Basculer vers le quatrième onglet dans la liste",
|
"fourth-tab": "Basculer vers le quatrième onglet dans la liste",
|
||||||
"fifth-tab": "Basculer vers le cinquième onglet dans la liste",
|
"fifth-tab": "Basculer vers le cinquième onglet dans la liste",
|
||||||
"sixth-tab": "Basculer vers le sixième onglet dans la liste",
|
"sixth-tab": "Basculer vers le sixième onglet dans la liste",
|
||||||
"seventh-tab": "Basculer vers le septième onglet dans la liste",
|
"seventh-tab": "Basculer vers le septième onglet dans la liste",
|
||||||
"eight-tab": "Basculer vers le huitième onglet dans la liste",
|
"eight-tab": "Basculer vers le huitième onglet dans la liste",
|
||||||
"ninth-tab": "Basculer vers le neuvième onglet dans la liste",
|
"ninth-tab": "Basculer vers le neuvième onglet dans la liste",
|
||||||
"last-tab": "Basculer vers le dernier onglet dans la liste",
|
"last-tab": "Basculer vers le dernier onglet dans la liste",
|
||||||
"dialogs": "Boîtes de dialogue",
|
"dialogs": "Boîtes de dialogue",
|
||||||
"show-note-source": "Affiche la boîte de dialogue Source de la note",
|
"show-note-source": "Affiche la boîte de dialogue Source de la note",
|
||||||
"show-options": "Afficher les Options",
|
"show-options": "Afficher les Options",
|
||||||
"show-revisions": "Afficher la boîte de dialogue Versions de la note",
|
"show-revisions": "Afficher la boîte de dialogue Versions de la note",
|
||||||
"show-recent-changes": "Afficher la boîte de dialogue Modifications récentes",
|
"show-recent-changes": "Afficher la boîte de dialogue Modifications récentes",
|
||||||
"show-sql-console": "Afficher la boîte de dialogue Console SQL",
|
"show-sql-console": "Afficher la boîte de dialogue Console SQL",
|
||||||
"show-backend-log": "Afficher la boîte de dialogue Journal du backend",
|
"show-backend-log": "Afficher la boîte de dialogue Journal du backend",
|
||||||
"text-note-operations": "Opérations sur les notes textuelles",
|
"text-note-operations": "Opérations sur les notes textuelles",
|
||||||
"add-link-to-text": "Ouvrir la boîte de dialogue pour ajouter un lien dans le texte",
|
"add-link-to-text": "Ouvrir la boîte de dialogue pour ajouter un lien dans le texte",
|
||||||
"follow-link-under-cursor": "Suivre le lien sous le curseur",
|
"follow-link-under-cursor": "Suivre le lien sous le curseur",
|
||||||
"insert-date-and-time-to-text": "Insérer la date et l'heure dans le texte",
|
"insert-date-and-time-to-text": "Insérer la date et l'heure dans le texte",
|
||||||
"paste-markdown-into-text": "Coller du texte au format Markdown dans la note depuis le presse-papiers",
|
"paste-markdown-into-text": "Coller du texte au format Markdown dans la note depuis le presse-papiers",
|
||||||
"cut-into-note": "Couper la sélection depuis la note actuelle et créer une sous-note avec le texte sélectionné",
|
"cut-into-note": "Couper la sélection depuis la note actuelle et créer une sous-note avec le texte sélectionné",
|
||||||
"add-include-note-to-text": "Ouvrir la boîte de dialogue pour Inclure une note",
|
"add-include-note-to-text": "Ouvrir la boîte de dialogue pour Inclure une note",
|
||||||
"edit-readonly-note": "Éditer une note en lecture seule",
|
"edit-readonly-note": "Éditer une note en lecture seule",
|
||||||
"attributes-labels-and-relations": "Attributs (labels et relations)",
|
"attributes-labels-and-relations": "Attributs (labels et relations)",
|
||||||
"add-new-label": "Créer un nouveau label",
|
"add-new-label": "Créer un nouveau label",
|
||||||
"create-new-relation": "Créer une nouvelle relation",
|
"create-new-relation": "Créer une nouvelle relation",
|
||||||
"ribbon-tabs": "Onglets du ruban",
|
"ribbon-tabs": "Onglets du ruban",
|
||||||
"toggle-basic-properties": "Afficher/masquer les Propriétés de base de la note",
|
"toggle-basic-properties": "Afficher/masquer les Propriétés de base de la note",
|
||||||
"toggle-file-properties": "Afficher/masquer les Propriétés du fichier",
|
"toggle-file-properties": "Afficher/masquer les Propriétés du fichier",
|
||||||
"toggle-image-properties": "Afficher/masquer les Propriétés de l'image",
|
"toggle-image-properties": "Afficher/masquer les Propriétés de l'image",
|
||||||
"toggle-owned-attributes": "Afficher/masquer les Attributs propres",
|
"toggle-owned-attributes": "Afficher/masquer les Attributs propres",
|
||||||
"toggle-inherited-attributes": "Afficher/masquer les Attributs hérités",
|
"toggle-inherited-attributes": "Afficher/masquer les Attributs hérités",
|
||||||
"toggle-promoted-attributes": "Afficher/masquer les Attributs promus",
|
"toggle-promoted-attributes": "Afficher/masquer les Attributs promus",
|
||||||
"toggle-link-map": "Afficher/masquer la Carte de la note",
|
"toggle-link-map": "Afficher/masquer la Carte de la note",
|
||||||
"toggle-note-info": "Afficher/masquer les Informations de la note",
|
"toggle-note-info": "Afficher/masquer les Informations de la note",
|
||||||
"toggle-note-paths": "Afficher/masquer les Emplacements de la note",
|
"toggle-note-paths": "Afficher/masquer les Emplacements de la note",
|
||||||
"toggle-similar-notes": "Afficher/masquer les Notes similaires",
|
"toggle-similar-notes": "Afficher/masquer les Notes similaires",
|
||||||
"other": "Autre",
|
"other": "Autre",
|
||||||
"toggle-right-pane": "Afficher/masquer le volet droit, qui inclut la Table des matières et les Accentuations",
|
"toggle-right-pane": "Afficher/masquer le volet droit, qui inclut la Table des matières et les Accentuations",
|
||||||
"print-active-note": "Imprimer la note active",
|
"print-active-note": "Imprimer la note active",
|
||||||
"open-note-externally": "Ouvrir la note comme fichier avec l'application par défaut",
|
"open-note-externally": "Ouvrir la note comme fichier avec l'application par défaut",
|
||||||
"render-active-note": "Rendre (ou re-rendre) la note active",
|
"render-active-note": "Rendre (ou re-rendre) la note active",
|
||||||
"run-active-note": "Exécuter le code JavaScript (frontend/backend) de la note active",
|
"run-active-note": "Exécuter le code JavaScript (frontend/backend) de la note active",
|
||||||
"toggle-note-hoisting": "Activer le focus sur la note active",
|
"toggle-note-hoisting": "Activer le focus sur la note active",
|
||||||
"unhoist": "Désactiver tout focus",
|
"unhoist": "Désactiver tout focus",
|
||||||
"reload-frontend-app": "Recharger l'application",
|
"reload-frontend-app": "Recharger l'application",
|
||||||
"open-dev-tools": "Ouvrir les outils de développement",
|
"open-dev-tools": "Ouvrir les outils de développement",
|
||||||
"toggle-left-note-tree-panel": "Basculer le panneau gauche (arborescence des notes)",
|
"toggle-left-note-tree-panel": "Basculer le panneau gauche (arborescence des notes)",
|
||||||
"toggle-full-screen": "Basculer en plein écran",
|
"toggle-full-screen": "Basculer en plein écran",
|
||||||
"zoom-out": "Dézoomer",
|
"zoom-out": "Dézoomer",
|
||||||
"zoom-in": "Zoomer",
|
"zoom-in": "Zoomer",
|
||||||
"note-navigation": "Navigation dans les notes",
|
"note-navigation": "Navigation dans les notes",
|
||||||
"reset-zoom-level": "Réinitialiser le niveau de zoom",
|
"reset-zoom-level": "Réinitialiser le niveau de zoom",
|
||||||
"copy-without-formatting": "Copier le texte sélectionné sans mise en forme",
|
"copy-without-formatting": "Copier le texte sélectionné sans mise en forme",
|
||||||
"force-save-revision": "Forcer la création / sauvegarde d'une nouvelle version de la note active",
|
"force-save-revision": "Forcer la création / sauvegarde d'une nouvelle version de la note active",
|
||||||
"show-help": "Affiche le guide de l'utilisateur intégré",
|
"show-help": "Affiche le guide de l'utilisateur intégré",
|
||||||
"toggle-book-properties": "Afficher/masquer les Propriétés du Livre",
|
"toggle-book-properties": "Afficher/masquer les Propriétés du Livre",
|
||||||
"toggle-classic-editor-toolbar": "Activer/désactiver l'onglet Mise en forme de l'éditeur avec la barre d'outils fixe",
|
"toggle-classic-editor-toolbar": "Activer/désactiver l'onglet Mise en forme de l'éditeur avec la barre d'outils fixe",
|
||||||
"export-as-pdf": "Exporte la note actuelle en PDF",
|
"export-as-pdf": "Exporte la note actuelle en PDF",
|
||||||
"show-cheatsheet": "Affiche une fenêtre modale avec des opérations de clavier courantes",
|
"show-cheatsheet": "Affiche une fenêtre modale avec des opérations de clavier courantes",
|
||||||
"toggle-zen-mode": "Active/désactive le mode zen (interface réduite pour favoriser la concentration)",
|
"toggle-zen-mode": "Active/désactive le mode zen (interface réduite pour favoriser la concentration)",
|
||||||
"back-in-note-history": "Naviguer à la note précédente dans l'historique",
|
"back-in-note-history": "Naviguer à la note précédente dans l'historique",
|
||||||
"forward-in-note-history": "Naviguer a la note suivante dans l'historique",
|
"forward-in-note-history": "Naviguer a la note suivante dans l'historique",
|
||||||
"open-command-palette": "Ouvrir la palette de commandes",
|
"open-command-palette": "Ouvrir la palette de commandes",
|
||||||
"clone-notes-to": "Cloner les nœuds sélectionnés",
|
"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 l’arborescence des notes jusqu’à la note active",
|
||||||
"login": {
|
"quick-search": "Activer la barre de recherche rapide",
|
||||||
"title": "Connexion",
|
"create-note-after": "Créer une note après la note active",
|
||||||
"heading": "Connexion à Trilium",
|
"create-note-into": "Créer une note enfant de la note active",
|
||||||
"incorrect-password": "Le mot de passe est incorrect. Veuillez réessayer.",
|
"find-in-text": "Afficher/Masquer le panneau de recherche"
|
||||||
"password": "Mot de passe",
|
},
|
||||||
"remember-me": "Se souvenir de moi",
|
"login": {
|
||||||
"button": "Connexion"
|
"title": "Connexion",
|
||||||
},
|
"heading": "Connexion à Trilium",
|
||||||
"set_password": {
|
"incorrect-password": "Le mot de passe est incorrect. Veuillez réessayer.",
|
||||||
"title": "Définir un mot de passe",
|
"password": "Mot de passe",
|
||||||
"heading": "Définir un mot de passe",
|
"remember-me": "Se souvenir de moi",
|
||||||
"description": "Avant de pouvoir commencer à utiliser Trilium depuis le web, vous devez d'abord définir un mot de passe. Vous utiliserez ensuite ce mot de passe pour vous connecter.",
|
"button": "Connexion"
|
||||||
"password": "Mot de passe",
|
},
|
||||||
"password-confirmation": "Confirmation du mot de passe",
|
"set_password": {
|
||||||
"button": "Définir le mot de passe"
|
"title": "Définir un mot de passe",
|
||||||
},
|
"heading": "Définir un mot de passe",
|
||||||
"javascript-required": "Trilium nécessite que JavaScript soit activé.",
|
"description": "Avant de pouvoir commencer à utiliser Trilium depuis le web, vous devez d'abord définir un mot de passe. Vous utiliserez ensuite ce mot de passe pour vous connecter.",
|
||||||
"setup": {
|
"password": "Mot de passe",
|
||||||
"heading": "Configuration de Trilium Notes",
|
"password-confirmation": "Confirmation du mot de passe",
|
||||||
"new-document": "Je suis un nouvel utilisateur et je souhaite créer un nouveau document Trilium pour mes notes",
|
"button": "Définir le mot de passe"
|
||||||
"sync-from-desktop": "J'ai déjà l'application de bureau et je souhaite configurer la synchronisation avec celle-ci",
|
},
|
||||||
"sync-from-server": "J'ai déjà un serveur et je souhaite configurer la synchronisation avec celui-ci",
|
"javascript-required": "Trilium nécessite que JavaScript soit activé.",
|
||||||
"next": "Suivant",
|
"setup": {
|
||||||
"init-in-progress": "Initialisation du document en cours",
|
"heading": "Configuration de Trilium Notes",
|
||||||
"redirecting": "Vous serez bientôt redirigé vers l'application.",
|
"new-document": "Je suis un nouvel utilisateur et je souhaite créer un nouveau document Trilium pour mes notes",
|
||||||
"title": "Configuration"
|
"sync-from-desktop": "J'ai déjà l'application de bureau et je souhaite configurer la synchronisation avec celle-ci",
|
||||||
},
|
"sync-from-server": "J'ai déjà un serveur et je souhaite configurer la synchronisation avec celui-ci",
|
||||||
"setup_sync-from-desktop": {
|
"next": "Suivant",
|
||||||
"heading": "Synchroniser depuis une application de bureau",
|
"init-in-progress": "Initialisation du document en cours",
|
||||||
"description": "Cette procédure doit être réalisée depuis l'application de bureau installée sur votre ordinateur:",
|
"redirecting": "Vous serez bientôt redirigé vers l'application.",
|
||||||
"step1": "Ouvrez l'application Trilium Notes.",
|
"title": "Configuration"
|
||||||
"step2": "Dans le menu Trilium, cliquez sur Options.",
|
},
|
||||||
"step3": "Cliquez sur la catégorie Synchroniser.",
|
"setup_sync-from-desktop": {
|
||||||
"step4": "Remplacez l'adresse de l'instance de serveur par : {{- host}} et cliquez sur Enregistrer.",
|
"heading": "Synchroniser depuis une application de bureau",
|
||||||
"step5": "Cliquez sur le bouton 'Tester la synchronisation' pour vérifier que la connexion fonctionne.",
|
"description": "Cette procédure doit être réalisée depuis l'application de bureau:",
|
||||||
"step6": "Une fois que vous avez terminé ces étapes, cliquez sur {{- link}}.",
|
"step1": "Ouvrez l'application Trilium Notes.",
|
||||||
"step6-here": "ici"
|
"step2": "Dans le menu Trilium, cliquez sur Options.",
|
||||||
},
|
"step3": "Cliquez sur la catégorie Synchroniser.",
|
||||||
"setup_sync-from-server": {
|
"step4": "Remplacez l'adresse de l'instance de serveur par : {{- host}} et cliquez sur Enregistrer.",
|
||||||
"heading": "Synchroniser depuis le serveur",
|
"step5": "Cliquez sur le bouton 'Tester la synchronisation' pour vérifier que la connexion fonctionne.",
|
||||||
"instructions": "Veuillez saisir l'adresse du serveur Trilium et les informations d'identification ci-dessous. Cela téléchargera l'intégralité du document Trilium à partir du serveur et configurera la synchronisation avec celui-ci. En fonction de la taille du document et de votre vitesse de connexion, cela peut prendre un plusieurs minutes.",
|
"step6": "Une fois que vous avez terminé ces étapes, cliquez sur {{- link}}.",
|
||||||
"server-host": "Adresse du serveur Trilium",
|
"step6-here": "ici"
|
||||||
"server-host-placeholder": "https://<nom d'hôte>:<port>",
|
},
|
||||||
"proxy-server": "Serveur proxy (facultatif)",
|
"setup_sync-from-server": {
|
||||||
"proxy-server-placeholder": "https://<nom d'hôte>:<port>",
|
"heading": "Synchroniser depuis le serveur",
|
||||||
"note": "Note :",
|
"instructions": "Veuillez saisir l'adresse du serveur Trilium et les informations d'identification ci-dessous. Cela téléchargera l'intégralité du document Trilium à partir du serveur et configurera la synchronisation avec celui-ci. En fonction de la taille du document et de votre vitesse de connexion, cela peut prendre un plusieurs minutes.",
|
||||||
"proxy-instruction": "Si vous laissez le paramètre de proxy vide, le proxy du système sera utilisé (s'applique uniquement à l'application de bureau)",
|
"server-host": "Adresse du serveur Trilium",
|
||||||
"password": "Mot de passe",
|
"server-host-placeholder": "https://<nom d'hôte>:<port>",
|
||||||
"password-placeholder": "Mot de passe",
|
"proxy-server": "Serveur proxy (facultatif)",
|
||||||
"back": "Retour",
|
"proxy-server-placeholder": "https://<nom d'hôte>:<port>",
|
||||||
"finish-setup": "Terminer"
|
"note": "Note :",
|
||||||
},
|
"proxy-instruction": "Si vous laissez le paramètre de proxy vide, le proxy du système sera utilisé (s'applique uniquement à l'application de bureau)",
|
||||||
"setup_sync-in-progress": {
|
"password": "Mot de passe",
|
||||||
"heading": "Synchronisation en cours",
|
"password-placeholder": "Mot de passe",
|
||||||
"successful": "La synchronisation a été correctement configurée. La synchronisation initiale prendra un certain temps. Une fois terminée, vous serez redirigé vers la page de connexion.",
|
"back": "Retour",
|
||||||
"outstanding-items": "Éléments de synchronisation exceptionnels :",
|
"finish-setup": "Terminer"
|
||||||
"outstanding-items-default": "N/A"
|
},
|
||||||
},
|
"setup_sync-in-progress": {
|
||||||
"share_404": {
|
"heading": "Synchronisation en cours",
|
||||||
"title": "Page non trouvée",
|
"successful": "La synchronisation a été correctement configurée. La synchronisation initiale prendra un certain temps. Une fois terminée, vous serez redirigé vers la page de connexion.",
|
||||||
"heading": "Page non trouvée"
|
"outstanding-items": "Éléments de synchronisation exceptionnels :",
|
||||||
},
|
"outstanding-items-default": "N/A"
|
||||||
"share_page": {
|
},
|
||||||
"parent": "parent :",
|
"share_404": {
|
||||||
"clipped-from": "Cette note a été initialement extraite de {{- url}}",
|
"title": "Page non trouvée",
|
||||||
"child-notes": "Notes enfants :",
|
"heading": "Page non trouvée"
|
||||||
"no-content": "Cette note n'a aucun contenu."
|
},
|
||||||
},
|
"share_page": {
|
||||||
"weekdays": {
|
"parent": "parent :",
|
||||||
"monday": "Lundi",
|
"clipped-from": "Cette note a été initialement extraite de {{- url}}",
|
||||||
"tuesday": "Mardi",
|
"child-notes": "Notes enfants :",
|
||||||
"wednesday": "Mercredi",
|
"no-content": "Cette note n'a aucun contenu."
|
||||||
"thursday": "Jeudi",
|
},
|
||||||
"friday": "Vendredi",
|
"weekdays": {
|
||||||
"saturday": "Samedi",
|
"monday": "Lundi",
|
||||||
"sunday": "Dimanche"
|
"tuesday": "Mardi",
|
||||||
},
|
"wednesday": "Mercredi",
|
||||||
"months": {
|
"thursday": "Jeudi",
|
||||||
"january": "Janvier",
|
"friday": "Vendredi",
|
||||||
"february": "Février",
|
"saturday": "Samedi",
|
||||||
"march": "Mars",
|
"sunday": "Dimanche"
|
||||||
"april": "Avril",
|
},
|
||||||
"may": "Mai",
|
"months": {
|
||||||
"june": "Juin",
|
"january": "Janvier",
|
||||||
"july": "Juillet",
|
"february": "Février",
|
||||||
"august": "Août",
|
"march": "Mars",
|
||||||
"september": "Septembre",
|
"april": "Avril",
|
||||||
"october": "Octobre",
|
"may": "Mai",
|
||||||
"november": "Novembre",
|
"june": "Juin",
|
||||||
"december": "Décembre"
|
"july": "Juillet",
|
||||||
},
|
"august": "Août",
|
||||||
"special_notes": {
|
"september": "Septembre",
|
||||||
"search_prefix": "Recherche :"
|
"october": "Octobre",
|
||||||
},
|
"november": "Novembre",
|
||||||
"test_sync": {
|
"december": "Décembre"
|
||||||
"not-configured": "L'hôte du serveur de synchronisation n'est pas configuré. Veuillez d'abord configurer la synchronisation.",
|
},
|
||||||
"successful": "L'établissement de liaison du serveur de synchronisation a été réussi, la synchronisation a été démarrée."
|
"special_notes": {
|
||||||
},
|
"search_prefix": "Recherche :"
|
||||||
"hidden-subtree": {
|
},
|
||||||
"root-title": "Notes cachées",
|
"test_sync": {
|
||||||
"search-history-title": "Historique de recherche",
|
"not-configured": "L'hôte du serveur de synchronisation n'est pas configuré. Veuillez d'abord configurer la synchronisation.",
|
||||||
"note-map-title": "Carte de la Note",
|
"successful": "L'établissement de liaison du serveur de synchronisation a été réussi, la synchronisation a été démarrée."
|
||||||
"sql-console-history-title": "Historique de la console SQL",
|
},
|
||||||
"shared-notes-title": "Notes partagées",
|
"hidden-subtree": {
|
||||||
"bulk-action-title": "Action groupée",
|
"root-title": "Notes cachées",
|
||||||
"backend-log-title": "Journal Backend",
|
"search-history-title": "Historique de recherche",
|
||||||
"user-hidden-title": "Utilisateur masqué",
|
"note-map-title": "Carte de la Note",
|
||||||
"launch-bar-templates-title": "Modèles de barre de raccourcis",
|
"sql-console-history-title": "Historique de la console SQL",
|
||||||
"base-abstract-launcher-title": "Raccourci Base abstraite",
|
"shared-notes-title": "Notes partagées",
|
||||||
"command-launcher-title": "Raccourci Commande",
|
"bulk-action-title": "Action groupée",
|
||||||
"note-launcher-title": "Raccourci Note",
|
"backend-log-title": "Journal Backend",
|
||||||
"script-launcher-title": "Raccourci Script",
|
"user-hidden-title": "Utilisateur masqué",
|
||||||
"built-in-widget-title": "Widget intégré",
|
"launch-bar-templates-title": "Modèles de barre de raccourcis",
|
||||||
"spacer-title": "Séparateur",
|
"base-abstract-launcher-title": "Raccourci Base abstraite",
|
||||||
"custom-widget-title": "Widget personnalisé",
|
"command-launcher-title": "Raccourci Commande",
|
||||||
"launch-bar-title": "Barre de lancement",
|
"note-launcher-title": "Raccourci Note",
|
||||||
"available-launchers-title": "Raccourcis disponibles",
|
"script-launcher-title": "Raccourci Script",
|
||||||
"go-to-previous-note-title": "Aller à la note précédente",
|
"built-in-widget-title": "Widget intégré",
|
||||||
"go-to-next-note-title": "Aller à la note suivante",
|
"spacer-title": "Séparateur",
|
||||||
"new-note-title": "Nouvelle note",
|
"custom-widget-title": "Widget personnalisé",
|
||||||
"search-notes-title": "Rechercher des notes",
|
"launch-bar-title": "Barre de lancement",
|
||||||
"calendar-title": "Calendrier",
|
"available-launchers-title": "Raccourcis disponibles",
|
||||||
"recent-changes-title": "Modifications récentes",
|
"go-to-previous-note-title": "Aller à la note précédente",
|
||||||
"bookmarks-title": "Signets",
|
"go-to-next-note-title": "Aller à la note suivante",
|
||||||
"open-today-journal-note-title": "Ouvrir la note du journal du jour",
|
"new-note-title": "Nouvelle note",
|
||||||
"quick-search-title": "Recherche rapide",
|
"search-notes-title": "Rechercher des notes",
|
||||||
"protected-session-title": "Session protégée",
|
"calendar-title": "Calendrier",
|
||||||
"sync-status-title": "État de la synchronisation",
|
"recent-changes-title": "Modifications récentes",
|
||||||
"settings-title": "Réglages",
|
"bookmarks-title": "Signets",
|
||||||
"options-title": "Options",
|
"open-today-journal-note-title": "Ouvrir la note du journal du jour",
|
||||||
"appearance-title": "Apparence",
|
"quick-search-title": "Recherche rapide",
|
||||||
"shortcuts-title": "Raccourcis",
|
"protected-session-title": "Session protégée",
|
||||||
"text-notes": "Notes de texte",
|
"sync-status-title": "État de la synchronisation",
|
||||||
"code-notes-title": "Notes de code",
|
"settings-title": "Réglages",
|
||||||
"images-title": "Images",
|
"options-title": "Options",
|
||||||
"spellcheck-title": "Correcteur orthographique",
|
"appearance-title": "Apparence",
|
||||||
"password-title": "Mot de passe",
|
"shortcuts-title": "Raccourcis",
|
||||||
"etapi-title": "ETAPI",
|
"text-notes": "Notes de texte",
|
||||||
"backup-title": "Sauvegarde",
|
"code-notes-title": "Notes de code",
|
||||||
"sync-title": "Synchronisation",
|
"images-title": "Images",
|
||||||
"other": "Autre",
|
"spellcheck-title": "Correcteur orthographique",
|
||||||
"advanced-title": "Avancé",
|
"password-title": "Mot de passe",
|
||||||
"visible-launchers-title": "Raccourcis visibles",
|
"etapi-title": "ETAPI",
|
||||||
"user-guide": "Guide de l'utilisateur"
|
"backup-title": "Sauvegarde",
|
||||||
},
|
"sync-title": "Synchronisation",
|
||||||
"notes": {
|
"other": "Autre",
|
||||||
"new-note": "Nouvelle note",
|
"advanced-title": "Avancé",
|
||||||
"duplicate-note-suffix": "(dup)",
|
"visible-launchers-title": "Raccourcis visibles",
|
||||||
"duplicate-note-title": "{{- noteTitle }} {{ duplicateNoteSuffix }}"
|
"user-guide": "Guide de l'utilisateur"
|
||||||
},
|
},
|
||||||
"backend_log": {
|
"notes": {
|
||||||
"log-does-not-exist": "Le fichier journal '{{ fileName }}' n'existe pas (encore).",
|
"new-note": "Nouvelle note",
|
||||||
"reading-log-failed": "La lecture du fichier journal d'administration '{{ fileName }}' a échoué."
|
"duplicate-note-suffix": "(dup)",
|
||||||
},
|
"duplicate-note-title": "{{- noteTitle }} {{ duplicateNoteSuffix }}"
|
||||||
"content_renderer": {
|
},
|
||||||
"note-cannot-be-displayed": "Ce type de note ne peut pas être affiché."
|
"backend_log": {
|
||||||
},
|
"log-does-not-exist": "Le fichier journal '{{ fileName }}' n'existe pas (encore).",
|
||||||
"pdf": {
|
"reading-log-failed": "La lecture du fichier journal d'administration '{{ fileName }}' a échoué."
|
||||||
"export_filter": "Document PDF (*.pdf)",
|
},
|
||||||
"unable-to-export-message": "La note actuelle n'a pas pu être exportée en format PDF.",
|
"content_renderer": {
|
||||||
"unable-to-export-title": "Impossible d'exporter au format PDF",
|
"note-cannot-be-displayed": "Ce type de note ne peut pas être affiché."
|
||||||
"unable-to-save-message": "Le fichier sélectionné n'a pas pu être écrit. Réessayez ou sélectionnez une autre destination."
|
},
|
||||||
},
|
"pdf": {
|
||||||
"tray": {
|
"export_filter": "Document PDF (*.pdf)",
|
||||||
"tooltip": "Trilium Notes",
|
"unable-to-export-message": "La note actuelle n'a pas pu être exportée en format PDF.",
|
||||||
"close": "Quitter Trilium",
|
"unable-to-export-title": "Impossible d'exporter au format PDF",
|
||||||
"recents": "Notes récentes",
|
"unable-to-save-message": "Le fichier sélectionné n'a pas pu être écrit. Réessayez ou sélectionnez une autre destination."
|
||||||
"bookmarks": "Signets",
|
},
|
||||||
"today": "Ouvrir la note du journal du jour",
|
"tray": {
|
||||||
"new-note": "Nouvelle note",
|
"tooltip": "Trilium Notes",
|
||||||
"show-windows": "Afficher les fenêtres"
|
"close": "Quitter Trilium",
|
||||||
},
|
"recents": "Notes récentes",
|
||||||
"migration": {
|
"bookmarks": "Signets",
|
||||||
"old_version": "La migration directe à partir de votre version actuelle n'est pas prise en charge. Veuillez d'abord mettre à jour vers la version v0.60.4, puis vers cette nouvelle version.",
|
"today": "Ouvrir la note du journal du jour",
|
||||||
"error_message": "Erreur lors de la migration vers la version {{version}}: {{stack}}",
|
"new-note": "Nouvelle note",
|
||||||
"wrong_db_version": "La version de la base de données ({{version}}) est plus récente que ce que l'application supporte actuellement ({{targetVersion}}), ce qui signifie qu'elle a été créée par une version plus récente et incompatible de Trilium. Mettez à jour vers la dernière version de Trilium pour résoudre ce problème."
|
"show-windows": "Afficher les fenêtres"
|
||||||
},
|
},
|
||||||
"modals": {
|
"migration": {
|
||||||
"error_title": "Erreur"
|
"old_version": "La migration directe à partir de votre version actuelle n'est pas prise en charge. Veuillez d'abord mettre à jour vers la version v0.60.4, puis vers cette nouvelle version.",
|
||||||
},
|
"error_message": "Erreur lors de la migration vers la version {{version}}: {{stack}}",
|
||||||
"keyboard_action_names": {
|
"wrong_db_version": "La version de la base de données ({{version}}) est plus récente que ce que l'application supporte actuellement ({{targetVersion}}), ce qui signifie qu'elle a été créée par une version plus récente et incompatible de Trilium. Mettez à jour vers la dernière version de Trilium pour résoudre ce problème."
|
||||||
"command-palette": "Palette de commandes",
|
},
|
||||||
"quick-search": "Recherche rapide"
|
"modals": {
|
||||||
}
|
"error_title": "Erreur"
|
||||||
|
},
|
||||||
|
"keyboard_action_names": {
|
||||||
|
"command-palette": "Palette de commandes",
|
||||||
|
"quick-search": "Recherche rapide",
|
||||||
|
"back-in-note-history": "Revenir dans l’historique des notes",
|
||||||
|
"forward-in-note-history": "Suivant dans l’historique 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 l’arborescence",
|
||||||
|
"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"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -53,7 +53,7 @@
|
|||||||
"show-help": "内蔵のユーザーガイドを開く",
|
"show-help": "内蔵のユーザーガイドを開く",
|
||||||
"show-cheatsheet": "よく使うキーボードショートカットをモーダルで表示する",
|
"show-cheatsheet": "よく使うキーボードショートカットをモーダルで表示する",
|
||||||
"text-note-operations": "テキストノート操作",
|
"text-note-operations": "テキストノート操作",
|
||||||
"add-link-to-text": "テキストにリンクを追加するダイアログを開く",
|
"add-link-to-text": "テキストにリンクを追加ダイアログを開く",
|
||||||
"follow-link-under-cursor": "カーソル下のリンク先へ移動",
|
"follow-link-under-cursor": "カーソル下のリンク先へ移動",
|
||||||
"insert-date-and-time-to-text": "現在の日時を挿入する",
|
"insert-date-and-time-to-text": "現在の日時を挿入する",
|
||||||
"paste-markdown-into-text": "クリップボードからMarkdownをテキストノートに貼り付けます",
|
"paste-markdown-into-text": "クリップボードからMarkdownをテキストノートに貼り付けます",
|
||||||
@@ -70,7 +70,7 @@
|
|||||||
"open-note-externally": "デフォルトのアプリケーションでノートをファイルとして開く",
|
"open-note-externally": "デフォルトのアプリケーションでノートをファイルとして開く",
|
||||||
"render-active-note": "アクティブなノートを再描画(再レンダリング)する",
|
"render-active-note": "アクティブなノートを再描画(再レンダリング)する",
|
||||||
"run-active-note": "アクティブなJavaScript(フロントエンド/バックエンド)のコードノートを実行する",
|
"run-active-note": "アクティブなJavaScript(フロントエンド/バックエンド)のコードノートを実行する",
|
||||||
"reload-frontend-app": "フロントエンドのリロード",
|
"reload-frontend-app": "フロントエンドをリロード",
|
||||||
"open-dev-tools": "開発者ツールを開く",
|
"open-dev-tools": "開発者ツールを開く",
|
||||||
"find-in-text": "検索パネルの切り替え",
|
"find-in-text": "検索パネルの切り替え",
|
||||||
"toggle-left-note-tree-panel": "左パネルの切り替え (ノートツリー)",
|
"toggle-left-note-tree-panel": "左パネルの切り替え (ノートツリー)",
|
||||||
@@ -85,7 +85,22 @@
|
|||||||
"sort-child-notes": "子ノートを並べ替える",
|
"sort-child-notes": "子ノートを並べ替える",
|
||||||
"create-note-into-inbox": "inbox(定義されている場合)またはデイノートにノートを作成する",
|
"create-note-into-inbox": "inbox(定義されている場合)またはデイノートにノートを作成する",
|
||||||
"note-clipboard": "ノートクリップボード",
|
"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": {
|
"keyboard_action_names": {
|
||||||
"back-in-note-history": "ノートの履歴を戻る",
|
"back-in-note-history": "ノートの履歴を戻る",
|
||||||
@@ -156,7 +171,31 @@
|
|||||||
"toggle-left-pane": "左ペイン切り替え",
|
"toggle-left-pane": "左ペイン切り替え",
|
||||||
"toggle-full-screen": "フルスクリーンの切り替え",
|
"toggle-full-screen": "フルスクリーンの切り替え",
|
||||||
"copy-without-formatting": "書式なしでコピー",
|
"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": {
|
"login": {
|
||||||
"title": "ログイン",
|
"title": "ログイン",
|
||||||
@@ -164,14 +203,17 @@
|
|||||||
"incorrect-totp": "TOTPが正しくありません。もう一度お試しください。",
|
"incorrect-totp": "TOTPが正しくありません。もう一度お試しください。",
|
||||||
"incorrect-password": "パスワードが正しくありません。もう一度お試しください。",
|
"incorrect-password": "パスワードが正しくありません。もう一度お試しください。",
|
||||||
"password": "パスワード",
|
"password": "パスワード",
|
||||||
"button": "ログイン"
|
"button": "ログイン",
|
||||||
|
"remember-me": "ログイン情報を記憶する",
|
||||||
|
"sign_in_with_sso": "{{ ssoIssuerName }}でログイン"
|
||||||
},
|
},
|
||||||
"set_password": {
|
"set_password": {
|
||||||
"title": "パスワードの設定",
|
"title": "パスワードの設定",
|
||||||
"heading": "パスワードの設定",
|
"heading": "パスワードの設定",
|
||||||
"description": "ウェブからTriliumを始めるには、パスワードを設定する必要があります。設定したパスワードを使ってログインします。",
|
"description": "ウェブからTriliumを始めるには、パスワードを設定する必要があります。設定したパスワードを使ってログインします。",
|
||||||
"password": "パスワード",
|
"password": "パスワード",
|
||||||
"button": "パスワードの設定"
|
"button": "パスワードの設定",
|
||||||
|
"password-confirmation": "パスワードの再入力"
|
||||||
},
|
},
|
||||||
"javascript-required": "Triliumを使用するにはJavaScriptを有効にする必要があります。",
|
"javascript-required": "Triliumを使用するにはJavaScriptを有効にする必要があります。",
|
||||||
"setup": {
|
"setup": {
|
||||||
@@ -180,7 +222,9 @@
|
|||||||
"sync-from-desktop": "すでにデスクトップ版のインスタンスがあり、同期を設定したい",
|
"sync-from-desktop": "すでにデスクトップ版のインスタンスがあり、同期を設定したい",
|
||||||
"sync-from-server": "すでにサーバー版のインスタンスがあり、同期を設定したい",
|
"sync-from-server": "すでにサーバー版のインスタンスがあり、同期を設定したい",
|
||||||
"init-in-progress": "ドキュメントの初期化処理を実行中",
|
"init-in-progress": "ドキュメントの初期化処理を実行中",
|
||||||
"redirecting": "まもなくアプリケーションにリダイレクトされます。"
|
"redirecting": "まもなくアプリケーションにリダイレクトされます。",
|
||||||
|
"next": "次へ",
|
||||||
|
"title": "セットアップ"
|
||||||
},
|
},
|
||||||
"setup_sync-from-desktop": {
|
"setup_sync-from-desktop": {
|
||||||
"heading": "デスクトップから同期",
|
"heading": "デスクトップから同期",
|
||||||
@@ -190,7 +234,8 @@
|
|||||||
"step3": "同期をクリックします。",
|
"step3": "同期をクリックします。",
|
||||||
"step4": "サーバーインスタンスアドレスを {{- host}} に変更し、保存をクリックします。",
|
"step4": "サーバーインスタンスアドレスを {{- host}} に変更し、保存をクリックします。",
|
||||||
"step5": "「同期テスト」をクリックして、接続が成功したか確認してください。",
|
"step5": "「同期テスト」をクリックして、接続が成功したか確認してください。",
|
||||||
"step6": "これらのステップを完了したら、{{- link}} をクリックしてください。"
|
"step6": "これらのステップを完了したら、{{- link}} をクリックしてください。",
|
||||||
|
"step6-here": "ここ"
|
||||||
},
|
},
|
||||||
"setup_sync-from-server": {
|
"setup_sync-from-server": {
|
||||||
"heading": "サーバーから同期",
|
"heading": "サーバーから同期",
|
||||||
@@ -202,7 +247,8 @@
|
|||||||
"proxy-instruction": "プロキシ設定を空欄にすると、システムプロキシが使用されます(デスクトップアプリケーションにのみ適用されます)",
|
"proxy-instruction": "プロキシ設定を空欄にすると、システムプロキシが使用されます(デスクトップアプリケーションにのみ適用されます)",
|
||||||
"password": "パスワード",
|
"password": "パスワード",
|
||||||
"password-placeholder": "パスワード",
|
"password-placeholder": "パスワード",
|
||||||
"finish-setup": "セットアップ完了"
|
"finish-setup": "セットアップ完了",
|
||||||
|
"back": "戻る"
|
||||||
},
|
},
|
||||||
"setup_sync-in-progress": {
|
"setup_sync-in-progress": {
|
||||||
"heading": "同期中",
|
"heading": "同期中",
|
||||||
@@ -237,7 +283,8 @@
|
|||||||
"search_prefix": "検索:"
|
"search_prefix": "検索:"
|
||||||
},
|
},
|
||||||
"test_sync": {
|
"test_sync": {
|
||||||
"not-configured": "同期サーバーホストが設定されていません。最初に同期を設定してください。"
|
"not-configured": "同期サーバーホストが設定されていません。最初に同期を設定してください。",
|
||||||
|
"successful": "同期サーバーとのハンドシェイクが成功しました。同期が開始されました。"
|
||||||
},
|
},
|
||||||
"hidden-subtree": {
|
"hidden-subtree": {
|
||||||
"search-history-title": "検索履歴",
|
"search-history-title": "検索履歴",
|
||||||
@@ -254,7 +301,40 @@
|
|||||||
"other": "その他",
|
"other": "その他",
|
||||||
"advanced-title": "高度",
|
"advanced-title": "高度",
|
||||||
"user-guide": "ユーザーガイド",
|
"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": {
|
"notes": {
|
||||||
"new-note": "新しいノート",
|
"new-note": "新しいノート",
|
||||||
@@ -296,7 +376,10 @@
|
|||||||
"site-theme": "サイトのテーマ",
|
"site-theme": "サイトのテーマ",
|
||||||
"search_placeholder": "検索...",
|
"search_placeholder": "検索...",
|
||||||
"last-updated": "最終更新日 {{- date}}",
|
"last-updated": "最終更新日 {{- date}}",
|
||||||
"subpages": "サブページ:"
|
"subpages": "サブページ:",
|
||||||
|
"image_alt": "記事画像",
|
||||||
|
"on-this-page": "このページの内容",
|
||||||
|
"expand": "展開"
|
||||||
},
|
},
|
||||||
"hidden_subtree_templates": {
|
"hidden_subtree_templates": {
|
||||||
"text-snippet": "テキストスニペット",
|
"text-snippet": "テキストスニペット",
|
||||||
@@ -315,6 +398,19 @@
|
|||||||
"board_note_second": "2番目のノート",
|
"board_note_second": "2番目のノート",
|
||||||
"board_note_third": "3番目のノート",
|
"board_note_third": "3番目のノート",
|
||||||
"board_status_progress": "進行中",
|
"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}週"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,428 +1,428 @@
|
|||||||
{
|
{
|
||||||
"keyboard_actions": {
|
"keyboard_actions": {
|
||||||
"open-jump-to-note-dialog": "打開「跳轉到筆記」對話方塊",
|
"open-jump-to-note-dialog": "打開「跳轉至筆記」對話方塊",
|
||||||
"search-in-subtree": "在目前筆記的子階層中搜尋筆記",
|
"search-in-subtree": "在目前筆記的子階層中搜尋筆記",
|
||||||
"expand-subtree": "展開目前筆記的子階層",
|
"expand-subtree": "展開目前筆記的子階層",
|
||||||
"collapse-tree": "收合全部的筆記樹",
|
"collapse-tree": "收摺全部的筆記樹",
|
||||||
"collapse-subtree": "收合目前筆記的子階層",
|
"collapse-subtree": "收摺目前筆記的子階層",
|
||||||
"sort-child-notes": "排序子筆記",
|
"sort-child-notes": "排序子筆記",
|
||||||
"creating-and-moving-notes": "新增和移動筆記",
|
"creating-and-moving-notes": "新增和移動筆記",
|
||||||
"create-note-into-inbox": "在收件匣(如果已定義)或日記中新增筆記",
|
"create-note-into-inbox": "在收件匣(如果已定義)或日記中新增筆記",
|
||||||
"delete-note": "刪除筆記",
|
"delete-note": "刪除筆記",
|
||||||
"move-note-up": "上移筆記",
|
"move-note-up": "上移筆記",
|
||||||
"move-note-down": "下移筆記",
|
"move-note-down": "下移筆記",
|
||||||
"move-note-up-in-hierarchy": "將筆記層級上移",
|
"move-note-up-in-hierarchy": "將筆記層級上移",
|
||||||
"move-note-down-in-hierarchy": "將筆記層級下移",
|
"move-note-down-in-hierarchy": "將筆記層級下移",
|
||||||
"edit-note-title": "從筆記樹跳轉到筆記詳情並編輯標題",
|
"edit-note-title": "從筆記樹跳轉至筆記詳情並編輯標題",
|
||||||
"edit-branch-prefix": "顯示編輯分支前綴對話方塊",
|
"edit-branch-prefix": "顯示編輯分支前綴對話方塊",
|
||||||
"note-clipboard": "筆記剪貼簿",
|
"note-clipboard": "筆記剪貼簿",
|
||||||
"copy-notes-to-clipboard": "複製選定的筆記到剪貼簿",
|
"copy-notes-to-clipboard": "複製選定的筆記至剪貼簿",
|
||||||
"paste-notes-from-clipboard": "從剪貼簿貼上筆記至目前筆記中",
|
"paste-notes-from-clipboard": "從剪貼簿貼上筆記至目前筆記中",
|
||||||
"cut-notes-to-clipboard": "剪下選定的筆記至剪貼簿",
|
"cut-notes-to-clipboard": "剪下選定的筆記至剪貼簿",
|
||||||
"select-all-notes-in-parent": "選擇當前筆記級別的所有筆記",
|
"select-all-notes-in-parent": "選擇當前筆記級別的所有筆記",
|
||||||
"add-note-above-to-the-selection": "加入上方筆記至選擇中",
|
"add-note-above-to-the-selection": "加入上方筆記至選擇中",
|
||||||
"add-note-below-to-selection": "加入下方筆記至選擇中",
|
"add-note-below-to-selection": "加入下方筆記至選擇中",
|
||||||
"duplicate-subtree": "複製子階層",
|
"duplicate-subtree": "複製子階層",
|
||||||
"tabs-and-windows": "分頁和視窗",
|
"tabs-and-windows": "分頁和視窗",
|
||||||
"open-new-tab": "打開新分頁",
|
"open-new-tab": "打開新分頁",
|
||||||
"close-active-tab": "關閉活動分頁",
|
"close-active-tab": "關閉活動分頁",
|
||||||
"reopen-last-tab": "重新打開最後關閉的分頁",
|
"reopen-last-tab": "重新打開最後關閉的分頁",
|
||||||
"activate-next-tab": "切換至右側分頁",
|
"activate-next-tab": "切換至右側分頁",
|
||||||
"activate-previous-tab": "切換至左側分頁",
|
"activate-previous-tab": "切換至左側分頁",
|
||||||
"open-new-window": "打開新空白視窗",
|
"open-new-window": "打開新空白視窗",
|
||||||
"toggle-tray": "從系統列顯示/隱藏應用程式",
|
"toggle-tray": "從系統匣顯示/隱藏應用程式",
|
||||||
"first-tab": "切換至列表中的第一個分頁",
|
"first-tab": "切換至列表中第一個分頁",
|
||||||
"second-tab": "切換至列表中的第二個分頁",
|
"second-tab": "切換至列表中第二個分頁",
|
||||||
"third-tab": "切換至列表中的第三個分頁",
|
"third-tab": "切換至列表中第三個分頁",
|
||||||
"fourth-tab": "切換至列表中的第四個分頁",
|
"fourth-tab": "切換至列表中第四個分頁",
|
||||||
"fifth-tab": "切換至列表中的第五個分頁",
|
"fifth-tab": "切換至列表中第五個分頁",
|
||||||
"sixth-tab": "切換至列表中的第六個分頁",
|
"sixth-tab": "切換至列表中第六個分頁",
|
||||||
"seventh-tab": "切換至列表中的第七個分頁",
|
"seventh-tab": "切換至列表中第七個分頁",
|
||||||
"eight-tab": "切換至列表中的第八個分頁",
|
"eight-tab": "切換至列表中第八個分頁",
|
||||||
"ninth-tab": "切換至列表中的第九個分頁",
|
"ninth-tab": "切換至列表中第九個分頁",
|
||||||
"last-tab": "切換至列表中的最後一個分頁",
|
"last-tab": "切換至列表中最後一個分頁",
|
||||||
"dialogs": "對話方塊",
|
"dialogs": "對話方塊",
|
||||||
"show-note-source": "顯示筆記來源對話方塊",
|
"show-note-source": "顯示筆記來源對話方塊",
|
||||||
"show-options": "打開選項頁面",
|
"show-options": "打開選項頁面",
|
||||||
"show-revisions": "顯示筆記修改歷史對話方塊",
|
"show-revisions": "顯示筆記修改歷史對話方塊",
|
||||||
"show-recent-changes": "顯示最近更改對話方塊",
|
"show-recent-changes": "顯示最近更改對話方塊",
|
||||||
"show-sql-console": "顯示 SQL 控制台對話方塊",
|
"show-sql-console": "打開 SQL 控制台頁面",
|
||||||
"show-backend-log": "顯示後端日誌對話方塊",
|
"show-backend-log": "打開後端日誌頁面",
|
||||||
"text-note-operations": "文字筆記操作",
|
"text-note-operations": "文字筆記操作",
|
||||||
"add-link-to-text": "打開對話方塊以插入連結",
|
"add-link-to-text": "打開對話方塊以插入連結",
|
||||||
"follow-link-under-cursor": "開啟游標處的連結",
|
"follow-link-under-cursor": "開啟游標處的連結",
|
||||||
"insert-date-and-time-to-text": "插入目前日期和時間",
|
"insert-date-and-time-to-text": "插入目前日期和時間",
|
||||||
"paste-markdown-into-text": "將剪貼簿中的 Markdown 文字貼上",
|
"paste-markdown-into-text": "將剪貼簿中的 Markdown 文字貼上",
|
||||||
"cut-into-note": "從目前筆記剪下選擇的部分並新增至子筆記",
|
"cut-into-note": "從目前筆記剪下選擇的部分並新增至子筆記",
|
||||||
"add-include-note-to-text": "打開對話方塊以包含筆記",
|
"add-include-note-to-text": "打開對話方塊以包含筆記",
|
||||||
"edit-readonly-note": "編輯唯讀筆記",
|
"edit-readonly-note": "編輯唯讀筆記",
|
||||||
"attributes-labels-and-relations": "屬性(標籤和關係)",
|
"attributes-labels-and-relations": "屬性(標籤和關係)",
|
||||||
"add-new-label": "新增新標籤",
|
"add-new-label": "新增新標籤",
|
||||||
"create-new-relation": "新增新關係",
|
"create-new-relation": "新增新關聯",
|
||||||
"ribbon-tabs": "功能區分頁",
|
"ribbon-tabs": "功能區分頁",
|
||||||
"toggle-basic-properties": "顯示基本屬性",
|
"toggle-basic-properties": "顯示基本屬性",
|
||||||
"toggle-file-properties": "顯示文件屬性",
|
"toggle-file-properties": "顯示文件屬性",
|
||||||
"toggle-image-properties": "顯示圖像屬性",
|
"toggle-image-properties": "顯示圖像屬性",
|
||||||
"toggle-owned-attributes": "顯示擁有的屬性",
|
"toggle-owned-attributes": "顯示擁有的屬性",
|
||||||
"toggle-inherited-attributes": "顯示繼承的屬性",
|
"toggle-inherited-attributes": "顯示繼承的屬性",
|
||||||
"toggle-promoted-attributes": "顯示提升的屬性",
|
"toggle-promoted-attributes": "顯示提升的屬性",
|
||||||
"toggle-link-map": "顯示連結地圖",
|
"toggle-link-map": "顯示連結地圖",
|
||||||
"toggle-note-info": "顯示筆記資訊",
|
"toggle-note-info": "顯示筆記資訊",
|
||||||
"toggle-note-paths": "顯示筆記路徑",
|
"toggle-note-paths": "顯示筆記路徑",
|
||||||
"toggle-similar-notes": "顯示相似筆記",
|
"toggle-similar-notes": "顯示相似筆記",
|
||||||
"other": "其他",
|
"other": "其他",
|
||||||
"toggle-right-pane": "切換右側面板的顯示,包括目錄和高亮",
|
"toggle-right-pane": "切換右側面板的顯示,包括目錄和高亮",
|
||||||
"print-active-note": "列印目前筆記",
|
"print-active-note": "列印目前筆記",
|
||||||
"open-note-externally": "以預設應用程式打開筆記文件",
|
"open-note-externally": "以預設應用程式打開筆記文件",
|
||||||
"render-active-note": "渲染(重新渲染)目前筆記",
|
"render-active-note": "渲染(重新渲染)目前筆記",
|
||||||
"run-active-note": "執行目前的 JavaScript(前端/後端)程式碼筆記",
|
"run-active-note": "執行目前的 JavaScript(前端/後端)程式碼筆記",
|
||||||
"toggle-note-hoisting": "提升目前筆記",
|
"toggle-note-hoisting": "聚焦目前筆記",
|
||||||
"unhoist": "從任何地方取消提升",
|
"unhoist": "取消任何聚焦",
|
||||||
"reload-frontend-app": "重新載入前端應用",
|
"reload-frontend-app": "重新載入前端應用",
|
||||||
"open-dev-tools": "打開開發者工具",
|
"open-dev-tools": "打開開發者工具",
|
||||||
"toggle-left-note-tree-panel": "顯示左側(筆記樹)面板",
|
"toggle-left-note-tree-panel": "顯示左側(筆記樹)面板",
|
||||||
"toggle-full-screen": "切換全螢幕",
|
"toggle-full-screen": "切換全螢幕",
|
||||||
"zoom-out": "縮小",
|
"zoom-out": "縮小",
|
||||||
"zoom-in": "放大",
|
"zoom-in": "放大",
|
||||||
"note-navigation": "筆記導航",
|
"note-navigation": "筆記導航",
|
||||||
"reset-zoom-level": "重設縮放比例",
|
"reset-zoom-level": "重設縮放比例",
|
||||||
"copy-without-formatting": "以純文字複製選定文字",
|
"copy-without-formatting": "以純文字複製選定文字",
|
||||||
"force-save-revision": "強制新增/儲存目前筆記的新版本",
|
"force-save-revision": "強制新增/儲存目前筆記的新版本",
|
||||||
"show-help": "顯示用戶指南",
|
"show-help": "顯示用戶說明",
|
||||||
"toggle-book-properties": "顯示書籍屬性",
|
"toggle-book-properties": "顯示集合屬性",
|
||||||
"back-in-note-history": "跳轉至歷史記錄中的上一個筆記",
|
"back-in-note-history": "跳轉至歷史記錄中的上一個筆記",
|
||||||
"forward-in-note-history": "跳轉至歷史記錄中的下一個筆記",
|
"forward-in-note-history": "跳轉至歷史記錄中的下一個筆記",
|
||||||
"open-command-palette": "打開命令面板",
|
"open-command-palette": "打開命令面板",
|
||||||
"scroll-to-active-note": "滾動筆記樹到目前筆記",
|
"scroll-to-active-note": "滾動筆記樹至目前筆記",
|
||||||
"quick-search": "開啟快速搜尋列",
|
"quick-search": "開啟快速搜尋列",
|
||||||
"create-note-after": "新增筆記於目前筆記之後",
|
"create-note-after": "新增筆記於目前筆記之後",
|
||||||
"create-note-into": "新增目前筆記的子筆記",
|
"create-note-into": "新增目前筆記的子筆記",
|
||||||
"clone-notes-to": "複製選定筆記的複本至",
|
"clone-notes-to": "克隆選定的筆記至",
|
||||||
"move-notes-to": "移動選定的筆記至",
|
"move-notes-to": "移動選定的筆記至",
|
||||||
"show-cheatsheet": "顯示常用鍵盤快捷鍵",
|
"show-cheatsheet": "顯示常用鍵盤快捷鍵",
|
||||||
"find-in-text": "顯示搜尋面板",
|
"find-in-text": "顯示搜尋面板",
|
||||||
"toggle-classic-editor-toolbar": "顯示固定工具列編輯器的格式分頁",
|
"toggle-classic-editor-toolbar": "顯示固定工具列編輯器的格式分頁",
|
||||||
"export-as-pdf": "匯出目前筆記為 PDF",
|
"export-as-pdf": "匯出目前筆記為 PDF",
|
||||||
"toggle-zen-mode": "啟用/禁用禪模式(極簡界面以專注編輯)"
|
"toggle-zen-mode": "啟用/禁用禪模式(極簡界面以專注編輯)"
|
||||||
},
|
},
|
||||||
"login": {
|
"login": {
|
||||||
"title": "登入",
|
"title": "登入",
|
||||||
"heading": "Trilium 登入",
|
"heading": "Trilium 登入",
|
||||||
"incorrect-password": "密碼不正確。請再試一次。",
|
"incorrect-password": "密碼不正確。請再試一次。",
|
||||||
"password": "密碼",
|
"password": "密碼",
|
||||||
"remember-me": "記住我",
|
"remember-me": "記住我",
|
||||||
"button": "登入",
|
"button": "登入",
|
||||||
"incorrect-totp": "TOTP 不正確。請再試一次。",
|
"incorrect-totp": "TOTP 不正確。請再試一次。",
|
||||||
"sign_in_with_sso": "用 {{ ssoIssuerName }} 登入"
|
"sign_in_with_sso": "用 {{ ssoIssuerName }} 登入"
|
||||||
},
|
},
|
||||||
"set_password": {
|
"set_password": {
|
||||||
"title": "設定密碼",
|
"title": "設定密碼",
|
||||||
"heading": "設定密碼",
|
"heading": "設定密碼",
|
||||||
"description": "在由網頁開始使用 Trilium 之前,您需要先設定一個密碼並用此密碼登入。",
|
"description": "在由網頁開始使用 Trilium 之前,您需要先設定一個密碼並用此密碼登入。",
|
||||||
"password": "密碼",
|
"password": "密碼",
|
||||||
"password-confirmation": "確認密碼",
|
"password-confirmation": "確認密碼",
|
||||||
"button": "設定密碼"
|
"button": "設定密碼"
|
||||||
},
|
},
|
||||||
"javascript-required": "Trilium 需要啟用 JavaScript。",
|
"javascript-required": "Trilium 需要啟用 JavaScript。",
|
||||||
"setup": {
|
"setup": {
|
||||||
"heading": "Trilium 筆記設定",
|
"heading": "Trilium 筆記設定",
|
||||||
"new-document": "我是新用戶,我想為我的筆記建立一個新的 Trilium 文件",
|
"new-document": "我是新用戶,我想為我的筆記建立一個新的 Trilium 文件",
|
||||||
"sync-from-desktop": "我已經擁有桌面版本,想設定與它進行同步",
|
"sync-from-desktop": "我已經擁有桌面版本,想設定與它進行同步",
|
||||||
"sync-from-server": "我已經擁有伺服器版本,想設定與它進行同步",
|
"sync-from-server": "我已經擁有伺服器版本,想設定與它進行同步",
|
||||||
"next": "下一步",
|
"next": "下一步",
|
||||||
"init-in-progress": "文件正在初始化",
|
"init-in-progress": "文件正在初始化",
|
||||||
"redirecting": "您即將被重新導向至應用程式。",
|
"redirecting": "您即將被重新導向至應用程式。",
|
||||||
"title": "設定"
|
"title": "設定"
|
||||||
},
|
},
|
||||||
"setup_sync-from-desktop": {
|
"setup_sync-from-desktop": {
|
||||||
"heading": "從桌面版同步",
|
"heading": "從桌面版同步",
|
||||||
"description": "此設定需要從桌面版本啟動:",
|
"description": "此設定需要從桌面版本啟動:",
|
||||||
"step1": "打開您的桌面版 TriliumNext 筆記。",
|
"step1": "打開您的桌面版 Trilium 筆記。",
|
||||||
"step2": "從 Trilium 選單中,點擊「選項」。",
|
"step2": "從 Trilium 選單中,點擊「選項」。",
|
||||||
"step3": "點擊「同步」類別。",
|
"step3": "點擊「同步」類別。",
|
||||||
"step4": "將伺服器版網址更改為:{{- host}} 並點擊保存。",
|
"step4": "將伺服器版網址更改為:{{- host}} 並點擊儲存。",
|
||||||
"step5": "點擊「測試同步」以驗證連接是否成功。",
|
"step5": "點擊「測試同步」以驗證連接是否成功。",
|
||||||
"step6": "完成這些步驟後,點擊 {{- link}}。",
|
"step6": "完成這些步驟後,點擊 {{- link}}。",
|
||||||
"step6-here": "這裡"
|
"step6-here": "這裡"
|
||||||
},
|
},
|
||||||
"setup_sync-from-server": {
|
"setup_sync-from-server": {
|
||||||
"heading": "從伺服器同步",
|
"heading": "從伺服器同步",
|
||||||
"instructions": "請在下方輸入 Trilium 伺服器網址和密碼。這將從伺服器下載整個 Trilium 數據庫檔案並同步。取決於數據庫大小和您的連接速度,這可能需要一段時間。",
|
"instructions": "請在下方輸入 Trilium 伺服器網址和密碼。這將從伺服器下載整個 Trilium 數據庫檔案並同步。取決於數據庫大小和您的連接速度,這可能需要一段時間。",
|
||||||
"server-host": "Trilium 伺服器網址",
|
"server-host": "Trilium 伺服器網址",
|
||||||
"server-host-placeholder": "https://<主機名稱>:<端口>",
|
"server-host-placeholder": "https://<主機名稱>:<端口>",
|
||||||
"proxy-server": "代理伺服器(可選)",
|
"proxy-server": "代理伺服器(可選)",
|
||||||
"proxy-server-placeholder": "https://<主機名稱>:<端口>",
|
"proxy-server-placeholder": "https://<主機名稱>:<端口>",
|
||||||
"note": "注意:",
|
"note": "注意:",
|
||||||
"proxy-instruction": "如果您將代理設定留空,將使用系統代理(僅適用於桌面版)",
|
"proxy-instruction": "如果您將代理設定留空,將使用系統代理(僅適用於桌面版)",
|
||||||
"password": "密碼",
|
"password": "密碼",
|
||||||
"password-placeholder": "密碼",
|
"password-placeholder": "密碼",
|
||||||
"back": "返回",
|
"back": "返回",
|
||||||
"finish-setup": "完成設定"
|
"finish-setup": "完成設定"
|
||||||
},
|
},
|
||||||
"setup_sync-in-progress": {
|
"setup_sync-in-progress": {
|
||||||
"heading": "同步中",
|
"heading": "正在同步",
|
||||||
"successful": "已正確設定同步。初次同步可能需要一些時間。完成後,您將被重新導向至登入頁面。",
|
"successful": "已正確設定同步。初次同步可能需要一些時間。完成後,您將被重新導向至登入頁面。",
|
||||||
"outstanding-items": "未完成的同步項目:",
|
"outstanding-items": "未完成的同步項目:",
|
||||||
"outstanding-items-default": "無"
|
"outstanding-items-default": "無"
|
||||||
},
|
},
|
||||||
"share_404": {
|
"share_404": {
|
||||||
"title": "未找到",
|
"title": "未找到",
|
||||||
"heading": "未找到"
|
"heading": "未找到"
|
||||||
},
|
},
|
||||||
"share_page": {
|
"share_page": {
|
||||||
"parent": "父級:",
|
"parent": "父級:",
|
||||||
"clipped-from": "此筆記最初自 {{- url}} 剪下",
|
"clipped-from": "此筆記最初自 {{- url}} 剪下",
|
||||||
"child-notes": "子筆記:",
|
"child-notes": "子筆記:",
|
||||||
"no-content": "此筆記沒有內容。"
|
"no-content": "此筆記沒有內容。"
|
||||||
},
|
},
|
||||||
"weekdays": {
|
"weekdays": {
|
||||||
"monday": "週一",
|
"monday": "週一",
|
||||||
"tuesday": "週二",
|
"tuesday": "週二",
|
||||||
"wednesday": "週三",
|
"wednesday": "週三",
|
||||||
"thursday": "週四",
|
"thursday": "週四",
|
||||||
"friday": "週五",
|
"friday": "週五",
|
||||||
"saturday": "週六",
|
"saturday": "週六",
|
||||||
"sunday": "週日"
|
"sunday": "週日"
|
||||||
},
|
},
|
||||||
"months": {
|
"months": {
|
||||||
"january": "一月",
|
"january": "一月",
|
||||||
"february": "二月",
|
"february": "二月",
|
||||||
"march": "三月",
|
"march": "三月",
|
||||||
"april": "四月",
|
"april": "四月",
|
||||||
"may": "五月",
|
"may": "五月",
|
||||||
"june": "六月",
|
"june": "六月",
|
||||||
"july": "七月",
|
"july": "七月",
|
||||||
"august": "八月",
|
"august": "八月",
|
||||||
"september": "九月",
|
"september": "九月",
|
||||||
"october": "十月",
|
"october": "十月",
|
||||||
"november": "十一月",
|
"november": "十一月",
|
||||||
"december": "十二月"
|
"december": "十二月"
|
||||||
},
|
},
|
||||||
"special_notes": {
|
"special_notes": {
|
||||||
"search_prefix": "搜尋:"
|
"search_prefix": "搜尋:"
|
||||||
},
|
},
|
||||||
"test_sync": {
|
"test_sync": {
|
||||||
"not-configured": "尚未設定同步伺服器主機,請先設定同步。",
|
"not-configured": "尚未設定同步伺服器主機,請先設定同步。",
|
||||||
"successful": "成功與同步伺服器握手,已開始同步。"
|
"successful": "成功與同步伺服器握手,已開始同步。"
|
||||||
},
|
},
|
||||||
"keyboard_action_names": {
|
"keyboard_action_names": {
|
||||||
"zoom-in": "放大",
|
"zoom-in": "放大",
|
||||||
"reset-zoom-level": "重設縮放比例",
|
"reset-zoom-level": "重設縮放比例",
|
||||||
"zoom-out": "縮小",
|
"zoom-out": "縮小",
|
||||||
"copy-without-formatting": "以純文字複製",
|
"copy-without-formatting": "以純文字複製",
|
||||||
"force-save-revision": "強制儲存修改版本",
|
"force-save-revision": "強制儲存修改版本",
|
||||||
"back-in-note-history": "返回筆記歷史",
|
"back-in-note-history": "返回筆記歷史",
|
||||||
"forward-in-note-history": "前進筆記歷史",
|
"forward-in-note-history": "前進筆記歷史",
|
||||||
"jump-to-note": "跳轉至…",
|
"jump-to-note": "跳轉至…",
|
||||||
"scroll-to-active-note": "滾動到目前筆記",
|
"scroll-to-active-note": "滾動至目前筆記",
|
||||||
"quick-search": "快速搜尋",
|
"quick-search": "快速搜尋",
|
||||||
"search-in-subtree": "在子階層中搜尋",
|
"search-in-subtree": "在子階層中搜尋",
|
||||||
"expand-subtree": "展開子階層",
|
"expand-subtree": "展開子階層",
|
||||||
"collapse-tree": "收合筆記樹",
|
"collapse-tree": "收摺筆記樹",
|
||||||
"collapse-subtree": "收合子階層",
|
"collapse-subtree": "收摺子階層",
|
||||||
"sort-child-notes": "排序子筆記",
|
"sort-child-notes": "排序子筆記",
|
||||||
"create-note-after": "於後面新建筆記",
|
"create-note-after": "於後面新建筆記",
|
||||||
"create-note-into": "新建筆記至",
|
"create-note-into": "新建筆記至",
|
||||||
"create-note-into-inbox": "新建筆記至收件匣",
|
"create-note-into-inbox": "新建筆記至收件匣",
|
||||||
"delete-notes": "刪除筆記",
|
"delete-notes": "刪除筆記",
|
||||||
"move-note-up": "上移筆記",
|
"move-note-up": "上移筆記",
|
||||||
"move-note-down": "下移筆記",
|
"move-note-down": "下移筆記",
|
||||||
"move-note-up-in-hierarchy": "上移筆記階層",
|
"move-note-up-in-hierarchy": "上移筆記階層",
|
||||||
"move-note-down-in-hierarchy": "下移筆記階層",
|
"move-note-down-in-hierarchy": "下移筆記階層",
|
||||||
"edit-note-title": "編輯筆記標題",
|
"edit-note-title": "編輯筆記標題",
|
||||||
"edit-branch-prefix": "編輯分支前綴",
|
"edit-branch-prefix": "編輯分支前綴",
|
||||||
"clone-notes-to": "複製筆記至",
|
"clone-notes-to": "克隆筆記至",
|
||||||
"move-notes-to": "移動筆記至",
|
"move-notes-to": "移動筆記至",
|
||||||
"copy-notes-to-clipboard": "複製筆記至剪貼簿",
|
"copy-notes-to-clipboard": "複製筆記至剪貼簿",
|
||||||
"paste-notes-from-clipboard": "從剪貼簿貼上筆記",
|
"paste-notes-from-clipboard": "從剪貼簿貼上筆記",
|
||||||
"cut-notes-to-clipboard": "剪下筆記至剪貼簿",
|
"cut-notes-to-clipboard": "剪下筆記至剪貼簿",
|
||||||
"select-all-notes-in-parent": "選擇父階層所有筆記",
|
"select-all-notes-in-parent": "選擇父階層所有筆記",
|
||||||
"add-note-above-to-selection": "加入上方筆記至選擇中",
|
"add-note-above-to-selection": "加入上方筆記至選擇中",
|
||||||
"add-note-below-to-selection": "加入下方筆記至選擇中",
|
"add-note-below-to-selection": "加入下方筆記至選擇中",
|
||||||
"duplicate-subtree": "複製子階層",
|
"duplicate-subtree": "複製子階層",
|
||||||
"open-new-tab": "打開新分頁",
|
"open-new-tab": "打開新分頁",
|
||||||
"close-active-tab": "關閉目前分頁",
|
"close-active-tab": "關閉目前分頁",
|
||||||
"reopen-last-tab": "重新打開最後關閉的分頁",
|
"reopen-last-tab": "重新打開最後關閉的分頁",
|
||||||
"activate-next-tab": "切換至下一分頁",
|
"activate-next-tab": "切換至下一分頁",
|
||||||
"activate-previous-tab": "切換至上一分頁",
|
"activate-previous-tab": "切換至上一分頁",
|
||||||
"open-new-window": "打開新視窗",
|
"open-new-window": "打開新視窗",
|
||||||
"toggle-system-tray-icon": "顯示/隱藏系統列圖示",
|
"toggle-system-tray-icon": "顯示/隱藏系統匣圖示",
|
||||||
"toggle-zen-mode": "啟用/禁用禪模式",
|
"toggle-zen-mode": "啟用/禁用禪模式",
|
||||||
"switch-to-first-tab": "切換至第一個分頁",
|
"switch-to-first-tab": "切換至第一個分頁",
|
||||||
"switch-to-second-tab": "切換至第二個分頁",
|
"switch-to-second-tab": "切換至第二個分頁",
|
||||||
"switch-to-third-tab": "切換至第三個分頁",
|
"switch-to-third-tab": "切換至第三個分頁",
|
||||||
"switch-to-fourth-tab": "切換至第四個分頁",
|
"switch-to-fourth-tab": "切換至第四個分頁",
|
||||||
"switch-to-fifth-tab": "切換至第五個分頁",
|
"switch-to-fifth-tab": "切換至第五個分頁",
|
||||||
"switch-to-sixth-tab": "切換至第六個分頁",
|
"switch-to-sixth-tab": "切換至第六個分頁",
|
||||||
"switch-to-seventh-tab": "切換至第七個分頁",
|
"switch-to-seventh-tab": "切換至第七個分頁",
|
||||||
"switch-to-eighth-tab": "切換至第八個分頁",
|
"switch-to-eighth-tab": "切換至第八個分頁",
|
||||||
"switch-to-ninth-tab": "切換至第九個分頁",
|
"switch-to-ninth-tab": "切換至第九個分頁",
|
||||||
"switch-to-last-tab": "切換至第最後一個分頁",
|
"switch-to-last-tab": "切換至第最後一個分頁",
|
||||||
"show-note-source": "顯示筆記原始碼",
|
"show-note-source": "顯示筆記原始碼",
|
||||||
"show-options": "顯示選項",
|
"show-options": "顯示選項",
|
||||||
"show-revisions": "顯示修改歷史",
|
"show-revisions": "顯示修改歷史",
|
||||||
"show-recent-changes": "顯示最近更改",
|
"show-recent-changes": "顯示最近更改",
|
||||||
"show-sql-console": "顯示 SQL 控制台",
|
"show-sql-console": "顯示 SQL 控制台",
|
||||||
"show-backend-log": "顯示後端日誌",
|
"show-backend-log": "顯示後端日誌",
|
||||||
"show-help": "顯示幫助",
|
"show-help": "顯示說明",
|
||||||
"show-cheatsheet": "顯示快捷鍵指南",
|
"show-cheatsheet": "顯示快捷鍵指南",
|
||||||
"add-link-to-text": "插入連結",
|
"add-link-to-text": "插入連結",
|
||||||
"follow-link-under-cursor": "開啟游標處的連結",
|
"follow-link-under-cursor": "開啟游標處的連結",
|
||||||
"insert-date-and-time-to-text": "插入日期和時間",
|
"insert-date-and-time-to-text": "插入日期和時間",
|
||||||
"paste-markdown-into-text": "貼上 Markdown 文字",
|
"paste-markdown-into-text": "貼上 Markdown 文字",
|
||||||
"cut-into-note": "剪下至筆記",
|
"cut-into-note": "剪下至筆記",
|
||||||
"add-include-note-to-text": "添加包含筆記",
|
"add-include-note-to-text": "新增包含筆記",
|
||||||
"edit-read-only-note": "編輯唯讀筆記",
|
"edit-read-only-note": "編輯唯讀筆記",
|
||||||
"add-new-label": "新增標籤",
|
"add-new-label": "新增標籤",
|
||||||
"add-new-relation": "新增關係",
|
"add-new-relation": "新增關聯",
|
||||||
"toggle-ribbon-tab-classic-editor": "顯示功能區分頁:經典編輯器",
|
"toggle-ribbon-tab-classic-editor": "顯示功能區分頁:經典編輯器",
|
||||||
"toggle-ribbon-tab-basic-properties": "顯示功能區分頁:基本屬性",
|
"toggle-ribbon-tab-basic-properties": "顯示功能區分頁:基本屬性",
|
||||||
"toggle-ribbon-tab-book-properties": "顯示功能區分頁:書籍屬性",
|
"toggle-ribbon-tab-book-properties": "顯示功能區分頁:書籍屬性",
|
||||||
"toggle-ribbon-tab-file-properties": "顯示功能區分頁:文件屬性",
|
"toggle-ribbon-tab-file-properties": "顯示功能區分頁:文件屬性",
|
||||||
"toggle-ribbon-tab-image-properties": "顯示功能區分頁:圖片屬性",
|
"toggle-ribbon-tab-image-properties": "顯示功能區分頁:圖片屬性",
|
||||||
"toggle-ribbon-tab-owned-attributes": "顯示功能區分頁:自有屬性",
|
"toggle-ribbon-tab-owned-attributes": "顯示功能區分頁:自有屬性",
|
||||||
"toggle-ribbon-tab-inherited-attributes": "顯示功能區分頁:繼承屬性",
|
"toggle-ribbon-tab-inherited-attributes": "顯示功能區分頁:繼承屬性",
|
||||||
"toggle-ribbon-tab-promoted-attributes": "顯示功能區分頁:提升屬性",
|
"toggle-ribbon-tab-promoted-attributes": "顯示功能區分頁:提升屬性",
|
||||||
"toggle-ribbon-tab-note-map": "顯示功能區分頁:筆記地圖",
|
"toggle-ribbon-tab-note-map": "顯示功能區分頁:筆記地圖",
|
||||||
"toggle-ribbon-tab-note-info": "顯示功能區分頁:筆記資訊",
|
"toggle-ribbon-tab-note-info": "顯示功能區分頁:筆記資訊",
|
||||||
"toggle-ribbon-tab-note-paths": "顯示功能區分頁:筆記路徑",
|
"toggle-ribbon-tab-note-paths": "顯示功能區分頁:筆記路徑",
|
||||||
"toggle-ribbon-tab-similar-notes": "顯示功能區分頁:相似筆記",
|
"toggle-ribbon-tab-similar-notes": "顯示功能區分頁:相似筆記",
|
||||||
"toggle-right-pane": "打開右側面板",
|
"toggle-right-pane": "打開右側面板",
|
||||||
"print-active-note": "列印目前筆記",
|
"print-active-note": "列印目前筆記",
|
||||||
"export-active-note-as-pdf": "匯出目前筆記為 PDF",
|
"export-active-note-as-pdf": "匯出目前筆記為 PDF",
|
||||||
"open-note-externally": "於外部打開筆記",
|
"open-note-externally": "於外部打開筆記",
|
||||||
"render-active-note": "渲染目前筆記",
|
"render-active-note": "渲染目前筆記",
|
||||||
"run-active-note": "執行目前筆記",
|
"run-active-note": "執行目前筆記",
|
||||||
"toggle-note-hoisting": "提升筆記",
|
"toggle-note-hoisting": "聚焦筆記",
|
||||||
"unhoist-note": "取消提升筆記",
|
"unhoist-note": "取消聚焦筆記",
|
||||||
"reload-frontend-app": "重新載入前端程式",
|
"reload-frontend-app": "重新載入前端程式",
|
||||||
"open-developer-tools": "打開開發者工具",
|
"open-developer-tools": "打開開發者工具",
|
||||||
"find-in-text": "在文字中尋找",
|
"find-in-text": "在文字中尋找",
|
||||||
"toggle-left-pane": "打開左側面板",
|
"toggle-left-pane": "打開左側面板",
|
||||||
"toggle-full-screen": "切換全螢幕",
|
"toggle-full-screen": "切換全螢幕",
|
||||||
"command-palette": "命令面板"
|
"command-palette": "命令面板"
|
||||||
},
|
},
|
||||||
"weekdayNumber": "第 {weekNumber} 週",
|
"weekdayNumber": "第 {weekNumber} 週",
|
||||||
"quarterNumber": "第 {quarterNumber} 季度",
|
"quarterNumber": "第 {quarterNumber} 季度",
|
||||||
"hidden-subtree": {
|
"hidden-subtree": {
|
||||||
"root-title": "隱藏的筆記",
|
"root-title": "隱藏的筆記",
|
||||||
"search-history-title": "搜尋歷史",
|
"search-history-title": "搜尋歷史",
|
||||||
"note-map-title": "筆記地圖",
|
"note-map-title": "筆記地圖",
|
||||||
"sql-console-history-title": "SQL 控制台歷史",
|
"sql-console-history-title": "SQL 控制台歷史",
|
||||||
"shared-notes-title": "分享筆記",
|
"shared-notes-title": "分享筆記",
|
||||||
"bulk-action-title": "批次操作",
|
"bulk-action-title": "批次操作",
|
||||||
"backend-log-title": "後端日誌",
|
"backend-log-title": "後端日誌",
|
||||||
"user-hidden-title": "隱藏的用戶",
|
"user-hidden-title": "隱藏的用戶",
|
||||||
"launch-bar-templates-title": "啟動欄模版",
|
"launch-bar-templates-title": "啟動列模版",
|
||||||
"base-abstract-launcher-title": "基礎摘要啟動器",
|
"base-abstract-launcher-title": "基礎摘要啟動器",
|
||||||
"command-launcher-title": "命令啟動器",
|
"command-launcher-title": "命令啟動器",
|
||||||
"note-launcher-title": "筆記啟動器",
|
"note-launcher-title": "筆記啟動器",
|
||||||
"script-launcher-title": "腳本啟動器",
|
"script-launcher-title": "腳本啟動器",
|
||||||
"built-in-widget-title": "內建小工具",
|
"built-in-widget-title": "內建小工具",
|
||||||
"spacer-title": "空白占位",
|
"spacer-title": "空白占位",
|
||||||
"custom-widget-title": "自定義小工具",
|
"custom-widget-title": "自訂小工具",
|
||||||
"launch-bar-title": "啟動欄",
|
"launch-bar-title": "啟動列",
|
||||||
"available-launchers-title": "可用啟動器",
|
"available-launchers-title": "可用啟動器",
|
||||||
"go-to-previous-note-title": "跳轉到前一筆記",
|
"go-to-previous-note-title": "跳轉至前一筆記",
|
||||||
"go-to-next-note-title": "跳轉到後一筆記",
|
"go-to-next-note-title": "跳轉至後一筆記",
|
||||||
"new-note-title": "新增筆記",
|
"new-note-title": "新增筆記",
|
||||||
"search-notes-title": "搜尋筆記",
|
"search-notes-title": "搜尋筆記",
|
||||||
"jump-to-note-title": "跳轉至…",
|
"jump-to-note-title": "跳轉至…",
|
||||||
"calendar-title": "日曆",
|
"calendar-title": "日曆",
|
||||||
"recent-changes-title": "最近修改",
|
"recent-changes-title": "最近修改",
|
||||||
"bookmarks-title": "書籤",
|
"bookmarks-title": "書籤",
|
||||||
"open-today-journal-note-title": "打開今日日記筆記",
|
"open-today-journal-note-title": "打開今日日記筆記",
|
||||||
"quick-search-title": "快速搜尋",
|
"quick-search-title": "快速搜尋",
|
||||||
"protected-session-title": "受保護的作業階段",
|
"protected-session-title": "受保護的作業階段",
|
||||||
"sync-status-title": "同步狀態",
|
"sync-status-title": "同步狀態",
|
||||||
"settings-title": "設定",
|
"settings-title": "設定",
|
||||||
"llm-chat-title": "與筆記聊天",
|
"llm-chat-title": "與筆記聊天",
|
||||||
"options-title": "選項",
|
"options-title": "選項",
|
||||||
"appearance-title": "外觀",
|
"appearance-title": "外觀",
|
||||||
"shortcuts-title": "快捷鍵",
|
"shortcuts-title": "快捷鍵",
|
||||||
"text-notes": "文字筆記",
|
"text-notes": "文字筆記",
|
||||||
"code-notes-title": "程式碼筆記",
|
"code-notes-title": "程式碼筆記",
|
||||||
"images-title": "圖片",
|
"images-title": "圖片",
|
||||||
"spellcheck-title": "拼寫檢查",
|
"spellcheck-title": "拼寫檢查",
|
||||||
"password-title": "密碼",
|
"password-title": "密碼",
|
||||||
"multi-factor-authentication-title": "多重身份驗證",
|
"multi-factor-authentication-title": "多重身份驗證",
|
||||||
"etapi-title": "ETAPI",
|
"etapi-title": "ETAPI",
|
||||||
"backup-title": "備份",
|
"backup-title": "備份",
|
||||||
"sync-title": "同步",
|
"sync-title": "同步",
|
||||||
"ai-llm-title": "AI/LLM",
|
"ai-llm-title": "AI/LLM",
|
||||||
"other": "其他",
|
"other": "其他",
|
||||||
"advanced-title": "進階",
|
"advanced-title": "進階",
|
||||||
"visible-launchers-title": "可見啟動器",
|
"visible-launchers-title": "可見啟動器",
|
||||||
"user-guide": "使用指南",
|
"user-guide": "用戶說明",
|
||||||
"localization": "語言和區域",
|
"localization": "語言和區域",
|
||||||
"inbox-title": "收件匣"
|
"inbox-title": "收件匣"
|
||||||
},
|
},
|
||||||
"notes": {
|
"notes": {
|
||||||
"new-note": "新增筆記",
|
"new-note": "新增筆記",
|
||||||
"duplicate-note-suffix": "(重複)",
|
"duplicate-note-suffix": "(重複)",
|
||||||
"duplicate-note-title": "{{- noteTitle }} {{ duplicateNoteSuffix }}"
|
"duplicate-note-title": "{{- noteTitle }} {{ duplicateNoteSuffix }}"
|
||||||
},
|
},
|
||||||
"backend_log": {
|
"backend_log": {
|
||||||
"log-does-not-exist": "後端日誌文件 '{{ fileName }}' 暫不存在。",
|
"log-does-not-exist": "後端日誌文件 '{{ fileName }}' 暫不存在。",
|
||||||
"reading-log-failed": "讀取後端日誌文件 '{{ fileName }}' 失敗。"
|
"reading-log-failed": "讀取後端日誌文件 '{{ fileName }}' 失敗。"
|
||||||
},
|
},
|
||||||
"content_renderer": {
|
"content_renderer": {
|
||||||
"note-cannot-be-displayed": "無法顯示此類型筆記。"
|
"note-cannot-be-displayed": "無法顯示此類型筆記。"
|
||||||
},
|
},
|
||||||
"pdf": {
|
"pdf": {
|
||||||
"export_filter": "PDF 文件 (*.pdf)",
|
"export_filter": "PDF 文件 (*.pdf)",
|
||||||
"unable-to-export-message": "目前筆記無法被匯出為 PDF 。",
|
"unable-to-export-message": "目前筆記無法被匯出為 PDF 。",
|
||||||
"unable-to-export-title": "無法匯出為 PDF",
|
"unable-to-export-title": "無法匯出為 PDF",
|
||||||
"unable-to-save-message": "選定文件無法被寫入。請重試或選擇其他路徑。"
|
"unable-to-save-message": "選定文件無法被寫入。請重試或選擇其他路徑。"
|
||||||
},
|
},
|
||||||
"tray": {
|
"tray": {
|
||||||
"tooltip": "Trilium 筆記",
|
"tooltip": "Trilium 筆記",
|
||||||
"close": "退出 Trilium",
|
"close": "退出 Trilium",
|
||||||
"recents": "最近筆記",
|
"recents": "最近筆記",
|
||||||
"bookmarks": "書籤",
|
"bookmarks": "書籤",
|
||||||
"today": "打開今日日記筆記",
|
"today": "打開今日日記筆記",
|
||||||
"new-note": "新增筆記",
|
"new-note": "新增筆記",
|
||||||
"show-windows": "顯示視窗",
|
"show-windows": "顯示視窗",
|
||||||
"open_new_window": "打開新視窗"
|
"open_new_window": "打開新視窗"
|
||||||
},
|
},
|
||||||
"migration": {
|
"migration": {
|
||||||
"old_version": "您目前的版本不支援直接遷移。請先更新至最新的 v0.60.4 然後再到此版本。",
|
"old_version": "您目前的版本不支援直接遷移。請先更新至最新的 v0.60.4 然後再到此版本。",
|
||||||
"error_message": "遷移至版本 {{version}} 時發生錯誤:{{stack}}",
|
"error_message": "遷移至版本 {{version}} 時發生錯誤:{{stack}}",
|
||||||
"wrong_db_version": "資料庫版本({{version}})比程式預期({{targetVersion}})新,這意味著它由一個更新且不相容的 Trilium 版本所創建。升級至最新版的 Trilium 以解決此問題。"
|
"wrong_db_version": "資料庫版本({{version}})比程式預期({{targetVersion}})新,這意味著它由一個更新且不相容的 Trilium 版本所創建。升級至最新版的 Trilium 以解決此問題。"
|
||||||
},
|
},
|
||||||
"modals": {
|
"modals": {
|
||||||
"error_title": "錯誤"
|
"error_title": "錯誤"
|
||||||
},
|
},
|
||||||
"share_theme": {
|
"share_theme": {
|
||||||
"site-theme": "網站主題",
|
"site-theme": "網站主題",
|
||||||
"search_placeholder": "搜尋…",
|
"search_placeholder": "搜尋…",
|
||||||
"image_alt": "文章圖片",
|
"image_alt": "文章圖片",
|
||||||
"last-updated": "最近於 {{- date}} 更新",
|
"last-updated": "最近於 {{- date}} 更新",
|
||||||
"subpages": "子頁面:",
|
"subpages": "子頁面:",
|
||||||
"on-this-page": "本頁內容",
|
"on-this-page": "本頁內容",
|
||||||
"expand": "展開"
|
"expand": "展開"
|
||||||
},
|
},
|
||||||
"hidden_subtree_templates": {
|
"hidden_subtree_templates": {
|
||||||
"text-snippet": "文字片段",
|
"text-snippet": "文字片段",
|
||||||
"description": "描述",
|
"description": "描述",
|
||||||
"list-view": "列表顯示",
|
"list-view": "列表顯示",
|
||||||
"grid-view": "網格顯示",
|
"grid-view": "網格顯示",
|
||||||
"calendar": "日曆",
|
"calendar": "日曆",
|
||||||
"table": "表格",
|
"table": "表格",
|
||||||
"geo-map": "地理地圖",
|
"geo-map": "地理地圖",
|
||||||
"start-date": "開始日期",
|
"start-date": "開始日期",
|
||||||
"end-date": "結束日期",
|
"end-date": "結束日期",
|
||||||
"start-time": "開始時間",
|
"start-time": "開始時間",
|
||||||
"end-time": "結束時間",
|
"end-time": "結束時間",
|
||||||
"geolocation": "地理位置",
|
"geolocation": "地理位置",
|
||||||
"built-in-templates": "內建模版",
|
"built-in-templates": "內建模版",
|
||||||
"board": "看板",
|
"board": "看板",
|
||||||
"status": "狀態",
|
"status": "狀態",
|
||||||
"board_note_first": "第一個筆記",
|
"board_note_first": "第一個筆記",
|
||||||
"board_note_second": "第二個筆記",
|
"board_note_second": "第二個筆記",
|
||||||
"board_note_third": "第三個筆記",
|
"board_note_third": "第三個筆記",
|
||||||
"board_status_todo": "待辦",
|
"board_status_todo": "待辦",
|
||||||
"board_status_progress": "進行中",
|
"board_status_progress": "進行中",
|
||||||
"board_status_done": "已完成"
|
"board_status_done": "已完成"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,7 +19,8 @@ const DAYJS_LOADER: Record<LOCALE_IDS, () => Promise<typeof import("dayjs/locale
|
|||||||
"ku": () => import("dayjs/locale/ku.js"),
|
"ku": () => import("dayjs/locale/ku.js"),
|
||||||
"ro": () => import("dayjs/locale/ro.js"),
|
"ro": () => import("dayjs/locale/ro.js"),
|
||||||
"ru": () => import("dayjs/locale/ru.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() {
|
export async function initializeTranslations() {
|
||||||
|
|||||||
@@ -38,6 +38,9 @@ async function cleanupOldLogFiles() {
|
|||||||
const customRetentionDays = config.Logging.retentionDays;
|
const customRetentionDays = config.Logging.retentionDays;
|
||||||
if (customRetentionDays > 0) {
|
if (customRetentionDays > 0) {
|
||||||
retentionDays = customRetentionDays;
|
retentionDays = customRetentionDays;
|
||||||
|
} else if (customRetentionDays <= -1){
|
||||||
|
info(`Log cleanup: keeping all log files, as specified by configuration.`);
|
||||||
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const cutoffDate = new Date();
|
const cutoffDate = new Date();
|
||||||
|
|||||||
1
docs/CNAME
vendored
Normal file
1
docs/CNAME
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
triliumnotes.org
|
||||||
197
docs/README-ZH_CN.md
vendored
197
docs/README-ZH_CN.md
vendored
@@ -1,97 +1,178 @@
|
|||||||
# Trilium Notes
|
# 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)
|
 
|
||||||
|

|
||||||
|

|
||||||
|
[](https://app.relative-ci.com/projects/Di5q7dz9daNDZ9UXi0Bp) [](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>
|
<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)(用于同步讨论)
|
## ⚠️ 为什么是 TriliumNext?
|
||||||
- [Github Discussions](https://github.com/TriliumNext/Trilium/discussions)(用于异步讨论)
|
|
||||||
- [Wiki](https://triliumnext.github.io/Docs/)(用于常见操作问题和用户指南)
|
|
||||||
|
|
||||||
上面链接的两个房间是镜像的,所以您可以在任意平台上使用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 的灵感。
|
如果你的发行版如下表所列,请使用该发行版的套件。
|
||||||
|
|
||||||
|
[](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。
|
||||||
|
|
||||||
|
以下是目前的语言覆盖状态:
|
||||||
|
|
||||||
|
[](https://hosted.weblate.org/engage/trilium/)
|
||||||
|
|
||||||
|
### 程序代码
|
||||||
|
|
||||||
|
下载储存库,使用 `pnpm` 安装相依套件,接着启动服务器(于 http://localhost:8080 提供服务):
|
||||||
```shell
|
```shell
|
||||||
npm install
|
git clone https://github.com/TriliumNext/Trilium.git
|
||||||
npm run server:start
|
cd Trilium
|
||||||
|
pnpm install
|
||||||
|
pnpm run server:start
|
||||||
```
|
```
|
||||||
|
|
||||||
## 👏 致谢
|
### 文件
|
||||||
|
|
||||||
* [CKEditor 5](https://github.com/ckeditor/ckeditor5) - 市面上最好的所见即所得编辑器,拥有互动性强且聆听能力强的团队
|
下载储存库,使用 `pnpm` 安装相依套件,接着启动编辑文件所需的环境:
|
||||||
* [FancyTree](https://github.com/mar10/fancytree) - 一个非常丰富的关于树的库,强大到没有对手。没有它,Trilium Notes 将不会如此。
|
```shell
|
||||||
* [CodeMirror](https://github.com/codemirror/CodeMirror) - 支持大量语言的代码编辑器
|
git clone https://github.com/TriliumNext/Trilium.git
|
||||||
* [jsPlumb](https://github.com/jsplumb/jsplumb) - 强大的可视化连接库。用于[关系图](https://triliumnext.github.io/Docs/Wiki/relation-map)和[链接图](https://triliumnext.github.io/Docs/Wiki/link-map)
|
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 2017–2025 zadam、Elian Doran 与其他贡献者。
|
||||||
|
|
||||||
|
本程序系自由软件:你可以在自由软件基金会(Free Software Foundation)所发布的 GNU Affero 通用公众授权条款(GNU AGPL)第 3 版或(由你选择)任何后续版本之条款下重新散布或修改本程序。
|
||||||
|
|||||||
178
docs/README-ZH_TW.md
vendored
Normal file
178
docs/README-ZH_TW.md
vendored
Normal file
@@ -0,0 +1,178 @@
|
|||||||
|
# Trilium Notes
|
||||||
|
|
||||||
|
 
|
||||||
|

|
||||||
|

|
||||||
|
[](https://app.relative-ci.com/projects/Di5q7dz9daNDZ9UXi0Bp) [](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
|
||||||
|
|
||||||
|
如果你的發行版如下表所列,請使用該發行版的套件。
|
||||||
|
|
||||||
|
[](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。
|
||||||
|
|
||||||
|
以下是目前的語言覆蓋狀態:
|
||||||
|
|
||||||
|
[](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 2017–2025 zadam、Elian Doran 與其他貢獻者。
|
||||||
|
|
||||||
|
本程式係自由軟體:你可以在自由軟體基金會(Free Software Foundation)所發佈的 GNU Affero 通用公眾授權條款(GNU AGPL)第 3 版或(由你選擇)任何後續版本之條款下重新散布或修改本程式。
|
||||||
14
docs/RPM-GPG-KEY-trilium
vendored
Normal file
14
docs/RPM-GPG-KEY-trilium
vendored
Normal 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-----
|
||||||
@@ -24,5 +24,11 @@ retentionDays=7
|
|||||||
|
|
||||||
Or via the environment variable `TRILIUM_LOGGING_RETENTION_DAYS`.
|
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]
|
> [!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.
|
> 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.
|
||||||
@@ -50,6 +50,11 @@ const UNSORTED_LOCALES: Locale[] = [
|
|||||||
name: "Русский",
|
name: "Русский",
|
||||||
electronLocale: "ru"
|
electronLocale: "ru"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
id: "ja",
|
||||||
|
name: "日本語",
|
||||||
|
electronLocale: "ja"
|
||||||
|
},
|
||||||
|
|
||||||
/*
|
/*
|
||||||
* Right to left languages
|
* Right to left languages
|
||||||
|
|||||||
566
pnpm-lock.yaml
generated
566
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user