mirror of
https://github.com/zadam/trilium.git
synced 2025-10-27 00:06:30 +01:00
Compare commits
2 Commits
fix/html-t
...
feat/bette
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5024e27885 | ||
|
|
78c27dbe04 |
@@ -1,6 +1,6 @@
|
||||
root = true
|
||||
|
||||
[*.{js,ts,.tsx}]
|
||||
[*.{js,ts}]
|
||||
charset = utf-8
|
||||
end_of_line = lf
|
||||
indent_size = 4
|
||||
|
||||
22
.github/actions/build-electron/action.yml
vendored
22
.github/actions/build-electron/action.yml
vendored
@@ -162,25 +162,3 @@ runs:
|
||||
echo "Found ZIP: $zip_file"
|
||||
echo "Note: ZIP files are not code signed, but their contents should be"
|
||||
fi
|
||||
|
||||
- name: Sign the RPM
|
||||
if: inputs.os == 'linux'
|
||||
shell: ${{ inputs.shell }}
|
||||
run: |
|
||||
echo -n "$GPG_SIGNING_KEY" | base64 --decode | gpg --import
|
||||
|
||||
# Import the key into RPM for verification
|
||||
gpg --export -a > pubkey
|
||||
rpm --import pubkey
|
||||
rm pubkey
|
||||
|
||||
# Sign the RPM
|
||||
rpm_file=$(find ./apps/desktop/upload -name "*.rpm" -print -quit)
|
||||
rpmsign --define "_gpg_name Trilium Notes Signing Key <triliumnotes@outlook.com>" --addsign "$rpm_file"
|
||||
rpm -Kv "$rpm_file"
|
||||
|
||||
# Validate code signing
|
||||
if ! rpm -K "$rpm_file" | grep -q "digests signatures OK"; then
|
||||
echo .rpm file not signed
|
||||
exit 1
|
||||
fi
|
||||
|
||||
1
.github/workflows/checks.yml
vendored
1
.github/workflows/checks.yml
vendored
@@ -12,7 +12,6 @@ jobs:
|
||||
steps:
|
||||
- name: Check if PRs have conflicts
|
||||
uses: eps1lon/actions-label-merge-conflict@v3
|
||||
if: github.repository == ${{ vars.REPO_MAIN }}
|
||||
with:
|
||||
dirtyLabel: "merge-conflicts"
|
||||
repoToken: "${{ secrets.MERGE_CONFLICT_LABEL_PAT }}"
|
||||
|
||||
5
.github/workflows/nightly.yml
vendored
5
.github/workflows/nightly.yml
vendored
@@ -27,7 +27,7 @@ permissions:
|
||||
|
||||
jobs:
|
||||
nightly-electron:
|
||||
if: github.repository == ${{ vars.REPO_MAIN }}
|
||||
if: github.repository == 'TriliumNext/Trilium'
|
||||
name: Deploy nightly
|
||||
strategy:
|
||||
fail-fast: false
|
||||
@@ -76,7 +76,6 @@ jobs:
|
||||
APPLE_ID: ${{ secrets.APPLE_ID }}
|
||||
APPLE_ID_PASSWORD: ${{ secrets.APPLE_ID_PASSWORD }}
|
||||
WINDOWS_SIGN_EXECUTABLE: ${{ vars.WINDOWS_SIGN_EXECUTABLE }}
|
||||
GPG_SIGNING_KEY: ${{ secrets.GPG_SIGN_KEY }}
|
||||
|
||||
- name: Publish release
|
||||
uses: softprops/action-gh-release@v2.3.2
|
||||
@@ -98,7 +97,7 @@ jobs:
|
||||
path: apps/desktop/upload
|
||||
|
||||
nightly-server:
|
||||
if: github.repository == ${{ vars.REPO_MAIN }}
|
||||
if: github.repository == 'TriliumNext/Trilium'
|
||||
name: Deploy server nightly
|
||||
strategy:
|
||||
fail-fast: false
|
||||
|
||||
1
.github/workflows/release.yml
vendored
1
.github/workflows/release.yml
vendored
@@ -58,7 +58,6 @@ jobs:
|
||||
APPLE_ID: ${{ secrets.APPLE_ID }}
|
||||
APPLE_ID_PASSWORD: ${{ secrets.APPLE_ID_PASSWORD }}
|
||||
WINDOWS_SIGN_EXECUTABLE: ${{ vars.WINDOWS_SIGN_EXECUTABLE }}
|
||||
GPG_SIGNING_KEY: ${{ secrets.GPG_SIGN_KEY }}
|
||||
|
||||
- name: Upload the artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
|
||||
5
.vscode/snippets.code-snippets
vendored
5
.vscode/snippets.code-snippets
vendored
@@ -20,10 +20,5 @@
|
||||
"scope": "typescript",
|
||||
"prefix": "jqf",
|
||||
"body": ["private $${1:name}!: JQuery<HTMLElement>;"]
|
||||
},
|
||||
"region": {
|
||||
"scope": "css",
|
||||
"prefix": "region",
|
||||
"body": ["/* #region ${1:name} */\n$0\n/* #endregion */"]
|
||||
}
|
||||
}
|
||||
|
||||
34
README.md
34
README.md
@@ -1,11 +1,11 @@
|
||||
# Trilium Notes
|
||||
|
||||
 
|
||||

|
||||

|
||||

|
||||

|
||||
[](https://app.relative-ci.com/projects/Di5q7dz9daNDZ9UXi0Bp) [](https://hosted.weblate.org/engage/trilium/)
|
||||
|
||||
[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)
|
||||
[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)
|
||||
|
||||
Trilium Notes is a free and open-source, cross-platform hierarchical note taking application with focus on building large personal knowledge bases.
|
||||
|
||||
@@ -46,15 +46,15 @@ See [screenshots](https://triliumnext.github.io/Docs/Wiki/screenshot-tour) for q
|
||||
- [awesome-trilium](https://github.com/Nriver/awesome-trilium) for 3rd party themes, scripts, plugins and more.
|
||||
- [TriliumRocks!](https://trilium.rocks/) for tutorials, guides, and much more.
|
||||
|
||||
## ❓Why TriliumNext?
|
||||
## ⚠️ Why TriliumNext?
|
||||
|
||||
The original Trilium developer ([Zadam](https://github.com/zadam)) has graciously given the Trilium repository to the community project which resides at https://github.com/TriliumNext
|
||||
[The original Trilium project is in maintenance mode](https://github.com/zadam/trilium/issues/4620).
|
||||
|
||||
### ⬆️Migrating from Zadam/Trilium?
|
||||
### Migrating from Trilium?
|
||||
|
||||
There are no special migration steps to migrate from a zadam/Trilium instance to a TriliumNext/Trilium instance. Simply [install TriliumNext/Trilium](#-installation) as usual and it will use your existing database.
|
||||
There are no special migration steps to migrate from a zadam/Trilium instance to a TriliumNext/Notes instance. Simply [install TriliumNext/Notes](#-installation) as usual and it will use your existing database.
|
||||
|
||||
Versions up to and including [v0.90.4](https://github.com/TriliumNext/Trilium/releases/tag/v0.90.4) are compatible with the latest zadam/trilium version of [v0.63.7](https://github.com/zadam/trilium/releases/tag/v0.63.7). Any later versions of TriliumNext/Trilium have their sync versions incremented which prevents direct migration.
|
||||
Versions up to and including [v0.90.4](https://github.com/TriliumNext/Notes/releases/tag/v0.90.4) are compatible with the latest zadam/trilium version of [v0.63.7](https://github.com/zadam/trilium/releases/tag/v0.63.7). Any later versions of TriliumNext have their sync versions incremented.
|
||||
|
||||
## 📖 Documentation
|
||||
|
||||
@@ -75,8 +75,8 @@ Feel free to join our official conversations. We would love to hear what feature
|
||||
|
||||
- [Matrix](https://matrix.to/#/#triliumnext:matrix.org) (For synchronous discussions.)
|
||||
- The `General` Matrix room is also bridged to [XMPP](xmpp:discuss@trilium.thisgreat.party?join)
|
||||
- [Github Discussions](https://github.com/TriliumNext/Trilium/discussions) (For asynchronous discussions.)
|
||||
- [Github Issues](https://github.com/TriliumNext/Trilium/issues) (For bug reports and feature requests.)
|
||||
- [Github Discussions](https://github.com/TriliumNext/Notes/discussions) (For asynchronous discussions.)
|
||||
- [Github Issues](https://github.com/TriliumNext/Notes/issues) (For bug reports and feature requests.)
|
||||
|
||||
## 🏗 Installation
|
||||
|
||||
@@ -104,15 +104,13 @@ Currently only the latest versions of Chrome & Firefox are supported (and tested
|
||||
|
||||
To use TriliumNext on a mobile device, you can use a mobile web browser to access the mobile interface of a server installation (see below).
|
||||
|
||||
See issue https://github.com/TriliumNext/Trilium/issues/4962 for more information on mobile app support.
|
||||
If you prefer a native Android app, you can use [TriliumDroid](https://apt.izzysoft.de/fdroid/index/apk/eu.fliegendewurst.triliumdroid). Report bugs and missing features at [their repository](https://github.com/FliegendeWurst/TriliumDroid).
|
||||
|
||||
If you prefer a native Android app, you can use [TriliumDroid](https://apt.izzysoft.de/fdroid/index/apk/eu.fliegendewurst.triliumdroid).
|
||||
Report bugs and missing features at [their repository](https://github.com/FliegendeWurst/TriliumDroid).
|
||||
Note: It is best to disable automatic updates on your server installation (see below) when using TriliumDroid since the sync version must match between Trilium and TriliumDroid.
|
||||
See issue https://github.com/TriliumNext/Notes/issues/72 for more information on mobile app support.
|
||||
|
||||
### Server
|
||||
|
||||
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).
|
||||
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).
|
||||
|
||||
|
||||
## 💻 Contribute
|
||||
@@ -154,11 +152,11 @@ pnpm install
|
||||
pnpm nx --project=desktop electron-forge:make -- --arch=x64 --platform=win32
|
||||
```
|
||||
|
||||
For more details, see the [development docs](https://github.com/TriliumNext/Trilium/tree/main/docs/Developer%20Guide/Developer%20Guide).
|
||||
For more details, see the [development docs](https://github.com/TriliumNext/Notes/blob/develop/docs/Developer%20Guide/Developer%20Guide/Building%20and%20deployment/Running%20a%20development%20build.md).
|
||||
|
||||
### Developer Documentation
|
||||
|
||||
Please view the [documentation guide](https://github.com/TriliumNext/Trilium/blob/main/docs/Developer%20Guide/Developer%20Guide/Environment%20Setup.md) for details. If you have more questions, feel free to reach out via the links described in the "Discuss with us" section above.
|
||||
Please view the [documentation guide](./docs/Developer%20Guide/Developer%20Guide/Environment%20Setup.md) for details. If you have more questions, feel free to reach out via the links described in the "Discuss with us" section above.
|
||||
|
||||
## 👏 Shoutouts
|
||||
|
||||
@@ -170,7 +168,7 @@ Please view the [documentation guide](https://github.com/TriliumNext/Trilium/blo
|
||||
## 🤝 Support
|
||||
|
||||
Support for the TriliumNext organization will be possible in the near future. For now, you can:
|
||||
- Support continued development on TriliumNext by supporting our developers: [eliandoran](https://github.com/sponsors/eliandoran) (See the [repository insights]([developers]([url](https://github.com/TriliumNext/trilium/graphs/contributors))) for a full list)
|
||||
- Support continued development on TriliumNext by supporting our developers: [eliandoran](https://github.com/sponsors/eliandoran) (See the [repository insights]([developers]([url](https://github.com/TriliumNext/Notes/graphs/contributors))) for a full list)
|
||||
- Show a token of gratitude to the original Trilium developer ([zadam](https://github.com/sponsors/zadam)) via [PayPal](https://paypal.me/za4am) or Bitcoin (bitcoin:bc1qv3svjn40v89mnkre5vyvs2xw6y8phaltl385d2).
|
||||
|
||||
|
||||
|
||||
@@ -35,13 +35,13 @@
|
||||
"chore:generate-openapi": "tsx bin/generate-openapi.js"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@playwright/test": "1.55.0",
|
||||
"@playwright/test": "1.54.2",
|
||||
"@stylistic/eslint-plugin": "5.2.3",
|
||||
"@types/express": "5.0.3",
|
||||
"@types/node": "22.18.0",
|
||||
"@types/node": "22.17.1",
|
||||
"@types/yargs": "17.0.33",
|
||||
"@vitest/coverage-v8": "3.2.4",
|
||||
"eslint": "9.34.0",
|
||||
"eslint": "9.33.0",
|
||||
"eslint-plugin-simple-import-sort": "12.1.1",
|
||||
"esm": "3.2.25",
|
||||
"jsdoc": "4.0.4",
|
||||
@@ -49,7 +49,7 @@
|
||||
"rcedit": "4.0.1",
|
||||
"rimraf": "6.0.1",
|
||||
"tslib": "2.8.1",
|
||||
"typedoc": "0.28.11",
|
||||
"typedoc": "0.28.10",
|
||||
"typedoc-plugin-missing-exports": "4.1.0"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@triliumnext/client",
|
||||
"version": "0.98.1",
|
||||
"version": "0.97.2",
|
||||
"description": "JQuery-based client for TriliumNext, used for both web and desktop (via Electron)",
|
||||
"private": true,
|
||||
"license": "AGPL-3.0-only",
|
||||
@@ -10,7 +10,7 @@
|
||||
"url": "https://github.com/TriliumNext/Notes"
|
||||
},
|
||||
"dependencies": {
|
||||
"@eslint/js": "9.34.0",
|
||||
"@eslint/js": "9.33.0",
|
||||
"@excalidraw/excalidraw": "0.18.0",
|
||||
"@fullcalendar/core": "6.1.19",
|
||||
"@fullcalendar/daygrid": "6.1.19",
|
||||
@@ -19,7 +19,7 @@
|
||||
"@fullcalendar/multimonth": "6.1.19",
|
||||
"@fullcalendar/timegrid": "6.1.19",
|
||||
"@maplibre/maplibre-gl-leaflet": "0.1.3",
|
||||
"@mermaid-js/layout-elk": "0.1.9",
|
||||
"@mermaid-js/layout-elk": "0.1.8",
|
||||
"@mind-elixir/node-menu": "5.0.0",
|
||||
"@popperjs/core": "2.11.8",
|
||||
"@triliumnext/ckeditor5": "workspace:*",
|
||||
@@ -28,15 +28,15 @@
|
||||
"@triliumnext/highlightjs": "workspace:*",
|
||||
"@triliumnext/share-theme": "workspace:*",
|
||||
"autocomplete.js": "0.38.1",
|
||||
"bootstrap": "5.3.8",
|
||||
"bootstrap": "5.3.7",
|
||||
"boxicons": "2.1.4",
|
||||
"dayjs": "1.11.14",
|
||||
"dayjs": "1.11.13",
|
||||
"dayjs-plugin-utc": "0.1.2",
|
||||
"debounce": "2.2.0",
|
||||
"draggabilly": "3.0.0",
|
||||
"force-graph": "1.50.1",
|
||||
"globals": "16.3.0",
|
||||
"i18next": "25.4.2",
|
||||
"i18next": "25.3.4",
|
||||
"i18next-http-backend": "3.0.2",
|
||||
"jquery": "3.7.1",
|
||||
"jquery.fancytree": "2.38.5",
|
||||
@@ -46,23 +46,23 @@
|
||||
"leaflet": "1.9.4",
|
||||
"leaflet-gpx": "2.2.0",
|
||||
"mark.js": "8.11.1",
|
||||
"marked": "16.2.1",
|
||||
"mermaid": "11.10.1",
|
||||
"mind-elixir": "5.0.6",
|
||||
"marked": "16.1.2",
|
||||
"mermaid": "11.9.0",
|
||||
"mind-elixir": "5.0.5",
|
||||
"normalize.css": "8.0.1",
|
||||
"panzoom": "9.4.3",
|
||||
"preact": "10.27.1",
|
||||
"react-i18next": "15.7.2",
|
||||
"preact": "10.27.0",
|
||||
"split.js": "1.6.5",
|
||||
"svg-pan-zoom": "3.6.2",
|
||||
"tabulator-tables": "6.3.1",
|
||||
"vanilla-js-wheel-zoom": "9.0.4"
|
||||
"vanilla-js-wheel-zoom": "9.0.4",
|
||||
"photoswipe": "^5.4.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@ckeditor/ckeditor5-inspector": "5.0.0",
|
||||
"@preact/preset-vite": "2.10.2",
|
||||
"@types/bootstrap": "5.2.10",
|
||||
"@types/jquery": "3.5.33",
|
||||
"@types/jquery": "3.5.32",
|
||||
"@types/leaflet": "1.9.20",
|
||||
"@types/leaflet-gpx": "1.3.7",
|
||||
"@types/mark.js": "8.11.12",
|
||||
@@ -70,7 +70,7 @@
|
||||
"copy-webpack-plugin": "13.0.1",
|
||||
"happy-dom": "18.0.1",
|
||||
"script-loader": "0.7.2",
|
||||
"vite-plugin-static-copy": "3.1.2"
|
||||
"vite-plugin-static-copy": "3.1.1"
|
||||
},
|
||||
"nx": {
|
||||
"name": "client",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import froca from "../services/froca.js";
|
||||
import RootCommandExecutor from "./root_command_executor.js";
|
||||
import Entrypoints from "./entrypoints.js";
|
||||
import Entrypoints, { type SqlExecuteResults } from "./entrypoints.js";
|
||||
import options from "../services/options.js";
|
||||
import utils, { hasTouchBar } from "../services/utils.js";
|
||||
import zoomComponent from "./zoom.js";
|
||||
@@ -31,14 +31,16 @@ import { StartupChecks } from "./startup_checks.js";
|
||||
import type { CreateNoteOpts } from "../services/note_create.js";
|
||||
import { ColumnComponent } from "tabulator-tables";
|
||||
import { ChooseNoteTypeCallback } from "../widgets/dialogs/note_type_chooser.jsx";
|
||||
import type RootContainer from "../widgets/containers/root_container.js";
|
||||
import { SqlExecuteResults } from "@triliumnext/commons";
|
||||
|
||||
interface Layout {
|
||||
getRootWidget: (appContext: AppContext) => RootContainer;
|
||||
getRootWidget: (appContext: AppContext) => RootWidget;
|
||||
}
|
||||
|
||||
export interface BeforeUploadListener extends Component {
|
||||
interface RootWidget extends Component {
|
||||
render: () => JQuery<HTMLElement>;
|
||||
}
|
||||
|
||||
interface BeforeUploadListener extends Component {
|
||||
beforeUnloadEvent(): boolean;
|
||||
}
|
||||
|
||||
@@ -83,6 +85,7 @@ export type CommandMappings = {
|
||||
focusTree: CommandData;
|
||||
focusOnTitle: CommandData;
|
||||
focusOnDetail: CommandData;
|
||||
focusOnSearchDefinition: Required<CommandData>;
|
||||
searchNotes: CommandData & {
|
||||
searchString?: string;
|
||||
ancestorNoteId?: string | null;
|
||||
@@ -90,11 +93,6 @@ export type CommandMappings = {
|
||||
closeTocCommand: CommandData;
|
||||
closeHlt: CommandData;
|
||||
showLaunchBarSubtree: CommandData;
|
||||
showHiddenSubtree: CommandData;
|
||||
showSQLConsoleHistory: CommandData;
|
||||
logout: CommandData;
|
||||
switchToMobileVersion: CommandData;
|
||||
switchToDesktopVersion: CommandData;
|
||||
showRevisions: CommandData & {
|
||||
noteId?: string | null;
|
||||
};
|
||||
@@ -140,7 +138,6 @@ export type CommandMappings = {
|
||||
showLeftPane: CommandData;
|
||||
showAttachments: CommandData;
|
||||
showSearchHistory: CommandData;
|
||||
showShareSubtree: CommandData;
|
||||
hoistNote: CommandData & { noteId: string };
|
||||
leaveProtectedSession: CommandData;
|
||||
enterProtectedSession: CommandData;
|
||||
@@ -326,7 +323,6 @@ export type CommandMappings = {
|
||||
printActiveNote: CommandData;
|
||||
exportAsPdf: CommandData;
|
||||
openNoteExternally: CommandData;
|
||||
openNoteCustom: CommandData;
|
||||
renderActiveNote: CommandData;
|
||||
unhoist: CommandData;
|
||||
reloadFrontendApp: CommandData;
|
||||
@@ -530,7 +526,7 @@ export type FilteredCommandNames<T extends CommandData> = keyof Pick<CommandMapp
|
||||
export class AppContext extends Component {
|
||||
isMainWindow: boolean;
|
||||
components: Component[];
|
||||
beforeUnloadListeners: (WeakRef<BeforeUploadListener> | (() => boolean))[];
|
||||
beforeUnloadListeners: WeakRef<BeforeUploadListener>[];
|
||||
tabManager!: TabManager;
|
||||
layout?: Layout;
|
||||
noteTreeWidget?: NoteTreeWidget;
|
||||
@@ -623,7 +619,7 @@ export class AppContext extends Component {
|
||||
component.triggerCommand(commandName, { $el: $(this) });
|
||||
});
|
||||
|
||||
this.child(rootWidget as Component);
|
||||
this.child(rootWidget);
|
||||
|
||||
this.triggerEvent("initialRenderComplete", {});
|
||||
}
|
||||
@@ -653,17 +649,13 @@ export class AppContext extends Component {
|
||||
return $(el).closest(".component").prop("component");
|
||||
}
|
||||
|
||||
addBeforeUnloadListener(obj: BeforeUploadListener | (() => boolean)) {
|
||||
addBeforeUnloadListener(obj: BeforeUploadListener) {
|
||||
if (typeof WeakRef !== "function") {
|
||||
// older browsers don't support WeakRef
|
||||
return;
|
||||
}
|
||||
|
||||
if (typeof obj === "object") {
|
||||
this.beforeUnloadListeners.push(new WeakRef<BeforeUploadListener>(obj));
|
||||
} else {
|
||||
this.beforeUnloadListeners.push(obj);
|
||||
}
|
||||
this.beforeUnloadListeners.push(new WeakRef<BeforeUploadListener>(obj));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -673,29 +665,25 @@ const appContext = new AppContext(window.glob.isMainWindow);
|
||||
$(window).on("beforeunload", () => {
|
||||
let allSaved = true;
|
||||
|
||||
appContext.beforeUnloadListeners = appContext.beforeUnloadListeners.filter((wr) => typeof wr === "function" || !!wr.deref());
|
||||
appContext.beforeUnloadListeners = appContext.beforeUnloadListeners.filter((wr) => !!wr.deref());
|
||||
|
||||
for (const listener of appContext.beforeUnloadListeners) {
|
||||
if (typeof listener === "object") {
|
||||
const component = listener.deref();
|
||||
for (const weakRef of appContext.beforeUnloadListeners) {
|
||||
const component = weakRef.deref();
|
||||
|
||||
if (!component) {
|
||||
continue;
|
||||
}
|
||||
if (!component) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!component.beforeUnloadEvent()) {
|
||||
console.log(`Component ${component.componentId} is not finished saving its state.`);
|
||||
allSaved = false;
|
||||
}
|
||||
} else {
|
||||
if (!listener()) {
|
||||
allSaved = false;
|
||||
}
|
||||
if (!component.beforeUnloadEvent()) {
|
||||
console.log(`Component ${component.componentId} is not finished saving its state.`);
|
||||
|
||||
toast.showMessage(t("app_context.please_wait_for_save"), 10000);
|
||||
|
||||
allSaved = false;
|
||||
}
|
||||
}
|
||||
|
||||
if (!allSaved) {
|
||||
toast.showMessage(t("app_context.please_wait_for_save"), 10000);
|
||||
return "some string";
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
import utils from "../services/utils.js";
|
||||
import type { CommandMappings, CommandNames, EventData, EventNames } from "./app_context.js";
|
||||
|
||||
type EventHandler = ((data: any) => void);
|
||||
|
||||
/**
|
||||
* Abstract class for all components in the Trilium's frontend.
|
||||
*
|
||||
@@ -21,7 +19,6 @@ export class TypedComponent<ChildT extends TypedComponent<ChildT>> {
|
||||
initialized: Promise<void> | null;
|
||||
parent?: TypedComponent<any>;
|
||||
_position!: number;
|
||||
private listeners: Record<string, EventHandler[]> | null = {};
|
||||
|
||||
constructor() {
|
||||
this.componentId = `${this.sanitizedClassName}-${utils.randomString(8)}`;
|
||||
@@ -79,14 +76,6 @@ export class TypedComponent<ChildT extends TypedComponent<ChildT>> {
|
||||
handleEventInChildren<T extends EventNames>(name: T, data: EventData<T>): Promise<unknown[] | unknown> | null {
|
||||
const promises: Promise<unknown>[] = [];
|
||||
|
||||
// Handle React children.
|
||||
if (this.listeners?.[name]) {
|
||||
for (const listener of this.listeners[name]) {
|
||||
listener(data);
|
||||
}
|
||||
}
|
||||
|
||||
// Handle legacy children.
|
||||
for (const child of this.children) {
|
||||
const ret = child.handleEvent(name, data) as Promise<void>;
|
||||
|
||||
@@ -131,35 +120,6 @@ export class TypedComponent<ChildT extends TypedComponent<ChildT>> {
|
||||
|
||||
return promise;
|
||||
}
|
||||
|
||||
registerHandler<T extends EventNames>(name: T, handler: EventHandler) {
|
||||
if (!this.listeners) {
|
||||
this.listeners = {};
|
||||
}
|
||||
|
||||
if (!this.listeners[name]) {
|
||||
this.listeners[name] = [];
|
||||
}
|
||||
|
||||
if (this.listeners[name].includes(handler)) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.listeners[name].push(handler);
|
||||
}
|
||||
|
||||
removeHandler<T extends EventNames>(name: T, handler: EventHandler) {
|
||||
if (!this.listeners?.[name]?.includes(handler)) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.listeners[name] = this.listeners[name]
|
||||
.filter(listener => listener !== handler);
|
||||
|
||||
if (!this.listeners[name].length) {
|
||||
delete this.listeners[name];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default class Component extends TypedComponent<Component> {}
|
||||
|
||||
@@ -10,7 +10,22 @@ import bundleService from "../services/bundle.js";
|
||||
import froca from "../services/froca.js";
|
||||
import linkService from "../services/link.js";
|
||||
import { t } from "../services/i18n.js";
|
||||
import { CreateChildrenResponse, SqlExecuteResponse } from "@triliumnext/commons";
|
||||
import type FNote from "../entities/fnote.js";
|
||||
|
||||
// TODO: Move somewhere else nicer.
|
||||
export type SqlExecuteResults = string[][][];
|
||||
|
||||
// TODO: Deduplicate with server.
|
||||
interface SqlExecuteResponse {
|
||||
success: boolean;
|
||||
error?: string;
|
||||
results: SqlExecuteResults;
|
||||
}
|
||||
|
||||
// TODO: Deduplicate with server.
|
||||
interface CreateChildrenResponse {
|
||||
note: FNote;
|
||||
}
|
||||
|
||||
export default class Entrypoints extends Component {
|
||||
constructor() {
|
||||
@@ -19,7 +34,7 @@ export default class Entrypoints extends Component {
|
||||
|
||||
openDevToolsCommand() {
|
||||
if (utils.isElectron()) {
|
||||
utils.dynamicRequire("@electron/remote").getCurrentWindow().webContents.toggleDevTools();
|
||||
utils.dynamicRequire("@electron/remote").getCurrentWindow().toggleDevTools();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -109,7 +124,7 @@ export default class Entrypoints extends Component {
|
||||
if (utils.isElectron()) {
|
||||
// standard JS version does not work completely correctly in electron
|
||||
const webContents = utils.dynamicRequire("@electron/remote").getCurrentWebContents();
|
||||
const activeIndex = webContents.navigationHistory.getActiveIndex();
|
||||
const activeIndex = parseInt(webContents.navigationHistory.getActiveIndex());
|
||||
|
||||
webContents.goToIndex(activeIndex - 1);
|
||||
} else {
|
||||
@@ -121,7 +136,7 @@ export default class Entrypoints extends Component {
|
||||
if (utils.isElectron()) {
|
||||
// standard JS version does not work completely correctly in electron
|
||||
const webContents = utils.dynamicRequire("@electron/remote").getCurrentWebContents();
|
||||
const activeIndex = webContents.navigationHistory.getActiveIndex();
|
||||
const activeIndex = parseInt(webContents.navigationHistory.getActiveIndex());
|
||||
|
||||
webContents.goToIndex(activeIndex + 1);
|
||||
} else {
|
||||
|
||||
@@ -43,6 +43,8 @@ export default class RootCommandExecutor extends Component {
|
||||
const noteContext = await appContext.tabManager.openTabWithNoteWithHoisting(searchNote.noteId, {
|
||||
activate: true
|
||||
});
|
||||
|
||||
appContext.triggerCommand("focusOnSearchDefinition", { ntxId: noteContext.ntxId });
|
||||
}
|
||||
|
||||
async searchInSubtreeCommand({ notePath }: CommandListenerData<"searchInSubtree">) {
|
||||
|
||||
@@ -8,10 +8,13 @@ import electronContextMenu from "./menus/electron_context_menu.js";
|
||||
import glob from "./services/glob.js";
|
||||
import { t } from "./services/i18n.js";
|
||||
import options from "./services/options.js";
|
||||
import server from "./services/server.js";
|
||||
import type ElectronRemote from "@electron/remote";
|
||||
import type Electron from "electron";
|
||||
import "./stylesheets/bootstrap.scss";
|
||||
import "boxicons/css/boxicons.min.css";
|
||||
import "./stylesheets/media-viewer.css";
|
||||
import "./styles/gallery.css";
|
||||
import "autocomplete.js/index_jquery.js";
|
||||
|
||||
await appContext.earlyInit();
|
||||
|
||||
@@ -64,7 +64,7 @@ export interface NoteMetaData {
|
||||
/**
|
||||
* Note is the main node and concept in Trilium.
|
||||
*/
|
||||
export default class FNote {
|
||||
class FNote {
|
||||
private froca: Froca;
|
||||
|
||||
noteId!: string;
|
||||
@@ -1020,14 +1020,6 @@ export default class FNote {
|
||||
return this.noteId.startsWith("_options");
|
||||
}
|
||||
|
||||
isTriliumSqlite() {
|
||||
return this.mime === "text/x-sqlite;schema=trilium";
|
||||
}
|
||||
|
||||
isTriliumScript() {
|
||||
return this.mime.startsWith("application/javascript");
|
||||
}
|
||||
|
||||
/**
|
||||
* Provides note's date metadata.
|
||||
*/
|
||||
@@ -1035,3 +1027,5 @@ export default class FNote {
|
||||
return await server.get<NoteMetaData>(`notes/${this.noteId}/metadata`);
|
||||
}
|
||||
}
|
||||
|
||||
export default FNote;
|
||||
|
||||
@@ -1,47 +1,78 @@
|
||||
import FlexContainer from "../widgets/containers/flex_container.js";
|
||||
import GlobalMenuWidget from "../widgets/buttons/global_menu.js";
|
||||
import TabRowWidget from "../widgets/tab_row.js";
|
||||
import TitleBarButtonsWidget from "../widgets/title_bar_buttons.js";
|
||||
import LeftPaneContainer from "../widgets/containers/left_pane_container.js";
|
||||
import NoteTreeWidget from "../widgets/note_tree.js";
|
||||
import NoteTitleWidget from "../widgets/note_title.jsx";
|
||||
import NoteTitleWidget from "../widgets/note_title.js";
|
||||
import OwnedAttributeListWidget from "../widgets/ribbon_widgets/owned_attribute_list.js";
|
||||
import NoteActionsWidget from "../widgets/buttons/note_actions.js";
|
||||
import NoteDetailWidget from "../widgets/note_detail.js";
|
||||
import PromotedAttributesWidget from "../widgets/promoted_attributes.js";
|
||||
import RibbonContainer from "../widgets/containers/ribbon_container.js";
|
||||
import PromotedAttributesWidget from "../widgets/ribbon_widgets/promoted_attributes.js";
|
||||
import InheritedAttributesWidget from "../widgets/ribbon_widgets/inherited_attribute_list.js";
|
||||
import NoteListWidget from "../widgets/note_list.js";
|
||||
import NoteIconWidget from "../widgets/note_icon.jsx";
|
||||
import SearchDefinitionWidget from "../widgets/ribbon_widgets/search_definition.js";
|
||||
import SqlResultWidget from "../widgets/sql_result.js";
|
||||
import SqlTableSchemasWidget from "../widgets/sql_table_schemas.js";
|
||||
import FilePropertiesWidget from "../widgets/ribbon_widgets/file_properties.js";
|
||||
import ImagePropertiesWidget from "../widgets/ribbon_widgets/image_properties.js";
|
||||
import NotePropertiesWidget from "../widgets/ribbon_widgets/note_properties.js";
|
||||
import NoteIconWidget from "../widgets/note_icon.js";
|
||||
import SearchResultWidget from "../widgets/search_result.js";
|
||||
import ScrollingContainer from "../widgets/containers/scrolling_container.js";
|
||||
import RootContainer from "../widgets/containers/root_container.js";
|
||||
import WatchedFileUpdateStatusWidget from "../widgets/watched_file_update_status.js";
|
||||
import SpacerWidget from "../widgets/spacer.js";
|
||||
import QuickSearchWidget from "../widgets/quick_search.js";
|
||||
import SplitNoteContainer from "../widgets/containers/split_note_container.js";
|
||||
import LeftPaneToggleWidget from "../widgets/buttons/left_pane_toggle.js";
|
||||
import CreatePaneButton from "../widgets/buttons/create_pane_button.js";
|
||||
import ClosePaneButton from "../widgets/buttons/close_pane_button.js";
|
||||
import BasicPropertiesWidget from "../widgets/ribbon_widgets/basic_properties.js";
|
||||
import NoteInfoWidget from "../widgets/ribbon_widgets/note_info_widget.js";
|
||||
import BookPropertiesWidget from "../widgets/ribbon_widgets/book_properties.js";
|
||||
import NoteMapRibbonWidget from "../widgets/ribbon_widgets/note_map.js";
|
||||
import NotePathsWidget from "../widgets/ribbon_widgets/note_paths.js";
|
||||
import SimilarNotesWidget from "../widgets/ribbon_widgets/similar_notes.js";
|
||||
import RightPaneContainer from "../widgets/containers/right_pane_container.js";
|
||||
import EditButton from "../widgets/floating_buttons/edit_button.js";
|
||||
import EditedNotesWidget from "../widgets/ribbon_widgets/edited_notes.js";
|
||||
import ShowTocWidgetButton from "../widgets/buttons/show_toc_widget_button.js";
|
||||
import ShowHighlightsListWidgetButton from "../widgets/buttons/show_highlights_list_widget_button.js";
|
||||
import NoteWrapperWidget from "../widgets/note_wrapper.js";
|
||||
import BacklinksWidget from "../widgets/floating_buttons/zpetne_odkazy.js";
|
||||
import SharedInfoWidget from "../widgets/shared_info.js";
|
||||
import FindWidget from "../widgets/find.js";
|
||||
import TocWidget from "../widgets/toc.js";
|
||||
import HighlightsListWidget from "../widgets/highlights_list.js";
|
||||
import PasswordNoteSetDialog from "../widgets/dialogs/password_not_set.js";
|
||||
import FloatingButtons from "../widgets/floating_buttons/floating_buttons.js";
|
||||
import RelationMapButtons from "../widgets/floating_buttons/relation_map_buttons.js";
|
||||
import SvgExportButton from "../widgets/floating_buttons/svg_export_button.js";
|
||||
import LauncherContainer from "../widgets/containers/launcher_container.js";
|
||||
import RevisionsButton from "../widgets/buttons/revisions_button.js";
|
||||
import CodeButtonsWidget from "../widgets/floating_buttons/code_buttons.js";
|
||||
import ApiLogWidget from "../widgets/api_log.js";
|
||||
import HideFloatingButtonsButton from "../widgets/floating_buttons/hide_floating_buttons_button.js";
|
||||
import ScriptExecutorWidget from "../widgets/ribbon_widgets/script_executor.js";
|
||||
import MovePaneButton from "../widgets/buttons/move_pane_button.js";
|
||||
import UploadAttachmentsDialog from "../widgets/dialogs/upload_attachments.js";
|
||||
import ScrollPadding from "../widgets/scroll_padding.js";
|
||||
import CopyImageReferenceButton from "../widgets/floating_buttons/copy_image_reference_button.js";
|
||||
import ScrollPaddingWidget from "../widgets/scroll_padding.js";
|
||||
import ClassicEditorToolbar from "../widgets/ribbon_widgets/classic_editor_toolbar.js";
|
||||
import options from "../services/options.js";
|
||||
import utils from "../services/utils.js";
|
||||
import GeoMapButtons from "../widgets/floating_buttons/geo_map_button.js";
|
||||
import ContextualHelpButton from "../widgets/floating_buttons/help_button.js";
|
||||
import CloseZenButton from "../widgets/close_zen_button.js";
|
||||
import type { AppContext } from "../components/app_context.js";
|
||||
import type { WidgetsByParent } from "../services/bundle.js";
|
||||
import SwitchSplitOrientationButton from "../widgets/floating_buttons/switch_layout_button.js";
|
||||
import ToggleReadOnlyButton from "../widgets/floating_buttons/toggle_read_only_button.js";
|
||||
import PngExportButton from "../widgets/floating_buttons/png_export_button.js";
|
||||
import RefreshButton from "../widgets/floating_buttons/refresh_button.js";
|
||||
import { applyModals } from "./layout_commons.js";
|
||||
import Ribbon from "../widgets/ribbon/Ribbon.jsx";
|
||||
import FloatingButtons from "../widgets/FloatingButtons.jsx";
|
||||
import { DESKTOP_FLOATING_BUTTONS } from "../widgets/FloatingButtonsDefinitions.jsx";
|
||||
import SearchResult from "../widgets/search_result.jsx";
|
||||
import GlobalMenu from "../widgets/buttons/global_menu.jsx";
|
||||
import SqlResults from "../widgets/sql_result.js";
|
||||
import SqlTableSchemas from "../widgets/sql_table_schemas.js";
|
||||
import TitleBarButtons from "../widgets/title_bar_buttons.jsx";
|
||||
import LeftPaneToggle from "../widgets/buttons/left_pane_toggle.js";
|
||||
import ApiLog from "../widgets/api_log.jsx";
|
||||
import CloseZenModeButton from "../widgets/close_zen_button.jsx";
|
||||
import SharedInfo from "../widgets/shared_info.jsx";
|
||||
|
||||
export default class DesktopLayout {
|
||||
|
||||
@@ -76,9 +107,9 @@ export default class DesktopLayout {
|
||||
new FlexContainer("row")
|
||||
.class("tab-row-container")
|
||||
.child(new FlexContainer("row").id("tab-row-left-spacer"))
|
||||
.optChild(launcherPaneIsHorizontal, <LeftPaneToggle isHorizontalLayout={true} />)
|
||||
.optChild(launcherPaneIsHorizontal, new LeftPaneToggleWidget(true))
|
||||
.child(new TabRowWidget().class("full-width"))
|
||||
.optChild(customTitleBarButtons, <TitleBarButtons />)
|
||||
.optChild(customTitleBarButtons, new TitleBarButtonsWidget())
|
||||
.css("height", "40px")
|
||||
.css("background-color", "var(--launcher-pane-background-color)")
|
||||
.setParent(appContext)
|
||||
@@ -99,7 +130,7 @@ export default class DesktopLayout {
|
||||
new FlexContainer("column")
|
||||
.id("rest-pane")
|
||||
.css("flex-grow", "1")
|
||||
.optChild(!fullWidthTabBar, new FlexContainer("row").child(new TabRowWidget()).optChild(customTitleBarButtons, <TitleBarButtons />).css("height", "40px"))
|
||||
.optChild(!fullWidthTabBar, new FlexContainer("row").child(new TabRowWidget()).optChild(customTitleBarButtons, new TitleBarButtonsWidget()).css("height", "40px"))
|
||||
.child(
|
||||
new FlexContainer("row")
|
||||
.filling()
|
||||
@@ -120,30 +151,69 @@ export default class DesktopLayout {
|
||||
.css("min-height", "50px")
|
||||
.css("align-items", "center")
|
||||
.cssBlock(".title-row > * { margin: 5px; }")
|
||||
.child(<NoteIconWidget />)
|
||||
.child(<NoteTitleWidget />)
|
||||
.child(new NoteIconWidget())
|
||||
.child(new NoteTitleWidget())
|
||||
.child(new SpacerWidget(0, 1))
|
||||
.child(new MovePaneButton(true))
|
||||
.child(new MovePaneButton(false))
|
||||
.child(new ClosePaneButton())
|
||||
.child(new CreatePaneButton())
|
||||
)
|
||||
.child(<Ribbon />)
|
||||
.child(<SharedInfo />)
|
||||
.child(
|
||||
new RibbonContainer()
|
||||
// the order of the widgets matter. Some of these want to "activate" themselves
|
||||
// when visible. When this happens to multiple of them, the first one "wins".
|
||||
// promoted attributes should always win.
|
||||
.ribbon(new ClassicEditorToolbar())
|
||||
.ribbon(new ScriptExecutorWidget())
|
||||
.ribbon(new SearchDefinitionWidget())
|
||||
.ribbon(new EditedNotesWidget())
|
||||
.ribbon(new BookPropertiesWidget())
|
||||
.ribbon(new NotePropertiesWidget())
|
||||
.ribbon(new FilePropertiesWidget())
|
||||
.ribbon(new ImagePropertiesWidget())
|
||||
.ribbon(new BasicPropertiesWidget())
|
||||
.ribbon(new OwnedAttributeListWidget())
|
||||
.ribbon(new InheritedAttributesWidget())
|
||||
.ribbon(new NotePathsWidget())
|
||||
.ribbon(new NoteMapRibbonWidget())
|
||||
.ribbon(new SimilarNotesWidget())
|
||||
.ribbon(new NoteInfoWidget())
|
||||
.button(new RevisionsButton())
|
||||
.button(new NoteActionsWidget())
|
||||
)
|
||||
.child(new SharedInfoWidget())
|
||||
.child(new WatchedFileUpdateStatusWidget())
|
||||
.child(<FloatingButtons items={DESKTOP_FLOATING_BUTTONS} />)
|
||||
.child(
|
||||
new FloatingButtons()
|
||||
.child(new RefreshButton())
|
||||
.child(new SwitchSplitOrientationButton())
|
||||
.child(new ToggleReadOnlyButton())
|
||||
.child(new EditButton())
|
||||
.child(new ShowTocWidgetButton())
|
||||
.child(new ShowHighlightsListWidgetButton())
|
||||
.child(new CodeButtonsWidget())
|
||||
.child(new RelationMapButtons())
|
||||
.child(new GeoMapButtons())
|
||||
.child(new CopyImageReferenceButton())
|
||||
.child(new SvgExportButton())
|
||||
.child(new PngExportButton())
|
||||
.child(new BacklinksWidget())
|
||||
.child(new ContextualHelpButton())
|
||||
.child(new HideFloatingButtonsButton())
|
||||
)
|
||||
.child(
|
||||
new ScrollingContainer()
|
||||
.filling()
|
||||
.child(new PromotedAttributesWidget())
|
||||
.child(<SqlTableSchemas />)
|
||||
.child(new SqlTableSchemasWidget())
|
||||
.child(new NoteDetailWidget())
|
||||
.child(new NoteListWidget(false))
|
||||
.child(<SearchResult />)
|
||||
.child(<SqlResults />)
|
||||
.child(<ScrollPadding />)
|
||||
.child(new SearchResultWidget())
|
||||
.child(new SqlResultWidget())
|
||||
.child(new ScrollPaddingWidget())
|
||||
)
|
||||
.child(<ApiLog />)
|
||||
.child(new ApiLogWidget())
|
||||
.child(new FindWidget())
|
||||
.child(
|
||||
...this.customWidgets.get("node-detail-pane"), // typo, let's keep it for a while as BC
|
||||
@@ -162,11 +232,11 @@ export default class DesktopLayout {
|
||||
)
|
||||
)
|
||||
)
|
||||
.child(<CloseZenModeButton />)
|
||||
.child(new CloseZenButton())
|
||||
|
||||
// Desktop-specific dialogs.
|
||||
.child(<PasswordNoteSetDialog />)
|
||||
.child(<UploadAttachmentsDialog />);
|
||||
.child(new PasswordNoteSetDialog())
|
||||
.child(new UploadAttachmentsDialog());
|
||||
|
||||
applyModals(rootContainer);
|
||||
return rootContainer;
|
||||
@@ -176,18 +246,14 @@ export default class DesktopLayout {
|
||||
let launcherPane;
|
||||
|
||||
if (isHorizontal) {
|
||||
launcherPane = new FlexContainer("row")
|
||||
.css("height", "53px")
|
||||
.class("horizontal")
|
||||
.child(new LauncherContainer(true))
|
||||
.child(<GlobalMenu isHorizontalLayout={true} />);
|
||||
launcherPane = new FlexContainer("row").css("height", "53px").class("horizontal").child(new LauncherContainer(true)).child(new GlobalMenuWidget(true));
|
||||
} else {
|
||||
launcherPane = new FlexContainer("column")
|
||||
.css("width", "53px")
|
||||
.class("vertical")
|
||||
.child(<GlobalMenu isHorizontalLayout={false} />)
|
||||
.child(new GlobalMenuWidget(false))
|
||||
.child(new LauncherContainer(false))
|
||||
.child(<LeftPaneToggle isHorizontalLayout={false} />);
|
||||
.child(new LeftPaneToggleWidget(false));
|
||||
}
|
||||
|
||||
launcherPane.id("launcher-pane");
|
||||
@@ -24,48 +24,48 @@ import InfoDialog from "../widgets/dialogs/info.js";
|
||||
import IncorrectCpuArchDialog from "../widgets/dialogs/incorrect_cpu_arch.js";
|
||||
import PopupEditorDialog from "../widgets/dialogs/popup_editor.js";
|
||||
import FlexContainer from "../widgets/containers/flex_container.js";
|
||||
import NoteIconWidget from "../widgets/note_icon";
|
||||
import PromotedAttributesWidget from "../widgets/promoted_attributes.js";
|
||||
import NoteIconWidget from "../widgets/note_icon.js";
|
||||
import NoteTitleWidget from "../widgets/note_title.js";
|
||||
import ClassicEditorToolbar from "../widgets/ribbon_widgets/classic_editor_toolbar.js";
|
||||
import PromotedAttributesWidget from "../widgets/ribbon_widgets/promoted_attributes.js";
|
||||
import NoteDetailWidget from "../widgets/note_detail.js";
|
||||
import NoteListWidget from "../widgets/note_list.js";
|
||||
import CallToActionDialog from "../widgets/dialogs/call_to_action.jsx";
|
||||
import NoteTitleWidget from "../widgets/note_title.jsx";
|
||||
import { PopupEditorFormattingToolbar } from "../widgets/ribbon/FormattingToolbar.js";
|
||||
import { CallToActionDialog } from "../widgets/dialogs/call_to_action.jsx";
|
||||
|
||||
export function applyModals(rootContainer: RootContainer) {
|
||||
rootContainer
|
||||
.child(<BulkActionsDialog />)
|
||||
.child(<AboutDialog />)
|
||||
.child(<HelpDialog />)
|
||||
.child(<RecentChangesDialog />)
|
||||
.child(<BranchPrefixDialog />)
|
||||
.child(<SortChildNotesDialog />)
|
||||
.child(<IncludeNoteDialog />)
|
||||
.child(<NoteTypeChooserDialog />)
|
||||
.child(<JumpToNoteDialog />)
|
||||
.child(<AddLinkDialog />)
|
||||
.child(<CloneToDialog />)
|
||||
.child(<MoveToDialog />)
|
||||
.child(<ImportDialog />)
|
||||
.child(<ExportDialog />)
|
||||
.child(<MarkdownImportDialog />)
|
||||
.child(<ProtectedSessionPasswordDialog />)
|
||||
.child(<RevisionsDialog />)
|
||||
.child(<DeleteNotesDialog />)
|
||||
.child(<InfoDialog />)
|
||||
.child(<ConfirmDialog />)
|
||||
.child(<PromptDialog />)
|
||||
.child(<IncorrectCpuArchDialog />)
|
||||
.child(new BulkActionsDialog())
|
||||
.child(new AboutDialog())
|
||||
.child(new HelpDialog())
|
||||
.child(new RecentChangesDialog())
|
||||
.child(new BranchPrefixDialog())
|
||||
.child(new SortChildNotesDialog())
|
||||
.child(new IncludeNoteDialog())
|
||||
.child(new NoteTypeChooserDialog())
|
||||
.child(new JumpToNoteDialog())
|
||||
.child(new AddLinkDialog())
|
||||
.child(new CloneToDialog())
|
||||
.child(new MoveToDialog())
|
||||
.child(new ImportDialog())
|
||||
.child(new ExportDialog())
|
||||
.child(new MarkdownImportDialog())
|
||||
.child(new ProtectedSessionPasswordDialog())
|
||||
.child(new RevisionsDialog())
|
||||
.child(new DeleteNotesDialog())
|
||||
.child(new InfoDialog())
|
||||
.child(new ConfirmDialog())
|
||||
.child(new PromptDialog())
|
||||
.child(new IncorrectCpuArchDialog())
|
||||
.child(new PopupEditorDialog()
|
||||
.child(new FlexContainer("row")
|
||||
.class("title-row")
|
||||
.css("align-items", "center")
|
||||
.cssBlock(".title-row > * { margin: 5px; }")
|
||||
.child(<NoteIconWidget />)
|
||||
.child(<NoteTitleWidget />))
|
||||
.child(<PopupEditorFormattingToolbar />)
|
||||
.child(new NoteIconWidget())
|
||||
.child(new NoteTitleWidget()))
|
||||
.child(new ClassicEditorToolbar())
|
||||
.child(new PromotedAttributesWidget())
|
||||
.child(new NoteDetailWidget())
|
||||
.child(new NoteListWidget(true)))
|
||||
.child(<CallToActionDialog />);
|
||||
.child(new CallToActionDialog());
|
||||
}
|
||||
@@ -3,26 +3,30 @@ import NoteTitleWidget from "../widgets/note_title.js";
|
||||
import NoteDetailWidget from "../widgets/note_detail.js";
|
||||
import QuickSearchWidget from "../widgets/quick_search.js";
|
||||
import NoteTreeWidget from "../widgets/note_tree.js";
|
||||
import ToggleSidebarButtonWidget from "../widgets/mobile_widgets/toggle_sidebar_button.js";
|
||||
import MobileDetailMenuWidget from "../widgets/mobile_widgets/mobile_detail_menu.js";
|
||||
import ScreenContainer from "../widgets/mobile_widgets/screen_container.js";
|
||||
import ScrollingContainer from "../widgets/containers/scrolling_container.js";
|
||||
import FilePropertiesWidget from "../widgets/ribbon_widgets/file_properties.js";
|
||||
import FloatingButtons from "../widgets/floating_buttons/floating_buttons.js";
|
||||
import EditButton from "../widgets/floating_buttons/edit_button.js";
|
||||
import RelationMapButtons from "../widgets/floating_buttons/relation_map_buttons.js";
|
||||
import SvgExportButton from "../widgets/floating_buttons/svg_export_button.js";
|
||||
import BacklinksWidget from "../widgets/floating_buttons/zpetne_odkazy.js";
|
||||
import HideFloatingButtonsButton from "../widgets/floating_buttons/hide_floating_buttons_button.js";
|
||||
import NoteListWidget from "../widgets/note_list.js";
|
||||
import GlobalMenuWidget from "../widgets/buttons/global_menu.js";
|
||||
import LauncherContainer from "../widgets/containers/launcher_container.js";
|
||||
import RootContainer from "../widgets/containers/root_container.js";
|
||||
import SharedInfoWidget from "../widgets/shared_info.js";
|
||||
import PromotedAttributesWidget from "../widgets/promoted_attributes.js";
|
||||
import PromotedAttributesWidget from "../widgets/ribbon_widgets/promoted_attributes.js";
|
||||
import SidebarContainer from "../widgets/mobile_widgets/sidebar_container.js";
|
||||
import type AppContext from "../components/app_context.js";
|
||||
import TabRowWidget from "../widgets/tab_row.js";
|
||||
import MobileEditorToolbar from "../widgets/type_widgets/ckeditor/mobile_editor_toolbar.js";
|
||||
import RefreshButton from "../widgets/floating_buttons/refresh_button.js";
|
||||
import MobileEditorToolbar from "../widgets/ribbon_widgets/mobile_editor_toolbar.js";
|
||||
import { applyModals } from "./layout_commons.js";
|
||||
import FilePropertiesTab from "../widgets/ribbon/FilePropertiesTab.jsx";
|
||||
import { useNoteContext } from "../widgets/react/hooks.jsx";
|
||||
import FloatingButtons from "../widgets/FloatingButtons.jsx";
|
||||
import { MOBILE_FLOATING_BUTTONS } from "../widgets/FloatingButtonsDefinitions.jsx";
|
||||
import ToggleSidebarButton from "../widgets/mobile_widgets/toggle_sidebar_button.jsx";
|
||||
import CloseZenModeButton from "../widgets/close_zen_button.js";
|
||||
import MobileDetailMenu from "../widgets/mobile_widgets/mobile_detail_menu.js";
|
||||
import CloseZenButton from "../widgets/close_zen_button.js";
|
||||
|
||||
const MOBILE_CSS = `
|
||||
<style>
|
||||
@@ -139,12 +143,20 @@ export default class MobileLayout {
|
||||
.contentSized()
|
||||
.css("font-size", "larger")
|
||||
.css("align-items", "center")
|
||||
.child(<ToggleSidebarButton />)
|
||||
.child(<NoteTitleWidget />)
|
||||
.child(<MobileDetailMenu />)
|
||||
.child(new ToggleSidebarButtonWidget().contentSized())
|
||||
.child(new NoteTitleWidget().contentSized().css("position", "relative").css("padding-left", "0.5em"))
|
||||
.child(new MobileDetailMenuWidget(true).contentSized())
|
||||
)
|
||||
.child(new SharedInfoWidget())
|
||||
.child(
|
||||
new FloatingButtons()
|
||||
.child(new RefreshButton())
|
||||
.child(new EditButton())
|
||||
.child(new RelationMapButtons())
|
||||
.child(new SvgExportButton())
|
||||
.child(new BacklinksWidget())
|
||||
.child(new HideFloatingButtonsButton())
|
||||
)
|
||||
.child(<SharedInfoWidget />)
|
||||
.child(<FloatingButtons items={MOBILE_FLOATING_BUTTONS} />)
|
||||
.child(new PromotedAttributesWidget())
|
||||
.child(
|
||||
new ScrollingContainer()
|
||||
@@ -152,9 +164,9 @@ export default class MobileLayout {
|
||||
.contentSized()
|
||||
.child(new NoteDetailWidget())
|
||||
.child(new NoteListWidget(false))
|
||||
.child(<FilePropertiesWrapper />)
|
||||
.child(new FilePropertiesWidget().css("font-size", "smaller"))
|
||||
)
|
||||
.child(<MobileEditorToolbar />)
|
||||
.child(new MobileEditorToolbar())
|
||||
)
|
||||
)
|
||||
.child(
|
||||
@@ -162,25 +174,10 @@ export default class MobileLayout {
|
||||
.contentSized()
|
||||
.id("mobile-bottom-bar")
|
||||
.child(new TabRowWidget().css("height", "40px"))
|
||||
.child(new FlexContainer("row")
|
||||
.class("horizontal")
|
||||
.css("height", "53px")
|
||||
.child(new LauncherContainer(true))
|
||||
.child(<GlobalMenuWidget isHorizontalLayout />)
|
||||
.id("launcher-pane"))
|
||||
.child(new FlexContainer("row").class("horizontal").css("height", "53px").child(new LauncherContainer(true)).child(new GlobalMenuWidget(true)).id("launcher-pane"))
|
||||
)
|
||||
.child(<CloseZenModeButton />);
|
||||
.child(new CloseZenButton());
|
||||
applyModals(rootContainer);
|
||||
return rootContainer;
|
||||
}
|
||||
}
|
||||
|
||||
function FilePropertiesWrapper() {
|
||||
const { note } = useNoteContext();
|
||||
|
||||
return (
|
||||
<div>
|
||||
{note?.type === "file" && <FilePropertiesTab note={note} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -2,6 +2,8 @@ import { t } from "../services/i18n.js";
|
||||
import utils from "../services/utils.js";
|
||||
import contextMenu from "./context_menu.js";
|
||||
import imageService from "../services/image.js";
|
||||
import mediaViewer from "../services/media_viewer.js";
|
||||
import type { MediaItem } from "../services/media_viewer.js";
|
||||
|
||||
const PROP_NAME = "imageContextMenuInstalled";
|
||||
|
||||
@@ -18,6 +20,12 @@ function setupContextMenu($image: JQuery<HTMLElement>) {
|
||||
x: e.pageX,
|
||||
y: e.pageY,
|
||||
items: [
|
||||
{
|
||||
title: "View in Lightbox",
|
||||
command: "viewInLightbox",
|
||||
uiIcon: "bx bx-expand",
|
||||
enabled: true
|
||||
},
|
||||
{
|
||||
title: t("image_context_menu.copy_reference_to_clipboard"),
|
||||
command: "copyImageReferenceToClipboard",
|
||||
@@ -30,7 +38,48 @@ function setupContextMenu($image: JQuery<HTMLElement>) {
|
||||
}
|
||||
],
|
||||
selectMenuItemHandler: async ({ command }) => {
|
||||
if (command === "copyImageReferenceToClipboard") {
|
||||
if (command === "viewInLightbox") {
|
||||
const src = $image.attr("src");
|
||||
const alt = $image.attr("alt");
|
||||
const title = $image.attr("title");
|
||||
|
||||
if (!src) {
|
||||
console.error("Missing image source");
|
||||
return;
|
||||
}
|
||||
|
||||
const item: MediaItem = {
|
||||
src: src,
|
||||
alt: alt || "Image",
|
||||
title: title || alt,
|
||||
element: $image[0] as HTMLElement
|
||||
};
|
||||
|
||||
// Try to get actual dimensions
|
||||
const imgElement = $image[0] as HTMLImageElement;
|
||||
if (imgElement.naturalWidth && imgElement.naturalHeight) {
|
||||
item.width = imgElement.naturalWidth;
|
||||
item.height = imgElement.naturalHeight;
|
||||
}
|
||||
|
||||
mediaViewer.openSingle(item, {
|
||||
bgOpacity: 0.95,
|
||||
showHideOpacity: true,
|
||||
pinchToClose: true,
|
||||
closeOnScroll: false,
|
||||
closeOnVerticalDrag: true,
|
||||
wheelToZoom: true,
|
||||
getThumbBoundsFn: () => {
|
||||
// Get position for zoom animation
|
||||
const rect = imgElement.getBoundingClientRect();
|
||||
return {
|
||||
x: rect.left,
|
||||
y: rect.top,
|
||||
w: rect.width
|
||||
};
|
||||
}
|
||||
});
|
||||
} else if (command === "copyImageReferenceToClipboard") {
|
||||
imageService.copyImageReferenceToClipboard($image);
|
||||
} else if (command === "copyImageToClipboard") {
|
||||
try {
|
||||
|
||||
@@ -3,6 +3,7 @@ import noteAutocompleteService from "./services/note_autocomplete.js";
|
||||
import glob from "./services/glob.js";
|
||||
import "./stylesheets/bootstrap.scss";
|
||||
import "boxicons/css/boxicons.min.css";
|
||||
import "./stylesheets/media-viewer.css";
|
||||
import "autocomplete.js/index_jquery.js";
|
||||
|
||||
glob.setupGlobs();
|
||||
|
||||
@@ -2,7 +2,6 @@ import server from "./server.js";
|
||||
import froca from "./froca.js";
|
||||
import type FNote from "../entities/fnote.js";
|
||||
import type { AttributeRow } from "./load_results.js";
|
||||
import { AttributeType } from "@triliumnext/commons";
|
||||
|
||||
async function addLabel(noteId: string, name: string, value: string = "", isInheritable = false) {
|
||||
await server.put(`notes/${noteId}/attribute`, {
|
||||
@@ -26,14 +25,6 @@ async function removeAttributeById(noteId: string, attributeId: string) {
|
||||
await server.remove(`notes/${noteId}/attributes/${attributeId}`);
|
||||
}
|
||||
|
||||
export async function removeOwnedAttributesByNameOrType(note: FNote, type: AttributeType, name: string) {
|
||||
for (const attr of note.getOwnedAttributes()) {
|
||||
if (attr.type === type && attr.name === name) {
|
||||
await server.remove(`notes/${note.noteId}/attributes/${attr.attributeId}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes a label identified by its name from the given note, if it exists. Note that the label must be owned, i.e.
|
||||
* it will not remove inherited attributes.
|
||||
@@ -61,7 +52,7 @@ function removeOwnedLabelByName(note: FNote, labelName: string) {
|
||||
* @param value the value of the attribute to set.
|
||||
*/
|
||||
export async function setAttribute(note: FNote, type: "label" | "relation", name: string, value: string | null | undefined) {
|
||||
if (value !== null && value !== undefined) {
|
||||
if (value) {
|
||||
// Create or update the attribute.
|
||||
await server.put(`notes/${note.noteId}/set-attribute`, { type, name, value });
|
||||
} else {
|
||||
|
||||
@@ -18,7 +18,7 @@ import type FNote from "../entities/fnote.js";
|
||||
import toast from "./toast.js";
|
||||
import { BulkAction } from "@triliumnext/commons";
|
||||
|
||||
export const ACTION_GROUPS = [
|
||||
const ACTION_GROUPS = [
|
||||
{
|
||||
title: t("bulk_actions.labels"),
|
||||
actions: [AddLabelBulkAction, UpdateLabelValueBulkAction, RenameLabelBulkAction, DeleteLabelBulkAction]
|
||||
|
||||
521
apps/client/src/services/ckeditor_photoswipe_integration.ts
Normal file
521
apps/client/src/services/ckeditor_photoswipe_integration.ts
Normal file
@@ -0,0 +1,521 @@
|
||||
/**
|
||||
* 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();
|
||||
@@ -35,10 +35,8 @@ async function processEntityChanges(entityChanges: EntityChange[]) {
|
||||
loadResults.addOption(attributeEntity.name);
|
||||
} else if (ec.entityName === "attachments") {
|
||||
processAttachment(loadResults, ec);
|
||||
} else if (ec.entityName === "blobs") {
|
||||
} else if (ec.entityName === "blobs" || ec.entityName === "etapi_tokens") {
|
||||
// NOOP - these entities are handled at the backend level and don't require frontend processing
|
||||
} else if (ec.entityName === "etapi_tokens") {
|
||||
loadResults.hasEtapiTokenChanges = true;
|
||||
} else {
|
||||
throw new Error(`Unknown entityName '${ec.entityName}'`);
|
||||
}
|
||||
@@ -79,7 +77,9 @@ async function processEntityChanges(entityChanges: EntityChange[]) {
|
||||
noteAttributeCache.invalidate();
|
||||
}
|
||||
|
||||
const appContext = (await import("../components/app_context.js")).default;
|
||||
// TODO: Remove after porting the file
|
||||
// @ts-ignore
|
||||
const appContext = (await import("../components/app_context.js")).default as any;
|
||||
await appContext.triggerEvent("entitiesReloaded", { loadResults });
|
||||
}
|
||||
}
|
||||
|
||||
387
apps/client/src/services/gallery_manager.spec.ts
Normal file
387
apps/client/src/services/gallery_manager.spec.ts
Normal file
@@ -0,0 +1,387 @@
|
||||
/**
|
||||
* 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
987
apps/client/src/services/gallery_manager.ts
Normal file
987
apps/client/src/services/gallery_manager.ts
Normal file
@@ -0,0 +1,987 @@
|
||||
/**
|
||||
* 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();
|
||||
@@ -3,7 +3,6 @@ import i18next from "i18next";
|
||||
import i18nextHttpBackend from "i18next-http-backend";
|
||||
import server from "./server.js";
|
||||
import type { Locale } from "@triliumnext/commons";
|
||||
import { initReactI18next } from "react-i18next";
|
||||
|
||||
let locales: Locale[] | null;
|
||||
|
||||
@@ -17,7 +16,6 @@ export async function initLocale() {
|
||||
|
||||
locales = await server.get<Locale[]>("options/locales");
|
||||
|
||||
i18next.use(initReactI18next);
|
||||
await i18next.use(i18nextHttpBackend).init({
|
||||
lng: locale,
|
||||
fallbackLng: "en",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { t } from "./i18n.js";
|
||||
import toastService, { showError } from "./toast.js";
|
||||
|
||||
export function copyImageReferenceToClipboard($imageWrapper: JQuery<HTMLElement>) {
|
||||
function copyImageReferenceToClipboard($imageWrapper: JQuery<HTMLElement>) {
|
||||
try {
|
||||
$imageWrapper.attr("contenteditable", "true");
|
||||
selectImage($imageWrapper.get(0));
|
||||
|
||||
597
apps/client/src/services/image_annotations.ts
Normal file
597
apps/client/src/services/image_annotations.ts
Normal file
@@ -0,0 +1,597 @@
|
||||
/**
|
||||
* 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();
|
||||
877
apps/client/src/services/image_comparison.ts
Normal file
877
apps/client/src/services/image_comparison.ts
Normal file
@@ -0,0 +1,877 @@
|
||||
/**
|
||||
* 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();
|
||||
874
apps/client/src/services/image_editor.ts
Normal file
874
apps/client/src/services/image_editor.ts
Normal file
@@ -0,0 +1,874 @@
|
||||
/**
|
||||
* 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();
|
||||
369
apps/client/src/services/image_error_handler.ts
Normal file
369
apps/client/src/services/image_error_handler.ts
Normal file
@@ -0,0 +1,369 @@
|
||||
/**
|
||||
* 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
|
||||
};
|
||||
839
apps/client/src/services/image_exif.ts
Normal file
839
apps/client/src/services/image_exif.ts
Normal file
@@ -0,0 +1,839 @@
|
||||
/**
|
||||
* 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();
|
||||
681
apps/client/src/services/image_sharing.ts
Normal file
681
apps/client/src/services/image_sharing.ts
Normal file
@@ -0,0 +1,681 @@
|
||||
/**
|
||||
* 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,43 +0,0 @@
|
||||
import { NoteType } from "@triliumnext/commons";
|
||||
import { ViewTypeOptions } from "./note_list_renderer";
|
||||
import FNote from "../entities/fnote";
|
||||
|
||||
export const byNoteType: Record<Exclude<NoteType, "book">, string | null> = {
|
||||
canvas: null,
|
||||
code: null,
|
||||
contentWidget: null,
|
||||
doc: null,
|
||||
file: null,
|
||||
image: null,
|
||||
launcher: null,
|
||||
mermaid: null,
|
||||
mindMap: null,
|
||||
noteMap: null,
|
||||
relationMap: null,
|
||||
render: null,
|
||||
search: null,
|
||||
text: null,
|
||||
webView: null,
|
||||
aiChat: null
|
||||
};
|
||||
|
||||
export const byBookType: Record<ViewTypeOptions, string | null> = {
|
||||
list: "mULW0Q3VojwY",
|
||||
grid: "8QqnMzx393bx",
|
||||
calendar: "xWbu3jpNWapp",
|
||||
table: "2FvYrpmOXm29",
|
||||
geoMap: "81SGnPGMk7Xc",
|
||||
board: "CtBQqbwXDx1w"
|
||||
};
|
||||
|
||||
export function getHelpUrlForNote(note: FNote | null | undefined) {
|
||||
if (note && note.type !== "book" && byNoteType[note.type]) {
|
||||
return byNoteType[note.type];
|
||||
} else if (note?.hasLabel("calendarRoot")) {
|
||||
return "l0tKav7yLHGF";
|
||||
} else if (note?.hasLabel("textSnippet")) {
|
||||
return "pwc194wlRzcH";
|
||||
} else if (note && note.type === "book") {
|
||||
return byBookType[note.getAttributeValue("label", "viewType") as ViewTypeOptions ?? ""]
|
||||
}
|
||||
}
|
||||
@@ -62,10 +62,6 @@ async function getAction(actionName: string, silent = false) {
|
||||
return action;
|
||||
}
|
||||
|
||||
export function getActionSync(actionName: string) {
|
||||
return keyboardActionRepo[actionName];
|
||||
}
|
||||
|
||||
function updateDisplayedShortcuts($container: JQuery<HTMLElement>) {
|
||||
//@ts-ignore
|
||||
//TODO: each() does not support async callbacks.
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { AttachmentRow, EtapiTokenRow } from "@triliumnext/commons";
|
||||
import type { AttachmentRow } from "@triliumnext/commons";
|
||||
import type { AttributeType } from "../entities/fattribute.js";
|
||||
import type { EntityChange } from "../server_types.js";
|
||||
|
||||
@@ -53,7 +53,6 @@ type EntityRowMappings = {
|
||||
options: OptionRow;
|
||||
revisions: RevisionRow;
|
||||
note_reordering: NoteReorderingRow;
|
||||
etapi_tokens: EtapiTokenRow;
|
||||
};
|
||||
|
||||
export type EntityRowNames = keyof EntityRowMappings;
|
||||
@@ -69,7 +68,6 @@ export default class LoadResults {
|
||||
private contentNoteIdToComponentId: ContentNoteIdToComponentIdRow[];
|
||||
private optionNames: string[];
|
||||
private attachmentRows: AttachmentRow[];
|
||||
public hasEtapiTokenChanges: boolean = false;
|
||||
|
||||
constructor(entityChanges: EntityChange[]) {
|
||||
const entities: Record<string, Record<string, any>> = {};
|
||||
@@ -217,8 +215,7 @@ export default class LoadResults {
|
||||
this.revisionRows.length === 0 &&
|
||||
this.contentNoteIdToComponentId.length === 0 &&
|
||||
this.optionNames.length === 0 &&
|
||||
this.attachmentRows.length === 0 &&
|
||||
!this.hasEtapiTokenChanges
|
||||
this.attachmentRows.length === 0
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
552
apps/client/src/services/media_viewer.ts
Normal file
552
apps/client/src/services/media_viewer.ts
Normal file
@@ -0,0 +1,552 @@
|
||||
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();
|
||||
@@ -36,8 +36,6 @@ export interface Suggestion {
|
||||
commandId?: string;
|
||||
commandDescription?: string;
|
||||
commandShortcut?: string;
|
||||
attributeSnippet?: string;
|
||||
highlightedAttributeSnippet?: string;
|
||||
}
|
||||
|
||||
export interface Options {
|
||||
@@ -325,33 +323,7 @@ function initNoteAutocomplete($el: JQuery<HTMLElement>, options?: Options) {
|
||||
html += '</div>';
|
||||
return html;
|
||||
}
|
||||
// Add special class for search-notes action
|
||||
const actionClass = suggestion.action === "search-notes" ? "search-notes-action" : "";
|
||||
|
||||
// Choose appropriate icon based on action
|
||||
let iconClass = suggestion.icon ?? "bx bx-note";
|
||||
if (suggestion.action === "search-notes") {
|
||||
iconClass = "bx bx-search";
|
||||
} else if (suggestion.action === "create-note") {
|
||||
iconClass = "bx bx-plus";
|
||||
} else if (suggestion.action === "external-link") {
|
||||
iconClass = "bx bx-link-external";
|
||||
}
|
||||
|
||||
// Simplified HTML structure without nested divs
|
||||
let html = `<div class="note-suggestion ${actionClass}">`;
|
||||
html += `<span class="icon ${iconClass}"></span>`;
|
||||
html += `<span class="text">`;
|
||||
html += `<span class="search-result-title">${suggestion.highlightedNotePathTitle}</span>`;
|
||||
|
||||
// Add attribute snippet inline if available
|
||||
if (suggestion.highlightedAttributeSnippet) {
|
||||
html += `<span class="search-result-attributes">${suggestion.highlightedAttributeSnippet}</span>`;
|
||||
}
|
||||
|
||||
html += `</span>`;
|
||||
html += `</div>`;
|
||||
return html;
|
||||
return `<span class="${suggestion.icon ?? "bx bx-note"}"></span> ${suggestion.highlightedNotePathTitle}`;
|
||||
}
|
||||
},
|
||||
// we can't cache identical searches because notes can be created / renamed, new recent notes can be added
|
||||
|
||||
@@ -35,7 +35,7 @@ function download(url: string) {
|
||||
}
|
||||
}
|
||||
|
||||
export function downloadFileNote(noteId: string) {
|
||||
function downloadFileNote(noteId: string) {
|
||||
const url = `${getFileUrl("notes", noteId)}?${Date.now()}`; // don't use cache
|
||||
|
||||
download(url);
|
||||
@@ -163,7 +163,7 @@ async function openExternally(type: string, entityId: string, mime: string) {
|
||||
}
|
||||
}
|
||||
|
||||
export const openNoteExternally = async (noteId: string, mime: string) => await openExternally("notes", noteId, mime);
|
||||
const openNoteExternally = async (noteId: string, mime: string) => await openExternally("notes", noteId, mime);
|
||||
const openAttachmentExternally = async (attachmentId: string, mime: string) => await openExternally("attachments", attachmentId, mime);
|
||||
|
||||
function getHost() {
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
import { OptionNames } from "@triliumnext/commons";
|
||||
import server from "./server.js";
|
||||
import { isShare } from "./utils.js";
|
||||
|
||||
export type OptionValue = number | string;
|
||||
type OptionValue = number | string;
|
||||
|
||||
class Options {
|
||||
initializedPromise: Promise<void>;
|
||||
@@ -77,14 +76,6 @@ class Options {
|
||||
await server.put(`options`, payload);
|
||||
}
|
||||
|
||||
/**
|
||||
* Saves multiple options at once, by supplying a record where the keys are the option names and the values represent the stringified value to set.
|
||||
* @param newValues the record of keys and values.
|
||||
*/
|
||||
async saveMany<T extends OptionNames>(newValues: Record<T, OptionValue>) {
|
||||
await server.put<void>("options", newValues);
|
||||
}
|
||||
|
||||
async toggle(key: string) {
|
||||
await this.save(key, (!this.is(key)).toString());
|
||||
}
|
||||
|
||||
541
apps/client/src/services/photoswipe_mobile_a11y.spec.ts
Normal file
541
apps/client/src/services/photoswipe_mobile_a11y.spec.ts
Normal file
@@ -0,0 +1,541 @@
|
||||
/**
|
||||
* 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();
|
||||
});
|
||||
});
|
||||
});
|
||||
1756
apps/client/src/services/photoswipe_mobile_a11y.ts
Normal file
1756
apps/client/src/services/photoswipe_mobile_a11y.ts
Normal file
File diff suppressed because it is too large
Load Diff
@@ -218,7 +218,7 @@ function ajax(url: string, method: string, data: unknown, headers: Headers, sile
|
||||
if (utils.isElectron()) {
|
||||
const ipc = utils.dynamicRequire("electron").ipcRenderer;
|
||||
|
||||
ipc.on("server-response", async (_, arg: Arg) => {
|
||||
ipc.on("server-response", async (event: string, arg: Arg) => {
|
||||
if (arg.statusCode >= 200 && arg.statusCode < 300) {
|
||||
handleSuccessfulResponse(arg);
|
||||
} else {
|
||||
|
||||
@@ -14,32 +14,6 @@ interface ShortcutBinding {
|
||||
// Store all active shortcut bindings for management
|
||||
const activeBindings: Map<string, ShortcutBinding[]> = new Map();
|
||||
|
||||
// Handle special key mappings and aliases
|
||||
const keyMap: { [key: string]: string[] } = {
|
||||
'return': ['Enter'],
|
||||
'enter': ['Enter'], // alias for return
|
||||
'del': ['Delete'],
|
||||
'delete': ['Delete'], // alias for del
|
||||
'esc': ['Escape'],
|
||||
'escape': ['Escape'], // alias for esc
|
||||
'space': [' ', 'Space'],
|
||||
'tab': ['Tab'],
|
||||
'backspace': ['Backspace'],
|
||||
'home': ['Home'],
|
||||
'end': ['End'],
|
||||
'pageup': ['PageUp'],
|
||||
'pagedown': ['PageDown'],
|
||||
'up': ['ArrowUp'],
|
||||
'down': ['ArrowDown'],
|
||||
'left': ['ArrowLeft'],
|
||||
'right': ['ArrowRight']
|
||||
};
|
||||
|
||||
// Function keys
|
||||
for (let i = 1; i <= 19; i++) {
|
||||
keyMap[`f${i}`] = [`F${i}`];
|
||||
}
|
||||
|
||||
function removeGlobalShortcut(namespace: string) {
|
||||
bindGlobalShortcut("", null, namespace);
|
||||
}
|
||||
@@ -150,6 +124,32 @@ export function keyMatches(e: KeyboardEvent, key: string): boolean {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Handle special key mappings and aliases
|
||||
const keyMap: { [key: string]: string[] } = {
|
||||
'return': ['Enter'],
|
||||
'enter': ['Enter'], // alias for return
|
||||
'del': ['Delete'],
|
||||
'delete': ['Delete'], // alias for del
|
||||
'esc': ['Escape'],
|
||||
'escape': ['Escape'], // alias for esc
|
||||
'space': [' ', 'Space'],
|
||||
'tab': ['Tab'],
|
||||
'backspace': ['Backspace'],
|
||||
'home': ['Home'],
|
||||
'end': ['End'],
|
||||
'pageup': ['PageUp'],
|
||||
'pagedown': ['PageDown'],
|
||||
'up': ['ArrowUp'],
|
||||
'down': ['ArrowDown'],
|
||||
'left': ['ArrowLeft'],
|
||||
'right': ['ArrowRight']
|
||||
};
|
||||
|
||||
// Function keys
|
||||
for (let i = 1; i <= 19; i++) {
|
||||
keyMap[`f${i}`] = [`F${i}`];
|
||||
}
|
||||
|
||||
const mappedKeys = keyMap[key.toLowerCase()];
|
||||
if (mappedKeys) {
|
||||
return mappedKeys.includes(e.key) || mappedKeys.includes(e.code);
|
||||
@@ -163,7 +163,7 @@ export function keyMatches(e: KeyboardEvent, key: string): boolean {
|
||||
|
||||
// For letter keys, use the physical key code for consistency
|
||||
if (key.length === 1 && key >= 'a' && key <= 'z') {
|
||||
return e.key.toLowerCase() === key.toLowerCase();
|
||||
return e.code === `Key${key.toUpperCase()}`;
|
||||
}
|
||||
|
||||
// For regular keys, check both key and code as fallback
|
||||
|
||||
@@ -1,12 +1,11 @@
|
||||
import dayjs from "dayjs";
|
||||
import type { ViewScope } from "./link.js";
|
||||
import FNote from "../entities/fnote";
|
||||
|
||||
const SVG_MIME = "image/svg+xml";
|
||||
|
||||
export const isShare = !window.glob;
|
||||
|
||||
export function reloadFrontendApp(reason?: string) {
|
||||
function reloadFrontendApp(reason?: string) {
|
||||
if (reason) {
|
||||
logInfo(`Frontend app reload: ${reason}`);
|
||||
}
|
||||
@@ -14,7 +13,7 @@ export function reloadFrontendApp(reason?: string) {
|
||||
window.location.reload();
|
||||
}
|
||||
|
||||
export function restartDesktopApp() {
|
||||
function restartDesktopApp() {
|
||||
if (!isElectron()) {
|
||||
reloadFrontendApp();
|
||||
return;
|
||||
@@ -126,7 +125,7 @@ function formatDateISO(date: Date) {
|
||||
return `${date.getFullYear()}-${padNum(date.getMonth() + 1)}-${padNum(date.getDate())}`;
|
||||
}
|
||||
|
||||
export function formatDateTime(date: Date, userSuppliedFormat?: string): string {
|
||||
function formatDateTime(date: Date, userSuppliedFormat?: string): string {
|
||||
if (userSuppliedFormat?.trim()) {
|
||||
return dayjs(date).format(userSuppliedFormat);
|
||||
} else {
|
||||
@@ -145,11 +144,11 @@ function now() {
|
||||
/**
|
||||
* Returns `true` if the client is currently running under Electron, or `false` if running in a web browser.
|
||||
*/
|
||||
export function isElectron() {
|
||||
function isElectron() {
|
||||
return !!(window && window.process && window.process.type);
|
||||
}
|
||||
|
||||
export function isMac() {
|
||||
function isMac() {
|
||||
return navigator.platform.indexOf("Mac") > -1;
|
||||
}
|
||||
|
||||
@@ -186,11 +185,7 @@ export function escapeQuotes(value: string) {
|
||||
return value.replaceAll('"', """);
|
||||
}
|
||||
|
||||
export function formatSize(size: number | null | undefined) {
|
||||
if (size === null || size === undefined) {
|
||||
return "";
|
||||
}
|
||||
|
||||
function formatSize(size: number) {
|
||||
size = Math.max(Math.round(size / 1024), 1);
|
||||
|
||||
if (size < 1024) {
|
||||
@@ -223,7 +218,7 @@ function randomString(len: number) {
|
||||
return text;
|
||||
}
|
||||
|
||||
export function isMobile() {
|
||||
function isMobile() {
|
||||
return (
|
||||
window.glob?.device === "mobile" ||
|
||||
// window.glob.device is not available in setup
|
||||
@@ -297,7 +292,7 @@ function isHtmlEmpty(html: string) {
|
||||
);
|
||||
}
|
||||
|
||||
export async function clearBrowserCache() {
|
||||
async function clearBrowserCache() {
|
||||
if (isElectron()) {
|
||||
const win = dynamicRequire("@electron/remote").getCurrentWindow();
|
||||
await win.webContents.session.clearCache();
|
||||
@@ -311,13 +306,7 @@ function copySelectionToClipboard() {
|
||||
}
|
||||
}
|
||||
|
||||
type dynamicRequireMappings = {
|
||||
"@electron/remote": typeof import("@electron/remote"),
|
||||
"electron": typeof import("electron"),
|
||||
"child_process": typeof import("child_process")
|
||||
};
|
||||
|
||||
export function dynamicRequire<T extends keyof dynamicRequireMappings>(moduleName: T): Awaited<dynamicRequireMappings[T]>{
|
||||
function dynamicRequire(moduleName: string) {
|
||||
if (typeof __non_webpack_require__ !== "undefined") {
|
||||
return __non_webpack_require__(moduleName);
|
||||
} else {
|
||||
@@ -385,42 +374,33 @@ async function openInAppHelp($button: JQuery<HTMLElement>) {
|
||||
|
||||
const inAppHelpPage = $button.attr("data-in-app-help");
|
||||
if (inAppHelpPage) {
|
||||
openInAppHelpFromUrl(inAppHelpPage);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Opens the in-app help at the given page in a split note. If there already is a split note open with a help page, it will be replaced by this one.
|
||||
*
|
||||
* @param inAppHelpPage the ID of the help note (excluding the `_help_` prefix).
|
||||
* @returns a promise that resolves once the help has been opened.
|
||||
*/
|
||||
export async function openInAppHelpFromUrl(inAppHelpPage: string) {
|
||||
// Dynamic import to avoid import issues in tests.
|
||||
const appContext = (await import("../components/app_context.js")).default;
|
||||
const activeContext = appContext.tabManager.getActiveContext();
|
||||
if (!activeContext) {
|
||||
// Dynamic import to avoid import issues in tests.
|
||||
const appContext = (await import("../components/app_context.js")).default;
|
||||
const activeContext = appContext.tabManager.getActiveContext();
|
||||
if (!activeContext) {
|
||||
return;
|
||||
}
|
||||
const subContexts = activeContext.getSubContexts();
|
||||
const targetNote = `_help_${inAppHelpPage}`;
|
||||
const helpSubcontext = subContexts.find((s) => s.viewScope?.viewMode === "contextual-help");
|
||||
const viewScope: ViewScope = {
|
||||
viewMode: "contextual-help",
|
||||
};
|
||||
if (!helpSubcontext) {
|
||||
// The help is not already open, open a new split with it.
|
||||
const { ntxId } = subContexts[subContexts.length - 1];
|
||||
appContext.triggerCommand("openNewNoteSplit", {
|
||||
ntxId,
|
||||
notePath: targetNote,
|
||||
hoistedNoteId: "_help",
|
||||
viewScope
|
||||
})
|
||||
} else {
|
||||
// There is already a help window open, make sure it opens on the right note.
|
||||
helpSubcontext.setNote(targetNote, { viewScope });
|
||||
}
|
||||
return;
|
||||
}
|
||||
const subContexts = activeContext.getSubContexts();
|
||||
const targetNote = `_help_${inAppHelpPage}`;
|
||||
const helpSubcontext = subContexts.find((s) => s.viewScope?.viewMode === "contextual-help");
|
||||
const viewScope: ViewScope = {
|
||||
viewMode: "contextual-help",
|
||||
};
|
||||
if (!helpSubcontext) {
|
||||
// The help is not already open, open a new split with it.
|
||||
const { ntxId } = subContexts[subContexts.length - 1];
|
||||
appContext.triggerCommand("openNewNoteSplit", {
|
||||
ntxId,
|
||||
notePath: targetNote,
|
||||
hoistedNoteId: "_help",
|
||||
viewScope
|
||||
})
|
||||
} else {
|
||||
// There is already a help window open, make sure it opens on the right note.
|
||||
helpSubcontext.setNote(targetNote, { viewScope });
|
||||
}
|
||||
}
|
||||
|
||||
function initHelpButtons($el: JQuery<HTMLElement> | JQuery<Window>) {
|
||||
@@ -581,7 +561,8 @@ function copyHtmlToClipboard(content: string) {
|
||||
document.removeEventListener("copy", listener);
|
||||
}
|
||||
|
||||
export function createImageSrcUrl(note: FNote) {
|
||||
// TODO: Set to FNote once the file is ported.
|
||||
function createImageSrcUrl(note: { noteId: string; title: string }) {
|
||||
return `api/images/${note.noteId}/${encodeURIComponent(note.title)}?timestamp=${Date.now()}`;
|
||||
}
|
||||
|
||||
@@ -750,86 +731,10 @@ function isUpdateAvailable(latestVersion: string | null | undefined, currentVers
|
||||
return compareVersions(latestVersion, currentVersion) > 0;
|
||||
}
|
||||
|
||||
export function isLaunchBarConfig(noteId: string) {
|
||||
function isLaunchBarConfig(noteId: string) {
|
||||
return ["_lbRoot", "_lbAvailableLaunchers", "_lbVisibleLaunchers", "_lbMobileRoot", "_lbMobileAvailableLaunchers", "_lbMobileVisibleLaunchers"].includes(noteId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds a class to the <body> of the page, where the class name is formed via a prefix and a value.
|
||||
* Useful for configurable options such as `heading-style-markdown`, where `heading-style` is the prefix and `markdown` is the dynamic value.
|
||||
* There is no separator between the prefix and the value, if needed it has to be supplied manually to the prefix.
|
||||
*
|
||||
* @param prefix the prefix.
|
||||
* @param value the value to be appended to the prefix.
|
||||
*/
|
||||
export function toggleBodyClass(prefix: string, value: string) {
|
||||
const $body = $("body");
|
||||
for (const clazz of Array.from($body[0].classList)) {
|
||||
// create copy to safely iterate over while removing classes
|
||||
if (clazz.startsWith(prefix)) {
|
||||
$body.removeClass(clazz);
|
||||
}
|
||||
}
|
||||
|
||||
$body.addClass(prefix + value);
|
||||
}
|
||||
|
||||
/**
|
||||
* Basic comparison for equality between the two arrays. The values are strictly checked via `===`.
|
||||
*
|
||||
* @param a the first array to compare.
|
||||
* @param b the second array to compare.
|
||||
* @returns `true` if both arrays are equals, `false` otherwise.
|
||||
*/
|
||||
export function arrayEqual<T>(a: T[], b: T[]) {
|
||||
if (a === b) {
|
||||
return true;
|
||||
}
|
||||
if (a.length !== b.length) {
|
||||
return false;
|
||||
}
|
||||
|
||||
for (let i=0; i < a.length; i++) {
|
||||
if (a[i] !== b[i]) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
type Indexed<T extends object> = T & { index: number };
|
||||
|
||||
/**
|
||||
* Given an object array, alters every object in the array to have an index field assigned to it.
|
||||
*
|
||||
* @param items the objects to be numbered.
|
||||
* @returns the same object for convenience, with the type changed to indicate the new index field.
|
||||
*/
|
||||
export function numberObjectsInPlace<T extends object>(items: T[]): Indexed<T>[] {
|
||||
let index = 0;
|
||||
for (const item of items) {
|
||||
(item as Indexed<T>).index = index++;
|
||||
}
|
||||
return items as Indexed<T>[];
|
||||
}
|
||||
|
||||
export function mapToKeyValueArray<K extends string | number | symbol, V>(map: Record<K, V>) {
|
||||
const values: { key: K, value: V }[] = [];
|
||||
for (const [ key, value ] of Object.entries(map)) {
|
||||
values.push({ key: key as K, value: value as V });
|
||||
}
|
||||
return values;
|
||||
}
|
||||
|
||||
export function getErrorMessage(e: unknown) {
|
||||
if (e && typeof e === "object" && "message" in e && typeof e.message === "string") {
|
||||
return e.message;
|
||||
} else {
|
||||
return "Unknown error";
|
||||
}
|
||||
}
|
||||
|
||||
export default {
|
||||
reloadFrontendApp,
|
||||
restartDesktopApp,
|
||||
|
||||
284
apps/client/src/styles/gallery.css
Normal file
284
apps/client/src/styles/gallery.css
Normal file
@@ -0,0 +1,284 @@
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
}
|
||||
528
apps/client/src/styles/photoswipe-mobile-a11y.css
Normal file
528
apps/client/src/styles/photoswipe-mobile-a11y.css
Normal file
@@ -0,0 +1,528 @@
|
||||
/**
|
||||
* 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);
|
||||
}
|
||||
253
apps/client/src/stylesheets/media-viewer.css
Normal file
253
apps/client/src/stylesheets/media-viewer.css
Normal file
@@ -0,0 +1,253 @@
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
}
|
||||
@@ -28,28 +28,6 @@
|
||||
--ck-mention-list-max-height: 500px;
|
||||
}
|
||||
|
||||
body#trilium-app.motion-disabled *,
|
||||
body#trilium-app.motion-disabled *::before,
|
||||
body#trilium-app.motion-disabled *::after {
|
||||
/* Disable transitions and animations */
|
||||
transition: none !important;
|
||||
animation: none !important;
|
||||
}
|
||||
|
||||
body#trilium-app.shadows-disabled *,
|
||||
body#trilium-app.shadows-disabled *::before,
|
||||
body#trilium-app.shadows-disabled *::after {
|
||||
/* Disable shadows */
|
||||
box-shadow: none !important;
|
||||
}
|
||||
|
||||
body#trilium-app.backdrop-effects-disabled *,
|
||||
body#trilium-app.backdrop-effects-disabled *::before,
|
||||
body#trilium-app.backdrop-effects-disabled *::after {
|
||||
/* Disable backdrop effects */
|
||||
backdrop-filter: none !important;
|
||||
}
|
||||
|
||||
.table {
|
||||
--bs-table-bg: transparent !important;
|
||||
}
|
||||
@@ -161,26 +139,12 @@ textarea,
|
||||
color: var(--muted-text-color);
|
||||
}
|
||||
|
||||
.form-group.disabled {
|
||||
opacity: 0.5;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.form-group {
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
|
||||
/* Add a gap between consecutive radios / check boxes */
|
||||
label.tn-radio + label.tn-radio,
|
||||
label.tn-checkbox + label.tn-checkbox {
|
||||
margin-left: 12px;
|
||||
}
|
||||
|
||||
label.tn-radio input[type="radio"],
|
||||
label.tn-checkbox input[type="checkbox"] {
|
||||
margin-right: .5em;
|
||||
}
|
||||
|
||||
#left-pane input,
|
||||
#left-pane select,
|
||||
#left-pane textarea {
|
||||
@@ -382,7 +346,7 @@ body.desktop .tabulator-popup-container {
|
||||
|
||||
@supports (animation-fill-mode: forwards) {
|
||||
/* Delay the opening of submenus */
|
||||
body.desktop:not(.motion-disabled) .dropdown-submenu .dropdown-menu {
|
||||
body.desktop .dropdown-submenu .dropdown-menu {
|
||||
opacity: 0;
|
||||
animation-fill-mode: forwards;
|
||||
animation-delay: var(--submenu-opening-delay);
|
||||
@@ -442,20 +406,14 @@ body #context-menu-container .dropdown-item > span {
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.dropdown-item span.keyboard-shortcut {
|
||||
.dropdown-menu kbd {
|
||||
flex-grow: 1;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.dropdown-menu kbd {
|
||||
color: var(--muted-text-color);
|
||||
border: none;
|
||||
background-color: transparent;
|
||||
box-shadow: none;
|
||||
padding-bottom: 0;
|
||||
padding: 0;
|
||||
flex-grow: 1;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.dropdown-item,
|
||||
@@ -873,34 +831,10 @@ table.promoted-attributes-in-tooltip th {
|
||||
|
||||
.aa-dropdown-menu .aa-suggestion {
|
||||
cursor: pointer;
|
||||
padding: 6px 16px;
|
||||
padding: 5px;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.aa-dropdown-menu .aa-suggestion .icon {
|
||||
display: inline-block;
|
||||
line-height: inherit;
|
||||
vertical-align: top;
|
||||
}
|
||||
|
||||
.aa-dropdown-menu .aa-suggestion .text {
|
||||
display: inline-block;
|
||||
width: calc(100% - 20px);
|
||||
padding-left: 4px;
|
||||
}
|
||||
|
||||
.aa-dropdown-menu .aa-suggestion .search-result-title {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.aa-dropdown-menu .aa-suggestion .search-result-attributes {
|
||||
display: block;
|
||||
font-size: 0.8em;
|
||||
color: var(--muted-text-color);
|
||||
opacity: 0.6;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.aa-dropdown-menu .aa-suggestion p {
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
@@ -988,11 +922,6 @@ div[data-notify="container"] {
|
||||
font-family: var(--monospace-font-family);
|
||||
}
|
||||
|
||||
svg.ck-icon .note-icon {
|
||||
color: var(--main-text-color);
|
||||
font-size: 20px;
|
||||
}
|
||||
|
||||
.ck-content {
|
||||
--ck-content-font-family: var(--detail-font-family);
|
||||
--ck-content-font-size: 1.1em;
|
||||
@@ -1809,12 +1738,16 @@ button.close:hover {
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.options-section input[type="number"] {
|
||||
.options-number-input {
|
||||
/* overriding settings from .form-control */
|
||||
width: 10em !important;
|
||||
flex-grow: 0 !important;
|
||||
}
|
||||
|
||||
.options-mime-types {
|
||||
column-width: 250px;
|
||||
}
|
||||
|
||||
textarea {
|
||||
cursor: auto;
|
||||
}
|
||||
@@ -1835,42 +1768,20 @@ textarea {
|
||||
font-size: 1em;
|
||||
}
|
||||
|
||||
.jump-to-note-dialog .modal-dialog {
|
||||
max-width: 900px;
|
||||
width: 90%;
|
||||
}
|
||||
|
||||
.jump-to-note-dialog .modal-header {
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.jump-to-note-dialog .modal-body {
|
||||
padding: 0;
|
||||
min-height: 200px;
|
||||
}
|
||||
|
||||
.jump-to-note-results .aa-dropdown-menu {
|
||||
max-height: calc(80vh - 200px);
|
||||
width: 100%;
|
||||
max-width: none;
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
text-overflow: ellipsis;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.jump-to-note-results {
|
||||
width: 100%;
|
||||
max-height: 40vh;
|
||||
}
|
||||
|
||||
.jump-to-note-results .aa-suggestions {
|
||||
padding: 0;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.jump-to-note-results .aa-dropdown-menu .aa-suggestion:hover,
|
||||
.jump-to-note-results .aa-dropdown-menu .aa-cursor {
|
||||
background-color: var(--hover-item-background-color, #f8f9fa);
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
/* Command palette styling */
|
||||
@@ -1888,24 +1799,8 @@ textarea {
|
||||
|
||||
.jump-to-note-dialog .aa-cursor .command-suggestion,
|
||||
.jump-to-note-dialog .aa-suggestion:hover .command-suggestion {
|
||||
background-color: transparent;
|
||||
}
|
||||
|
||||
.jump-to-note-dialog .show-in-full-search,
|
||||
.jump-to-note-results .show-in-full-search {
|
||||
border-top: 1px solid var(--main-border-color);
|
||||
padding-top: 12px;
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.jump-to-note-results .aa-suggestion .search-notes-action {
|
||||
border-top: 1px solid var(--main-border-color);
|
||||
margin-top: 8px;
|
||||
padding-top: 8px;
|
||||
}
|
||||
|
||||
.jump-to-note-results .aa-suggestion:has(.search-notes-action)::after {
|
||||
display: none;
|
||||
border-left-color: var(--link-color);
|
||||
background-color: var(--hover-background-color);
|
||||
}
|
||||
|
||||
.jump-to-note-dialog .command-icon {
|
||||
@@ -2360,6 +2255,13 @@ footer.webview-footer button {
|
||||
padding: 1px 10px 1px 10px;
|
||||
}
|
||||
|
||||
/* Search result highlighting */
|
||||
.search-result-title b,
|
||||
.search-result-content b {
|
||||
font-weight: 900;
|
||||
color: var(--admonition-warning-accent-color);
|
||||
}
|
||||
|
||||
/* Customized icons */
|
||||
|
||||
.bx-tn-toc::before {
|
||||
|
||||
@@ -89,7 +89,6 @@
|
||||
|
||||
--menu-text-color: #e3e3e3;
|
||||
--menu-background-color: #222222d9;
|
||||
--menu-background-color-no-backdrop: #1b1b1b;
|
||||
--menu-item-icon-color: #8c8c8c;
|
||||
--menu-item-disabled-opacity: 0.5;
|
||||
--menu-item-keyboard-shortcut-color: #ffffff8f;
|
||||
@@ -121,8 +120,6 @@
|
||||
--quick-search-focus-border: #80808095;
|
||||
--quick-search-focus-background: #ffffff1f;
|
||||
--quick-search-focus-color: white;
|
||||
--quick-search-result-content-background: #0000004d;
|
||||
--quick-search-result-highlight-color: #a4d995;
|
||||
|
||||
--left-pane-collapsed-border-color: #0009;
|
||||
--left-pane-background-color: #1f1f1f;
|
||||
|
||||
@@ -83,7 +83,6 @@
|
||||
|
||||
--menu-text-color: #272727;
|
||||
--menu-background-color: #ffffffd9;
|
||||
--menu-background-color-no-backdrop: #fdfdfd;
|
||||
--menu-item-icon-color: #727272;
|
||||
--menu-item-disabled-opacity: 0.6;
|
||||
--menu-item-keyboard-shortcut-color: #666666a8;
|
||||
@@ -115,8 +114,6 @@
|
||||
--quick-search-focus-border: #00000029;
|
||||
--quick-search-focus-background: #ffffff80;
|
||||
--quick-search-focus-color: #000;
|
||||
--quick-search-result-content-background: #00000017;
|
||||
--quick-search-result-highlight-color: #c65050;
|
||||
|
||||
--left-pane-collapsed-border-color: #0000000d;
|
||||
--left-pane-background-color: #f2f2f2;
|
||||
|
||||
@@ -83,12 +83,6 @@
|
||||
--tab-note-icons: true;
|
||||
}
|
||||
|
||||
body.backdrop-effects-disabled {
|
||||
/* Backdrop effects are disabled, replace the menu background color with the
|
||||
* no-backdrop fallback color */
|
||||
--menu-background-color: var(--menu-background-color-no-backdrop);
|
||||
}
|
||||
|
||||
/*
|
||||
* MENUS
|
||||
*
|
||||
@@ -197,17 +191,13 @@ html body .dropdown-item[disabled] {
|
||||
|
||||
/* Menu item keyboard shortcut */
|
||||
.dropdown-item kbd {
|
||||
margin-left: 16px;
|
||||
font-family: unset !important;
|
||||
font-size: unset !important;
|
||||
color: var(--menu-item-keyboard-shortcut-color) !important;
|
||||
padding-top: 0;
|
||||
}
|
||||
|
||||
.dropdown-item span.keyboard-shortcut {
|
||||
color: var(--menu-item-keyboard-shortcut-color) !important;
|
||||
margin-left: 16px;
|
||||
}
|
||||
|
||||
.dropdown-divider {
|
||||
position: relative;
|
||||
border-color: transparent !important;
|
||||
@@ -540,9 +530,10 @@ body.mobile .dropdown-menu .dropdown-item.submenu-open .dropdown-toggle::after {
|
||||
}
|
||||
|
||||
/* List item */
|
||||
.jump-to-note-dialog .aa-suggestion,
|
||||
.note-detail-empty .aa-suggestion {
|
||||
.jump-to-note-dialog .aa-suggestions div,
|
||||
.note-detail-empty .aa-suggestions div {
|
||||
border-radius: 6px;
|
||||
padding: 6px 12px;
|
||||
color: var(--menu-text-color);
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
@@ -455,7 +455,6 @@ optgroup {
|
||||
left: 0;
|
||||
width: var(--box-size);
|
||||
height: 100%;
|
||||
margin: unset;
|
||||
opacity: 0 !important;
|
||||
}
|
||||
|
||||
|
||||
@@ -96,6 +96,7 @@
|
||||
background: var(--background) !important;
|
||||
color: var(--color) !important;
|
||||
line-height: unset;
|
||||
cursor: help;
|
||||
}
|
||||
|
||||
.sql-table-schemas-widget .sql-table-schemas button:hover,
|
||||
@@ -105,6 +106,18 @@
|
||||
--color: var(--main-text-color);
|
||||
}
|
||||
|
||||
/* Tooltip */
|
||||
|
||||
.tooltip .table-schema {
|
||||
font-family: var(--monospace-font-family);
|
||||
font-size: .85em;
|
||||
}
|
||||
|
||||
/* Data type */
|
||||
.tooltip .table-schema td:nth-child(2) {
|
||||
color: var(--muted-text-color);
|
||||
}
|
||||
|
||||
/*
|
||||
* NOTE MAP
|
||||
*/
|
||||
@@ -168,7 +181,9 @@ div.note-detail-empty {
|
||||
}
|
||||
|
||||
.options-section:not(.tn-no-card) {
|
||||
margin: auto;
|
||||
margin: auto;
|
||||
min-width: var(--options-card-min-width);
|
||||
max-width: var(--options-card-max-width);
|
||||
border-radius: 12px;
|
||||
border: 1px solid var(--card-border-color) !important;
|
||||
box-shadow: var(--card-box-shadow);
|
||||
@@ -177,11 +192,6 @@ div.note-detail-empty {
|
||||
margin-bottom: calc(var(--options-title-offset) + 26px) !important;
|
||||
}
|
||||
|
||||
body.desktop .option-section:not(.tn-no-card) {
|
||||
min-width: var(--options-card-min-width);
|
||||
max-width: var(--options-card-max-width);
|
||||
}
|
||||
|
||||
.note-detail-content-widget-content.options {
|
||||
--default-padding: 15px;
|
||||
padding-top: calc(var(--default-padding) + var(--options-title-offset) + var(--options-title-font-size));
|
||||
@@ -223,6 +233,11 @@ body.desktop .option-section:not(.tn-no-card) {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.options-section .options-mime-types {
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.options-section .form-group {
|
||||
margin-bottom: 1em;
|
||||
}
|
||||
|
||||
@@ -330,6 +330,7 @@ body.layout-horizontal > .horizontal {
|
||||
*/
|
||||
|
||||
.calendar-dropdown-widget {
|
||||
width: unset !important;
|
||||
padding: 12px;
|
||||
color: var(--calendar-color);
|
||||
user-select: none;
|
||||
@@ -586,10 +587,6 @@ div.quick-search .search-button.show {
|
||||
top: 1px;
|
||||
}
|
||||
|
||||
.quick-search .quick-search-item-icon {
|
||||
vertical-align: text-bottom;
|
||||
}
|
||||
|
||||
/* Note title */
|
||||
.quick-search .dropdown-menu .dropdown-item > a {
|
||||
color: var(--menu-text-color);
|
||||
@@ -608,25 +605,6 @@ div.quick-search .search-button.show {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* Note content snippet */
|
||||
:root .quick-search .search-result-content {
|
||||
background-color: var(--quick-search-result-content-background);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
/* Highlighted search terms */
|
||||
:root .quick-search .search-result-title b,
|
||||
:root .quick-search .search-result-content b,
|
||||
:root .quick-search .search-result-attributes b {
|
||||
color: var(--quick-search-result-highlight-color);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* Divider line */
|
||||
.quick-search .dropdown-item::after {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/*
|
||||
* TREE PANE
|
||||
*/
|
||||
@@ -1397,7 +1375,7 @@ div.floating-buttons-children .floating-button:active {
|
||||
}
|
||||
|
||||
/* The first visible floating button */
|
||||
div.floating-buttons-children > *:first-child {
|
||||
div.floating-buttons-children > *:nth-child(1 of .visible) {
|
||||
--border-radius: var(--border-radius-size) 0 0 var(--border-radius-size);
|
||||
border-radius: var(--border-radius);
|
||||
}
|
||||
@@ -1499,6 +1477,13 @@ div.floating-buttons-children .close-floating-buttons:has(.close-floating-button
|
||||
padding-inline-start: 8px;
|
||||
}
|
||||
|
||||
/* Copy image reference */
|
||||
|
||||
.floating-buttons .copy-image-reference-button .hidden-image-copy {
|
||||
/* Take out of the the hidden image from flexbox to prevent the layout being affected */
|
||||
position: absolute;
|
||||
}
|
||||
|
||||
/* Code, relation map buttons */
|
||||
|
||||
.floating-buttons .code-buttons-widget,
|
||||
|
||||
@@ -732,8 +732,7 @@
|
||||
"note_type": "笔记类型",
|
||||
"editable": "可编辑",
|
||||
"basic_properties": "基本属性",
|
||||
"language": "语言",
|
||||
"configure_code_notes": "配置代码注释..."
|
||||
"language": "语言"
|
||||
},
|
||||
"book_properties": {
|
||||
"view_type": "视图类型",
|
||||
@@ -849,7 +848,7 @@
|
||||
"debug": "调试",
|
||||
"debug_description": "调试将打印额外的调试信息到控制台,以帮助调试复杂查询",
|
||||
"action": "操作",
|
||||
"search_button": "搜索",
|
||||
"search_button": "搜索 <kbd>回车</kbd>",
|
||||
"search_execute": "搜索并执行操作",
|
||||
"save_to_note": "保存到笔记",
|
||||
"search_parameters": "搜索参数",
|
||||
@@ -968,7 +967,7 @@
|
||||
},
|
||||
"protected_session": {
|
||||
"enter_password_instruction": "显示受保护的笔记需要输入您的密码:",
|
||||
"start_session_button": "开始受保护的会话 <kbd>Enter</kbd>",
|
||||
"start_session_button": "开始受保护的会话",
|
||||
"started": "受保护的会话已启动。",
|
||||
"wrong_password": "密码错误。",
|
||||
"protecting-finished-successfully": "保护操作已成功完成。",
|
||||
@@ -1029,7 +1028,7 @@
|
||||
"error_creating_anonymized_database": "无法创建匿名化数据库,请检查后端日志以获取详细信息",
|
||||
"successfully_created_fully_anonymized_database": "成功创建完全匿名化的数据库,路径为 {{anonymizedFilePath}}",
|
||||
"successfully_created_lightly_anonymized_database": "成功创建轻度匿名化的数据库,路径为 {{anonymizedFilePath}}",
|
||||
"no_anonymized_database_yet": "尚无匿名化数据库。"
|
||||
"no_anonymized_database_yet": "尚无匿名化数据库"
|
||||
},
|
||||
"database_integrity_check": {
|
||||
"title": "数据库完整性检查",
|
||||
@@ -1166,7 +1165,7 @@
|
||||
},
|
||||
"revisions_snapshot_interval": {
|
||||
"note_revisions_snapshot_interval_title": "笔记修订快照间隔",
|
||||
"note_revisions_snapshot_description": "笔记修订快照间隔是创建新笔记修订的时间。有关更多信息,请参见 <doc>wiki</doc>。",
|
||||
"note_revisions_snapshot_description": "笔记修订快照间隔是创建新笔记修订的时间。有关更多信息,请参见 <a href=\"https://triliumnext.github.io/Docs/Wiki/note-revisions.html\" class=\"external\">wiki</a>。",
|
||||
"snapshot_time_interval_label": "笔记修订快照时间间隔:"
|
||||
},
|
||||
"revisions_snapshot_limit": {
|
||||
@@ -1334,9 +1333,9 @@
|
||||
"oauth_title": "OAuth/OpenID 认证",
|
||||
"oauth_description": "OpenID 是一种标准化方式,允许您使用其他服务(如 Google)的账号登录网站来验证您的身份。默认的身份提供者是 Google,但您可以更改为任何其他 OpenID 提供者。点击<a href=\"#root/_hidden/_help/_help_Otzi9La2YAUX/_help_WOcw2SLH6tbX/_help_7DAiwaf8Z7Rz\">这里</a>了解更多信息。请参阅这些 <a href=\"https://developers.google.com/identity/openid-connect/openid-connect\">指南</a> 通过 Google 设置 OpenID 服务。",
|
||||
"oauth_description_warning": "要启用 OAuth/OpenID,您需要设置 config.ini 文件中的 OAuth/OpenID 基础 URL、客户端 ID 和客户端密钥,并重新启动应用程序。如果要从环境变量设置,请设置 TRILIUM_OAUTH_BASE_URL、TRILIUM_OAUTH_CLIENT_ID 和 TRILIUM_OAUTH_CLIENT_SECRET 环境变量。",
|
||||
"oauth_missing_vars": "缺少以下设置项:{{variables}}",
|
||||
"oauth_user_account": "用户账号: ",
|
||||
"oauth_user_email": "用户邮箱: ",
|
||||
"oauth_missing_vars": "缺少以下设置项: {{missingVars}}",
|
||||
"oauth_user_account": "用户账号:",
|
||||
"oauth_user_email": "用户邮箱:",
|
||||
"oauth_user_not_logged_in": "未登录!"
|
||||
},
|
||||
"shortcuts": {
|
||||
@@ -1358,7 +1357,7 @@
|
||||
"enable": "启用拼写检查",
|
||||
"language_code_label": "语言代码",
|
||||
"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": "可用的语言代码:",
|
||||
"restart-required": "拼写检查选项的更改将在应用重启后生效。"
|
||||
},
|
||||
@@ -1440,9 +1439,9 @@
|
||||
"open-in-popup": "快速编辑"
|
||||
},
|
||||
"shared_info": {
|
||||
"help_link": "访问 <a href=\"https://triliumnext.github.io/Docs/Wiki/sharing.html\">wiki</a> 获取帮助。",
|
||||
"shared_publicly": "该笔记已在 {{- link}} 上公开分享。",
|
||||
"shared_locally": "此笔记在本地通过 {{- link}} 进行共享。"
|
||||
"shared_publicly": "此笔记已公开分享于",
|
||||
"shared_locally": "此笔记已在本地分享于",
|
||||
"help_link": "访问 <a href=\"https://triliumnext.github.io/Docs/Wiki/sharing.html\">wiki</a> 获取帮助。"
|
||||
},
|
||||
"note_types": {
|
||||
"text": "文本",
|
||||
@@ -1520,8 +1519,7 @@
|
||||
"hoist-this-note-workspace": "聚焦此笔记(工作区)",
|
||||
"refresh-saved-search-results": "刷新保存的搜索结果",
|
||||
"create-child-note": "创建子笔记",
|
||||
"unhoist": "取消聚焦",
|
||||
"toggle-sidebar": "切换侧边栏"
|
||||
"unhoist": "取消聚焦"
|
||||
},
|
||||
"title_bar_buttons": {
|
||||
"window-on-top": "保持此窗口置顶"
|
||||
@@ -1873,19 +1871,14 @@
|
||||
"selected_provider": "已选提供商",
|
||||
"selected_provider_description": "选择用于聊天和补全功能的AI提供商",
|
||||
"select_model": "选择模型...",
|
||||
"select_provider": "选择提供商...",
|
||||
"ai_enabled": "已启用 AI 功能",
|
||||
"ai_disabled": "已禁用 AI 功能",
|
||||
"no_models_found_online": "找不到模型。请检查您的 API 密钥及设置。",
|
||||
"no_models_found_ollama": "找不到 Ollama 模型。请确认 Ollama 是否正在运行。",
|
||||
"error_fetching": "获取模型失败:{{error}}"
|
||||
"select_provider": "选择提供商..."
|
||||
},
|
||||
"code-editor-options": {
|
||||
"title": "编辑器"
|
||||
},
|
||||
"custom_date_time_format": {
|
||||
"title": "自定义日期/时间格式",
|
||||
"description": "通过<shortcut />或工具栏的方式可自定义日期和时间格式,有关日期/时间格式字符串中各个字符的含义,请参阅<doc>Day.js docs</doc>。",
|
||||
"description": "通过<kbd></kbd>或工具栏的方式可自定义日期和时间格式,有关日期/时间格式字符串中各个字符的含义,请参阅<a href=\"https://day.js.org/docs/en/display/format\" target=\"_blank\" rel=\"noopener noreferrer\">Day.js docs</a>。",
|
||||
"format_string": "日期/时间格式字符串:",
|
||||
"formatted_time": "格式化后日期/时间:"
|
||||
},
|
||||
@@ -1999,28 +1992,11 @@
|
||||
"help_title": "显示关于此画面的更多信息"
|
||||
},
|
||||
"call_to_action": {
|
||||
"next_theme_title": "新的 Trilium 主题已进入稳定版",
|
||||
"next_theme_message": "有一段时间,我们一直在设计新的主题,为了让应用程序看起来更加现代。",
|
||||
"next_theme_button": "切换至新的 Trilium 主题",
|
||||
"background_effects_title": "背景效果现已推出稳定版本",
|
||||
"background_effects_message": "在 Windows 装置上,背景效果现在已完全稳定。背景效果通过模糊背后的背景,为使用者界面增添一抹色彩。此技术也用于其他应用程序,例如 Windows 资源管理器。",
|
||||
"background_effects_button": "启用背景效果",
|
||||
"next_theme_title": "试用新 Trilium 主题",
|
||||
"next_theme_message": "当前使用旧版主题,要试用新主题吗?",
|
||||
"next_theme_button": "试用新主题",
|
||||
"dismiss": "关闭"
|
||||
},
|
||||
"settings": {
|
||||
"related_settings": "相关设置"
|
||||
},
|
||||
"settings_appearance": {
|
||||
"related_code_blocks": "文本笔记中代码块的色彩方案",
|
||||
"related_code_notes": "代码笔记的色彩方案"
|
||||
},
|
||||
"units": {
|
||||
"percentage": "%"
|
||||
},
|
||||
"ui-performance": {
|
||||
"title": "性能",
|
||||
"enable-motion": "启用过渡和动画",
|
||||
"enable-shadows": "启用阴影",
|
||||
"enable-backdrop-effects": "启用菜单、弹窗和面板的背景效果"
|
||||
"background_effects_button": "启用背景效果"
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -732,8 +732,7 @@
|
||||
"note_type": "Note type",
|
||||
"editable": "Editable",
|
||||
"basic_properties": "Basic Properties",
|
||||
"language": "Language",
|
||||
"configure_code_notes": "Configure code notes..."
|
||||
"language": "Language"
|
||||
},
|
||||
"book_properties": {
|
||||
"view_type": "View type",
|
||||
@@ -849,7 +848,7 @@
|
||||
"debug": "debug",
|
||||
"debug_description": "Debug will print extra debugging information into the console to aid in debugging complex queries",
|
||||
"action": "action",
|
||||
"search_button": "Search",
|
||||
"search_button": "Search <kbd>enter</kbd>",
|
||||
"search_execute": "Search & Execute actions",
|
||||
"save_to_note": "Save to note",
|
||||
"search_parameters": "Search Parameters",
|
||||
@@ -1114,12 +1113,6 @@
|
||||
"layout-vertical-description": "launcher bar is on the left (default)",
|
||||
"layout-horizontal-description": "launcher bar is underneath the tab bar, the tab bar is now full width."
|
||||
},
|
||||
"ui-performance": {
|
||||
"title": "Performance",
|
||||
"enable-motion": "Enable transitions and animations",
|
||||
"enable-shadows": "Enable shadows",
|
||||
"enable-backdrop-effects": "Enable background effects for menus, popups and panels"
|
||||
},
|
||||
"ai_llm": {
|
||||
"not_started": "Not started",
|
||||
"title": "AI Settings",
|
||||
@@ -1260,12 +1253,7 @@
|
||||
"selected_provider": "Selected Provider",
|
||||
"selected_provider_description": "Choose the AI provider for chat and completion features",
|
||||
"select_model": "Select model...",
|
||||
"select_provider": "Select provider...",
|
||||
"ai_enabled": "AI features enabled",
|
||||
"ai_disabled": "AI features disabled",
|
||||
"no_models_found_online": "No models found. Please check your API key and settings.",
|
||||
"no_models_found_ollama": "No Ollama models found. Please check if Ollama is running.",
|
||||
"error_fetching": "Error fetching models: {{error}}"
|
||||
"select_provider": "Select provider..."
|
||||
},
|
||||
"zoom_factor": {
|
||||
"title": "Zoom Factor (desktop build only)",
|
||||
@@ -1322,7 +1310,7 @@
|
||||
},
|
||||
"revisions_snapshot_interval": {
|
||||
"note_revisions_snapshot_interval_title": "Note Revision Snapshot Interval",
|
||||
"note_revisions_snapshot_description": "The Note revision snapshot interval is the time after which a new note revision will be created for the note. See <doc>wiki</doc> for more info.",
|
||||
"note_revisions_snapshot_description": "The Note revision snapshot interval is the time after which a new note revision will be created for the note. See <a href=\"https://triliumnext.github.io/Docs/Wiki/note-revisions.html\" class=\"external\">wiki</a> for more info.",
|
||||
"snapshot_time_interval_label": "Note revision snapshot time interval:"
|
||||
},
|
||||
"revisions_snapshot_limit": {
|
||||
@@ -1384,7 +1372,7 @@
|
||||
},
|
||||
"custom_date_time_format": {
|
||||
"title": "Custom Date/Time Format",
|
||||
"description": "Customize the format of the date and time inserted via <shortcut /> or the toolbar. See <doc>Day.js docs</doc> for available format tokens.",
|
||||
"description": "Customize the format of the date and time inserted via <kbd></kbd> or the toolbar. See <a href=\"https://day.js.org/docs/en/display/format\" target=\"_blank\" rel=\"noopener noreferrer\">Day.js docs</a> for available format tokens.",
|
||||
"format_string": "Format string:",
|
||||
"formatted_time": "Formatted date/time:"
|
||||
},
|
||||
@@ -1602,8 +1590,8 @@
|
||||
"open-in-popup": "Quick edit"
|
||||
},
|
||||
"shared_info": {
|
||||
"shared_publicly": "This note is shared publicly on {{- link}}.",
|
||||
"shared_locally": "This note is shared locally on {{- link}}.",
|
||||
"shared_publicly": "This note is shared publicly on",
|
||||
"shared_locally": "This note is shared locally on",
|
||||
"help_link": "For help visit <a href=\"https://triliumnext.github.io/Docs/Wiki/sharing.html\">wiki</a>."
|
||||
},
|
||||
"note_types": {
|
||||
@@ -1682,8 +1670,7 @@
|
||||
"hoist-this-note-workspace": "Hoist this note (workspace)",
|
||||
"refresh-saved-search-results": "Refresh saved search results",
|
||||
"create-child-note": "Create child note",
|
||||
"unhoist": "Unhoist",
|
||||
"toggle-sidebar": "Toggle sidebar"
|
||||
"unhoist": "Unhoist"
|
||||
},
|
||||
"title_bar_buttons": {
|
||||
"window-on-top": "Keep Window on Top"
|
||||
@@ -2007,22 +1994,11 @@
|
||||
"help_title": "Display more information about this screen"
|
||||
},
|
||||
"call_to_action": {
|
||||
"next_theme_title": "Try the new Trilium theme",
|
||||
"next_theme_message": "You are currently using the legacy theme, would you like to try the new theme?",
|
||||
"next_theme_button": "Try the new theme",
|
||||
"next_theme_title": "The new Trilium theme is now stable",
|
||||
"next_theme_message": "For a while now, we've been working on a new theme to give the application a more modern look.",
|
||||
"next_theme_button": "Switch to the new Trilium theme",
|
||||
"background_effects_title": "Background effects are now stable",
|
||||
"background_effects_message": "On Windows devices, background effects are now fully stable. The background effects adds a touch of color to the user interface by blurring the background behind it. This technique is also used in other applications such as Windows Explorer.",
|
||||
"background_effects_button": "Enable background effects",
|
||||
"dismiss": "Dismiss"
|
||||
},
|
||||
"settings": {
|
||||
"related_settings": "Related settings"
|
||||
},
|
||||
"settings_appearance": {
|
||||
"related_code_blocks": "Color scheme for code blocks in text notes",
|
||||
"related_code_notes": "Color scheme for code notes"
|
||||
},
|
||||
"units": {
|
||||
"percentage": "%"
|
||||
"background_effects_button": "Enable background effects"
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,22 +0,0 @@
|
||||
{
|
||||
"about": {
|
||||
"title": "درباره Trilium Notes",
|
||||
"homepage": "صفحه اصلی:",
|
||||
"app_version": "نسخه برنامه:",
|
||||
"db_version": "نسخه پایگاه داده:",
|
||||
"sync_version": "نسخه منطبق:",
|
||||
"build_date": "تاریخ ساخت:",
|
||||
"build_revision": "نسخه بازنگری شده:",
|
||||
"data_directory": "دایرکتوری داده:"
|
||||
},
|
||||
"toast": {
|
||||
"critical-error": {
|
||||
"title": "خطای بحرانی",
|
||||
"message": "خطای بحرانی رخ داده که مانع از اجرای برنامه می شود\n\n {{message}}\n\nبه احتمال زیاد ناشی از خطای غیرمنتظره در اجرای ناموفق یک اسکریپت است. برنامه را در مد ایمن اجرا کنید و خطا را بررسی نمایید."
|
||||
}
|
||||
},
|
||||
"add_link": {
|
||||
"add_link": "افزودن لینک",
|
||||
"note": "یادداشت"
|
||||
}
|
||||
}
|
||||
@@ -1,147 +0,0 @@
|
||||
{
|
||||
"about": {
|
||||
"title": "Lisätietoja Trilium Notes:ista",
|
||||
"homepage": "Kotisivu:",
|
||||
"app_version": "Sovelluksen versio:",
|
||||
"db_version": "Tietokannan versio:",
|
||||
"build_date": "Koontipäivämäärä:",
|
||||
"data_directory": "Datakansio:",
|
||||
"sync_version": "Synkronoinnin versio:",
|
||||
"build_revision": "Sovelluksen versio:"
|
||||
},
|
||||
"toast": {
|
||||
"critical-error": {
|
||||
"title": "Kriittinen virhe"
|
||||
},
|
||||
"widget-error": {
|
||||
"title": "Widgetin luonti epäonnistui"
|
||||
}
|
||||
},
|
||||
"add_link": {
|
||||
"add_link": "Lisää linkki",
|
||||
"link_title": "Linkin otsikko",
|
||||
"button_add_link": "Lisää linkki",
|
||||
"note": "Muistio",
|
||||
"search_note": "etsi muistiota sen nimellä"
|
||||
},
|
||||
"branch_prefix": {
|
||||
"prefix": "Etuliite: ",
|
||||
"save": "Tallenna"
|
||||
},
|
||||
"bulk_actions": {
|
||||
"bulk_actions": "Massatoiminnot",
|
||||
"available_actions": "Saatavilla olevat toiminnot",
|
||||
"chosen_actions": "Valitut toiminnot",
|
||||
"execute_bulk_actions": "Toteuta massatoiminnot",
|
||||
"bulk_actions_executed": "Massatoiminnot on toteutettu onnistuneesti.",
|
||||
"none_yet": "Ei vielä... lisää toiminto klikkaamalla jotiain yllä saatavilla olevaa yltä.",
|
||||
"labels": "Merkit",
|
||||
"relations": "Suhteet",
|
||||
"notes": "Muistiot",
|
||||
"other": "Muut",
|
||||
"affected_notes": "Vaikuttaa muistioihin"
|
||||
},
|
||||
"clone_to": {
|
||||
"clone_notes_to": "Kopioi muistiot...",
|
||||
"help_on_links": "Apua linkkeihin",
|
||||
"notes_to_clone": "Kopioitavat muistiot",
|
||||
"target_parent_note": "Kohteen päämuistio",
|
||||
"search_for_note_by_its_name": "ensi muistiota sen nimellä",
|
||||
"cloned_note_prefix_title": "Kopioitu muistia näytetään puussa annetulla etuliitteellä",
|
||||
"prefix_optional": "Etuliite (valinnainen)",
|
||||
"clone_to_selected_note": "Kopioi valittuun muistioon",
|
||||
"note_cloned": "Muistio \"{{clonedTitle}}\" on kopioitu \"{{targetTitle}}\""
|
||||
},
|
||||
"confirm": {
|
||||
"confirmation": "Vahvistus",
|
||||
"cancel": "Peruuta",
|
||||
"ok": "OK",
|
||||
"also_delete_note": "Poista myös muistio"
|
||||
},
|
||||
"delete_notes": {
|
||||
"delete_notes_preview": "Poista muistion esikatselu",
|
||||
"close": "Sulje",
|
||||
"notes_to_be_deleted": "Seuraavat muistiot tullaan poistamaan ({{notesCount}})",
|
||||
"no_note_to_delete": "Muistioita ei poisteta (vain kopiot).",
|
||||
"cancel": "Peruuta",
|
||||
"ok": "OK"
|
||||
},
|
||||
"export": {
|
||||
"export_note_title": "Vie muistio",
|
||||
"close": "Sulje",
|
||||
"format_html": "HTML - suositeltu, sillä se säilyttää kaikki formatoinnit",
|
||||
"format_markdown": "Markdown - tämä säilyttää suurimman osan formatoinneista.",
|
||||
"opml_version_1": "OPML v1.0 - pelkkä teksti",
|
||||
"opml_version_2": "OPML v2.0 - sallii myös HTML:n",
|
||||
"export": "Vie",
|
||||
"choose_export_type": "Valitse ensin viennin tyyppi",
|
||||
"export_status": "Viennin tila",
|
||||
"export_in_progress": "Vienti käynnissä: {{progressCount}}",
|
||||
"export_finished_successfully": "Vienti valmistui onnistuneesti.",
|
||||
"format_pdf": "PDF - tulostukseen ja jakamiseen."
|
||||
},
|
||||
"help": {
|
||||
"title": "Lunttilappu",
|
||||
"noteNavigation": "Muistion navigointi",
|
||||
"goUpDown": "mene ylös/alas muistioiden listassa",
|
||||
"collapseExpand": "pienennä/suurenna solmu",
|
||||
"notSet": "ei asetettu",
|
||||
"goBackForwards": "mene taaksepäin/eteenpäin historiassa",
|
||||
"jumpToParentNote": "Hyppää ylempään muistioon",
|
||||
"collapseWholeTree": "pienennä koko muistio puu",
|
||||
"onlyInDesktop": "Vain työpöytänäkymässä (Electron build)",
|
||||
"openEmptyTab": "Avaa tyhjä välilehti",
|
||||
"closeActiveTab": "sulje aktiivinen välilehti",
|
||||
"activateNextTab": "aktivoi seuraava välilehti",
|
||||
"activatePreviousTab": "aktivoi edellinen välilehti",
|
||||
"creatingNotes": "Luo muistiota",
|
||||
"movingCloningNotes": "Siirrä / kopioi muistioita",
|
||||
"moveNoteUpHierarchy": "siirrä muistio ylöspäin listassa",
|
||||
"selectNote": "valitse muistio",
|
||||
"editingNotes": "Muokkaa solmua",
|
||||
"createEditLink": "luo / muokkaa ulkoista linkkiä",
|
||||
"createInternalLink": "luo sisäinen linkki",
|
||||
"insertDateTime": "lisää nykyinen päivämäärä ja aika hiiren kohdalle",
|
||||
"troubleshooting": "Vianmääritys",
|
||||
"reloadFrontend": "lataa Trilium:in käyttöliittymä",
|
||||
"showDevTools": "näytä kehittäjätyökalut",
|
||||
"showSQLConsole": "näytä SQL konsoli",
|
||||
"other": "Muut"
|
||||
},
|
||||
"import": {
|
||||
"importIntoNote": "Tuo muistioon",
|
||||
"chooseImportFile": "Valitse tuonnin tiedosto",
|
||||
"options": "Valinnat",
|
||||
"safeImport": "Turvallinen tuonti",
|
||||
"shrinkImages": "Kutista kuvat",
|
||||
"replaceUnderscoresWithSpaces": "Korvaa alaviivat väleillä tuotujen muistioiden tiedostonimissä",
|
||||
"import": "Tuo",
|
||||
"failed": "Tuonti epäonnistui: {{message}}.",
|
||||
"html_import_tags": {
|
||||
"title": "HTML Tuonnin Tunnisteet",
|
||||
"placeholder": "Lisää HTML tunnisteet, yksi per rivi"
|
||||
},
|
||||
"import-status": "Tuonnin tila",
|
||||
"in-progress": "Tuonti vaiheessa: {{progress}}",
|
||||
"successful": "Tuonti valmistui onnistuneesti."
|
||||
},
|
||||
"include_note": {
|
||||
"dialog_title": "Sisällytä muistio",
|
||||
"label_note": "Muistio",
|
||||
"placeholder_search": "etsi muistiota sen nimellä",
|
||||
"box_size_small": "pieni (~ 10 riviä)",
|
||||
"box_size_medium": "keskisuuri (~ 30 riviä)",
|
||||
"button_include": "Sisällytä muistio"
|
||||
},
|
||||
"info": {
|
||||
"modalTitle": "Info viesti",
|
||||
"closeButton": "Sulje",
|
||||
"okButton": "OK"
|
||||
},
|
||||
"jump_to_note": {
|
||||
"search_button": "Etsi koko tekstistä"
|
||||
},
|
||||
"call_to_action": {
|
||||
"dismiss": "Hylkää"
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1 +0,0 @@
|
||||
{}
|
||||
@@ -1,353 +1,345 @@
|
||||
{
|
||||
"about": {
|
||||
"app_version": "Versione dell'app:",
|
||||
"db_version": "Versione DB:",
|
||||
"sync_version": "Versione Sync:",
|
||||
"data_directory": "Cartella dati:",
|
||||
"title": "Informazioni su Trilium Notes",
|
||||
"build_date": "Data della build:",
|
||||
"build_revision": "Revisione della build:",
|
||||
"homepage": "Homepage:"
|
||||
},
|
||||
"toast": {
|
||||
"critical-error": {
|
||||
"title": "Errore critico",
|
||||
"message": "Si è verificato un errore critico che impedisce l'avvio dell'applicazione client:\n\n{{message}}\n\nQuesto è probabilmente causato da un errore di script inaspettato. Prova a avviare l'applicazione in modo sicuro e controlla il problema."
|
||||
"about": {
|
||||
"app_version": "Versione dell'app:",
|
||||
"db_version": "Versione DB:",
|
||||
"sync_version": "Versione Sync:",
|
||||
"data_directory": "Cartella dati:",
|
||||
"title": "Informazioni su Trilium Notes",
|
||||
"build_date": "Data della build:",
|
||||
"build_revision": "Revisione della build:",
|
||||
"homepage": "Homepage:"
|
||||
},
|
||||
"bundle-error": {
|
||||
"title": "Non si è riusciti a caricare uno script personalizzato",
|
||||
"message": "Lo script della nota con ID \"{{id}}\", dal titolo \"{{title}}\" non è stato inizializzato a causa di:\n\n{{message}}"
|
||||
"toast": {
|
||||
"critical-error": {
|
||||
"title": "Errore critico",
|
||||
"message": "Si è verificato un errore critico che impedisce l'avvio dell'applicazione client:\n\n{{message}}\n\nQuesto è probabilmente causato da un errore di script inaspettato. Prova a avviare l'applicazione in modo sicuro e controlla il problema."
|
||||
},
|
||||
"bundle-error": {
|
||||
"title": "Non si è riusciti a caricare uno script personalizzato",
|
||||
"message": "Lo script della nota con ID \"{{id}}\", dal titolo \"{{title}}\" non è stato inizializzato a causa di:\n\n{{message}}"
|
||||
},
|
||||
"widget-error": {
|
||||
"title": "Impossibile inizializzare un widget",
|
||||
"message-custom": "Il widget personalizzato della nota con ID \"{{id}}\", dal titolo \"{{title}}\" non è stato inizializzato a causa di:\n\n{{message}}",
|
||||
"message-unknown": "Un widget sconosciuto non è stato inizializzato a causa di:\n\n{{message}}"
|
||||
}
|
||||
},
|
||||
"widget-error": {
|
||||
"title": "Impossibile inizializzare un widget",
|
||||
"message-custom": "Il widget personalizzato della nota con ID \"{{id}}\", dal titolo \"{{title}}\" non è stato inizializzato a causa di:\n\n{{message}}",
|
||||
"message-unknown": "Un widget sconosciuto non è stato inizializzato a causa di:\n\n{{message}}"
|
||||
"add_link": {
|
||||
"add_link": "Aggiungi un collegamento",
|
||||
"note": "Nota",
|
||||
"search_note": "cerca una nota per nome",
|
||||
"link_title_mirrors": "il titolo del collegamento rispecchia il titolo della nota corrente",
|
||||
"link_title_arbitrary": "il titolo del collegamento può essere modificato arbitrariamente",
|
||||
"link_title": "Titolo del collegamento",
|
||||
"button_add_link": "Aggiungi il collegamento <kbd>invio</kbd>",
|
||||
"help_on_links": "Aiuto sui collegamenti"
|
||||
},
|
||||
"branch_prefix": {
|
||||
"edit_branch_prefix": "Modifica il prefisso del ramo",
|
||||
"help_on_tree_prefix": "Aiuto sui prefissi dell'Albero",
|
||||
"prefix": "Prefisso: ",
|
||||
"save": "Salva",
|
||||
"branch_prefix_saved": "Il prefisso del ramo è stato salvato."
|
||||
},
|
||||
"bulk_actions": {
|
||||
"bulk_actions": "Azioni massive",
|
||||
"affected_notes": "Note influenzate",
|
||||
"include_descendants": "Includi i discendenti della nota selezionata",
|
||||
"available_actions": "Azioni disponibili",
|
||||
"chosen_actions": "Azioni scelte",
|
||||
"execute_bulk_actions": "Esegui le azioni massive",
|
||||
"bulk_actions_executed": "Le azioni massive sono state eseguite con successo.",
|
||||
"none_yet": "Ancora nessuna... aggiungi una azione cliccando su una di quelle disponibili sopra.",
|
||||
"labels": "Etichette",
|
||||
"relations": "Relazioni",
|
||||
"notes": "Note",
|
||||
"other": "Altro"
|
||||
},
|
||||
"clone_to": {
|
||||
"clone_notes_to": "Clona note in...",
|
||||
"help_on_links": "Aiuto sui collegamenti",
|
||||
"notes_to_clone": "Note da clonare",
|
||||
"target_parent_note": "Nodo padre obiettivo",
|
||||
"search_for_note_by_its_name": "cerca una nota per nome",
|
||||
"cloned_note_prefix_title": "Le note clonate saranno mostrate nell'albero delle note con il dato prefisso",
|
||||
"prefix_optional": "Prefisso (opzionale)",
|
||||
"clone_to_selected_note": "Clona sotto la nota selezionata <kbd>invio</kbd>",
|
||||
"no_path_to_clone_to": "Nessun percorso per clonare dentro.",
|
||||
"note_cloned": "La nota \"{{clonedTitle}}\" è stata clonata in \"{{targetTitle}}\""
|
||||
},
|
||||
"confirm": {
|
||||
"cancel": "Annulla",
|
||||
"ok": "OK",
|
||||
"confirmation": "Conferma",
|
||||
"are_you_sure_remove_note": "Sei sicuro di voler rimuovere la nota \"{{title}}\" dalla mappa delle relazioni? ",
|
||||
"if_you_dont_check": "Se non lo selezioni, la nota sarà rimossa solamente dalla mappa delle relazioni.",
|
||||
"also_delete_note": "Rimuove anche la nota"
|
||||
},
|
||||
"delete_notes": {
|
||||
"ok": "OK",
|
||||
"close": "Chiudi",
|
||||
"delete_notes_preview": "Anteprima di eliminazione delle note",
|
||||
"delete_all_clones_description": "Elimina anche tutti i cloni (può essere disfatto tramite i cambiamenti recenti)",
|
||||
"erase_notes_description": "L'eliminazione normale (soft) marca le note come eliminate e potranno essere recuperate entro un certo lasso di tempo (dalla finestra dei cambiamenti recenti). Selezionando questa opzione le note si elimineranno immediatamente e non sarà possibile recuperarle.",
|
||||
"erase_notes_warning": "Elimina le note in modo permanente (non potrà essere disfatto), compresi tutti i cloni. Ciò forzerà un nuovo caricamento dell'applicazione.",
|
||||
"cancel": "Annulla",
|
||||
"notes_to_be_deleted": "Le seguenti note saranno eliminate ({{- noteCount}})",
|
||||
"no_note_to_delete": "Nessuna nota sarà eliminata (solo i cloni).",
|
||||
"broken_relations_to_be_deleted": "Le seguenti relazioni saranno interrotte ed eliminate ({{- relationCount}})",
|
||||
"deleted_relation_text": "La nota {{- note}} (da eliminare) è referenziata dalla relazione {{- relation}} originata da {{- source}}."
|
||||
},
|
||||
"info": {
|
||||
"okButton": "OK",
|
||||
"closeButton": "Chiudi"
|
||||
},
|
||||
"export": {
|
||||
"close": "Chiudi",
|
||||
"export_note_title": "Esporta la nota",
|
||||
"export_status": "Stato dell'esportazione",
|
||||
"export": "Esporta",
|
||||
"choose_export_type": "Scegli prima il tipo di esportazione, per favore",
|
||||
"export_in_progress": "Esportazione in corso: {{progressCount}}",
|
||||
"export_finished_successfully": "Esportazione terminata con successo.",
|
||||
"format_pdf": "PDF- allo scopo di stampa o esportazione.",
|
||||
"export_type_subtree": "Questa nota e tutti i suoi discendenti",
|
||||
"format_html": "HTML - raccomandato in quanto mantiene tutti i formati",
|
||||
"format_html_zip": "HTML in archivio ZIP - questo è raccomandato in quanto conserva tutta la formattazione.",
|
||||
"format_markdown": "MArkdown - questo conserva la maggior parte della formattazione."
|
||||
},
|
||||
"password_not_set": {
|
||||
"body1": "Le note protette sono crittografate utilizzando una password utente, ma la password non è stata ancora impostata.",
|
||||
"body2": "Per proteggere le note, fare clic su <a class=\"open-password-options-button\" href=\"javascript:\">qui</a> per aprire la finestra di dialogo Opzioni e impostare la password."
|
||||
},
|
||||
"protected_session_password": {
|
||||
"close_label": "Chiudi"
|
||||
},
|
||||
"abstract_bulk_action": {
|
||||
"remove_this_search_action": "Rimuovi questa azione di ricerca"
|
||||
},
|
||||
"etapi": {
|
||||
"new_token_title": "Nuovo token ETAPI",
|
||||
"new_token_message": "Inserire il nuovo nome del token"
|
||||
},
|
||||
"electron_integration": {
|
||||
"zoom-factor": "Fattore di ingrandimento",
|
||||
"desktop-application": "Applicazione Desktop"
|
||||
},
|
||||
"note_autocomplete": {
|
||||
"search-for": "Cerca \"{{term}}\"",
|
||||
"create-note": "Crea e collega la nota figlia \"{{term}}\"",
|
||||
"insert-external-link": "Inserisci il collegamento esterno a \"{{term}}\"",
|
||||
"clear-text-field": "Pulisci il campo di testo",
|
||||
"show-recent-notes": "Mostra le note recenti",
|
||||
"full-text-search": "Ricerca full text"
|
||||
},
|
||||
"note_tooltip": {
|
||||
"note-has-been-deleted": "La nota è stata eliminata.",
|
||||
"quick-edit": "Modifica veloce"
|
||||
},
|
||||
"geo-map": {
|
||||
"create-child-note-title": "Crea una nota figlia e aggiungila alla mappa",
|
||||
"create-child-note-instruction": "Clicca sulla mappa per creare una nuova nota qui o premi Escape per uscire.",
|
||||
"unable-to-load-map": "Impossibile caricare la mappa."
|
||||
},
|
||||
"geo-map-context": {
|
||||
"open-location": "Apri la posizione",
|
||||
"remove-from-map": "Rimuovi dalla mappa",
|
||||
"add-note": "Aggiungi un marcatore in questa posizione"
|
||||
},
|
||||
"debug": {
|
||||
"debug": "Debug"
|
||||
},
|
||||
"database_anonymization": {
|
||||
"light_anonymization": "Anonimizzazione parziale",
|
||||
"title": "Anonimizzazione del Database",
|
||||
"full_anonymization": "Anonimizzazione completa",
|
||||
"full_anonymization_description": "Questa azione creerà una nuova copia del database e lo anonimizzerà (rimuove tutti i contenuti delle note, lasciando solo la struttura e qualche metadato non sensibile) per condividerlo online allo scopo di debugging, senza paura di far trapelare i tuoi dati personali.",
|
||||
"save_fully_anonymized_database": "Salva il database completamente anonimizzato",
|
||||
"light_anonymization_description": "Questa azione creerà una nuova copia del database e lo anonimizzerà in parzialmente — in particolare, solo il contenuto delle note sarà rimosso, ma i titoli e gli attributi rimarranno. Inoltre, note con script personalizzati JS di frontend/backend e widget personalizzati lasciando rimarranno. Ciò mette a disposizione più contesto per il debug dei problemi.",
|
||||
"choose_anonymization": "Puoi decidere da solo se fornire un database completamente o parzialmente anonimizzato. Anche un database completamente anonimizzato è molto utile, sebbene in alcuni casi i database parzialmente anonimizzati possono accelerare il processo di identificazione dei bug e la loro correzione.",
|
||||
"no_anonymized_database_yet": "Nessun database ancora anonimizzato.",
|
||||
"save_lightly_anonymized_database": "Salva il database parzialmente anonimizzato",
|
||||
"successfully_created_fully_anonymized_database": "Database completamente anonimizzato creato in {{anonymizedFilePath}}",
|
||||
"successfully_created_lightly_anonymized_database": "Database parzialmente anonimizzato creato in {{anonymizedFilePath}}"
|
||||
},
|
||||
"cpu_arch_warning": {
|
||||
"title": "Per favore scarica la versione ARM64",
|
||||
"continue_anyway": "Continua Comunque",
|
||||
"dont_show_again": "Non mostrare più questo avviso",
|
||||
"download_link": "Scarica la Versione Nativa"
|
||||
},
|
||||
"editorfeatures": {
|
||||
"title": "Caratteristiche",
|
||||
"emoji_completion_enabled": "Abilita il completamento automatico delle Emoji",
|
||||
"note_completion_enabled": "Abilita il completamento automatico delle note"
|
||||
},
|
||||
"table_view": {
|
||||
"new-row": "Nuova riga",
|
||||
"new-column": "Nuova colonna",
|
||||
"sort-column-by": "Ordina per \"{{title}}\"",
|
||||
"sort-column-ascending": "Ascendente",
|
||||
"sort-column-descending": "Discendente",
|
||||
"sort-column-clear": "Cancella l'ordinamento",
|
||||
"hide-column": "Nascondi la colonna \"{{title}}\"",
|
||||
"show-hide-columns": "Mostra/nascondi le colonne",
|
||||
"row-insert-above": "Inserisci una riga sopra",
|
||||
"row-insert-below": "Inserisci una riga sotto"
|
||||
},
|
||||
"abstract_search_option": {
|
||||
"remove_this_search_option": "Rimuovi questa opzione di ricerca",
|
||||
"failed_rendering": "Opzione di ricerca di rendering non riuscita: {{dto}} con errore: {{error}} {{stack}}"
|
||||
},
|
||||
"ancestor": {
|
||||
"label": "Antenato"
|
||||
},
|
||||
"add_label": {
|
||||
"add_label": "Aggiungi etichetta",
|
||||
"label_name_placeholder": "nome dell'etichetta",
|
||||
"new_value_placeholder": "nuovo valore",
|
||||
"to_value": "al valore"
|
||||
},
|
||||
"update_label_value": {
|
||||
"to_value": "al valore",
|
||||
"label_name_placeholder": "nome dell'etichetta"
|
||||
},
|
||||
"delete_label": {
|
||||
"delete_label": "Elimina etichetta",
|
||||
"label_name_placeholder": "nome dell'etichetta",
|
||||
"label_name_title": "Sono ammessi i caratteri alfanumerici, il carattere di sottolineato e i due punti."
|
||||
},
|
||||
"tree-context-menu": {
|
||||
"move-to": "Muovi in...",
|
||||
"cut": "Taglia"
|
||||
},
|
||||
"electron_context_menu": {
|
||||
"cut": "Taglia",
|
||||
"copy": "Copia",
|
||||
"paste": "Incolla",
|
||||
"copy-link": "Copia collegamento",
|
||||
"paste-as-plain-text": "Incolla come testo semplice"
|
||||
},
|
||||
"editing": {
|
||||
"editor_type": {
|
||||
"multiline-toolbar": "Mostra la barra degli strumenti su più linee se non entra."
|
||||
}
|
||||
},
|
||||
"edit_button": {
|
||||
"edit_this_note": "Modifica questa nota"
|
||||
},
|
||||
"shortcuts": {
|
||||
"shortcuts": "Scorciatoie"
|
||||
},
|
||||
"shared_switch": {
|
||||
"toggle-on-title": "Condividi la nota",
|
||||
"toggle-off-title": "Non condividere la nota"
|
||||
},
|
||||
"search_string": {
|
||||
"search_prefix": "Cerca:"
|
||||
},
|
||||
"attachment_detail": {
|
||||
"open_help_page": "Apri la pagina di aiuto sugli allegati"
|
||||
},
|
||||
"search_definition": {
|
||||
"ancestor": "antenato",
|
||||
"debug": "debug",
|
||||
"action": "azione",
|
||||
"add_search_option": "Aggiungi un opzione di ricerca:",
|
||||
"search_string": "cerca la stringa",
|
||||
"limit": "limite"
|
||||
},
|
||||
"modal": {
|
||||
"close": "Chiudi"
|
||||
},
|
||||
"board_view": {
|
||||
"insert-below": "Inserisci sotto",
|
||||
"delete-column": "Elimina la colonna",
|
||||
"delete-column-confirmation": "Sei sicuro di vole eliminare questa colonna? Il corrispondente attributo sarà eliminato anche nelle note sotto questa colonna."
|
||||
},
|
||||
"backup": {
|
||||
"enable_weekly_backup": "Abilita le archiviazioni settimanali",
|
||||
"enable_monthly_backup": "Abilita le archiviazioni mensili",
|
||||
"backup_recommendation": "Si raccomanda di mantenere attive le archiviazioni, sebbene ciò possa rendere l'avvio dell'applicazione lento con database grandi e/o dispositivi di archiviazione lenti.",
|
||||
"backup_now": "Archivia adesso",
|
||||
"backup_database_now": "Archivia il database adesso",
|
||||
"existing_backups": "Backup esistenti",
|
||||
"date-and-time": "Data e ora",
|
||||
"path": "Percorso",
|
||||
"database_backed_up_to": "Il database è stato archiviato in {{backupFilePath}}",
|
||||
"enable_daily_backup": "Abilita le archiviazioni giornaliere",
|
||||
"no_backup_yet": "Ancora nessuna archiviazione"
|
||||
},
|
||||
"backend_log": {
|
||||
"refresh": "Aggiorna"
|
||||
},
|
||||
"consistency_checks": {
|
||||
"find_and_fix_button": "Trova e correggi i problemi di coerenza",
|
||||
"finding_and_fixing_message": "In cerca e correzione dei problemi di coerenza...",
|
||||
"issues_fixed_message": "Qualsiasi problema di coerenza che possa essere stato trovato ora è corretto."
|
||||
},
|
||||
"database_integrity_check": {
|
||||
"check_button": "Controllo dell'integrità del database",
|
||||
"checking_integrity": "Controllo dell'integrità del database in corso...",
|
||||
"title": "Controllo di Integrità del database",
|
||||
"description": "Controllerà che il database non sia corrotto a livello SQLite. Può durare un po' di tempo, a seconda della grandezza del DB.",
|
||||
"integrity_check_failed": "Controllo di integrità fallito: {{results}}"
|
||||
},
|
||||
"sync": {
|
||||
"title": "Sincronizza",
|
||||
"force_full_sync_button": "Forza una sincronizzazione completa",
|
||||
"failed": "Sincronizzazione fallita: {{message}}"
|
||||
},
|
||||
"sync_2": {
|
||||
"config_title": "Configurazione per la Sincronizzazione",
|
||||
"proxy_label": "Server Proxy per la sincronizzazione (opzionale)",
|
||||
"test_title": "Test di sincronizzazione",
|
||||
"timeout": "Timeout per la sincronizzazione",
|
||||
"timeout_unit": "millisecondi",
|
||||
"save": "Salva",
|
||||
"help": "Aiuto"
|
||||
},
|
||||
"search_engine": {
|
||||
"save_button": "Salva"
|
||||
},
|
||||
"sql_table_schemas": {
|
||||
"tables": "Tabelle"
|
||||
},
|
||||
"tab_row": {
|
||||
"close_tab": "Chiudi la scheda",
|
||||
"add_new_tab": "Aggiungi una nuova scheda",
|
||||
"close": "Chiudi",
|
||||
"close_other_tabs": "Chiudi le altre schede",
|
||||
"close_right_tabs": "Chiudi le schede a destra",
|
||||
"close_all_tabs": "Chiudi tutte le schede",
|
||||
"reopen_last_tab": "Riapri l'ultima scheda chiusa",
|
||||
"move_tab_to_new_window": "Sposta questa scheda in una nuova finestra",
|
||||
"copy_tab_to_new_window": "Copia questa scheda in una nuova finestra",
|
||||
"new_tab": "Nuova scheda"
|
||||
},
|
||||
"toc": {
|
||||
"table_of_contents": "Sommario"
|
||||
},
|
||||
"table_of_contents": {
|
||||
"title": "Sommario"
|
||||
},
|
||||
"tray": {
|
||||
"title": "Vassoio di Sistema",
|
||||
"enable_tray": "Abilita il vassoio (Trilium necessita di essere riavviato affinché la modifica abbia effetto)"
|
||||
},
|
||||
"heading_style": {
|
||||
"title": "Stile dell'Intestazione",
|
||||
"plain": "Normale",
|
||||
"underline": "Sottolineato",
|
||||
"markdown": "Stile Markdown"
|
||||
},
|
||||
"highlights_list": {
|
||||
"title": "Punti salienti"
|
||||
},
|
||||
"highlights_list_2": {
|
||||
"title": "Punti salienti",
|
||||
"options": "Opzioni"
|
||||
},
|
||||
"quick-search": {
|
||||
"placeholder": "Ricerca rapida",
|
||||
"searching": "Ricerca in corso..."
|
||||
}
|
||||
},
|
||||
"add_link": {
|
||||
"add_link": "Aggiungi un collegamento",
|
||||
"note": "Nota",
|
||||
"search_note": "cerca una nota per nome",
|
||||
"link_title_mirrors": "il titolo del collegamento rispecchia il titolo della nota corrente",
|
||||
"link_title_arbitrary": "il titolo del collegamento può essere modificato arbitrariamente",
|
||||
"link_title": "Titolo del collegamento",
|
||||
"button_add_link": "Aggiungi il collegamento <kbd>invio</kbd>",
|
||||
"help_on_links": "Aiuto sui collegamenti"
|
||||
},
|
||||
"branch_prefix": {
|
||||
"edit_branch_prefix": "Modifica il prefisso del ramo",
|
||||
"help_on_tree_prefix": "Aiuto sui prefissi dell'Albero",
|
||||
"prefix": "Prefisso: ",
|
||||
"save": "Salva",
|
||||
"branch_prefix_saved": "Il prefisso del ramo è stato salvato."
|
||||
},
|
||||
"bulk_actions": {
|
||||
"bulk_actions": "Azioni massive",
|
||||
"affected_notes": "Note influenzate",
|
||||
"include_descendants": "Includi i discendenti della nota selezionata",
|
||||
"available_actions": "Azioni disponibili",
|
||||
"chosen_actions": "Azioni scelte",
|
||||
"execute_bulk_actions": "Esegui le azioni massive",
|
||||
"bulk_actions_executed": "Le azioni massive sono state eseguite con successo.",
|
||||
"none_yet": "Ancora nessuna... aggiungi una azione cliccando su una di quelle disponibili sopra.",
|
||||
"labels": "Etichette",
|
||||
"relations": "Relazioni",
|
||||
"notes": "Note",
|
||||
"other": "Altro"
|
||||
},
|
||||
"clone_to": {
|
||||
"clone_notes_to": "Clona note in...",
|
||||
"help_on_links": "Aiuto sui collegamenti",
|
||||
"notes_to_clone": "Note da clonare",
|
||||
"target_parent_note": "Nodo padre obiettivo",
|
||||
"search_for_note_by_its_name": "cerca una nota per nome",
|
||||
"cloned_note_prefix_title": "Le note clonate saranno mostrate nell'albero delle note con il dato prefisso",
|
||||
"prefix_optional": "Prefisso (opzionale)",
|
||||
"clone_to_selected_note": "Clona sotto la nota selezionata <kbd>invio</kbd>",
|
||||
"no_path_to_clone_to": "Nessun percorso per clonare dentro.",
|
||||
"note_cloned": "La nota \"{{clonedTitle}}\" è stata clonata in \"{{targetTitle}}\""
|
||||
},
|
||||
"confirm": {
|
||||
"cancel": "Annulla",
|
||||
"ok": "OK",
|
||||
"confirmation": "Conferma",
|
||||
"are_you_sure_remove_note": "Sei sicuro di voler rimuovere la nota \"{{title}}\" dalla mappa delle relazioni? ",
|
||||
"if_you_dont_check": "Se non lo selezioni, la nota sarà rimossa solamente dalla mappa delle relazioni.",
|
||||
"also_delete_note": "Rimuove anche la nota"
|
||||
},
|
||||
"delete_notes": {
|
||||
"ok": "OK",
|
||||
"close": "Chiudi",
|
||||
"delete_notes_preview": "Anteprima di eliminazione delle note",
|
||||
"delete_all_clones_description": "Elimina anche tutti i cloni (può essere disfatto tramite i cambiamenti recenti)",
|
||||
"erase_notes_description": "L'eliminazione normale (soft) marca le note come eliminate e potranno essere recuperate entro un certo lasso di tempo (dalla finestra dei cambiamenti recenti). Selezionando questa opzione le note si elimineranno immediatamente e non sarà possibile recuperarle.",
|
||||
"erase_notes_warning": "Elimina le note in modo permanente (non potrà essere disfatto), compresi tutti i cloni. Ciò forzerà un nuovo caricamento dell'applicazione.",
|
||||
"cancel": "Annulla",
|
||||
"notes_to_be_deleted": "Le seguenti note saranno eliminate ({{- noteCount}})",
|
||||
"no_note_to_delete": "Nessuna nota sarà eliminata (solo i cloni).",
|
||||
"broken_relations_to_be_deleted": "Le seguenti relazioni saranno interrotte ed eliminate ({{- relationCount}})",
|
||||
"deleted_relation_text": "La nota {{- note}} (da eliminare) è referenziata dalla relazione {{- relation}} originata da {{- source}}."
|
||||
},
|
||||
"info": {
|
||||
"okButton": "OK",
|
||||
"closeButton": "Chiudi"
|
||||
},
|
||||
"export": {
|
||||
"close": "Chiudi",
|
||||
"export_note_title": "Esporta la nota",
|
||||
"export_status": "Stato dell'esportazione",
|
||||
"export": "Esporta",
|
||||
"choose_export_type": "Scegli prima il tipo di esportazione, per favore",
|
||||
"export_in_progress": "Esportazione in corso: {{progressCount}}",
|
||||
"export_finished_successfully": "Esportazione terminata con successo.",
|
||||
"format_pdf": "PDF- allo scopo di stampa o esportazione.",
|
||||
"export_type_subtree": "Questa nota e tutti i suoi discendenti",
|
||||
"format_html": "HTML - raccomandato in quanto mantiene tutti i formati",
|
||||
"format_html_zip": "HTML in archivio ZIP - questo è raccomandato in quanto conserva tutta la formattazione.",
|
||||
"format_markdown": "MArkdown - questo conserva la maggior parte della formattazione.",
|
||||
"export_type_single": "Solo questa nota, senza le sottostanti"
|
||||
},
|
||||
"password_not_set": {
|
||||
"body1": "Le note protette sono crittografate utilizzando una password utente, ma la password non è stata ancora impostata.",
|
||||
"body2": "Per proteggere le note, fare clic su <a class=\"open-password-options-button\" href=\"javascript:\">qui</a> per aprire la finestra di dialogo Opzioni e impostare la password."
|
||||
},
|
||||
"protected_session_password": {
|
||||
"close_label": "Chiudi"
|
||||
},
|
||||
"abstract_bulk_action": {
|
||||
"remove_this_search_action": "Rimuovi questa azione di ricerca"
|
||||
},
|
||||
"etapi": {
|
||||
"new_token_title": "Nuovo token ETAPI",
|
||||
"new_token_message": "Inserire il nuovo nome del token"
|
||||
},
|
||||
"electron_integration": {
|
||||
"zoom-factor": "Fattore di ingrandimento",
|
||||
"desktop-application": "Applicazione Desktop"
|
||||
},
|
||||
"note_autocomplete": {
|
||||
"search-for": "Cerca \"{{term}}\"",
|
||||
"create-note": "Crea e collega la nota figlia \"{{term}}\"",
|
||||
"insert-external-link": "Inserisci il collegamento esterno a \"{{term}}\"",
|
||||
"clear-text-field": "Pulisci il campo di testo",
|
||||
"show-recent-notes": "Mostra le note recenti",
|
||||
"full-text-search": "Ricerca full text"
|
||||
},
|
||||
"note_tooltip": {
|
||||
"note-has-been-deleted": "La nota è stata eliminata.",
|
||||
"quick-edit": "Modifica veloce"
|
||||
},
|
||||
"geo-map": {
|
||||
"create-child-note-title": "Crea una nota figlia e aggiungila alla mappa",
|
||||
"create-child-note-instruction": "Clicca sulla mappa per creare una nuova nota qui o premi Escape per uscire.",
|
||||
"unable-to-load-map": "Impossibile caricare la mappa."
|
||||
},
|
||||
"geo-map-context": {
|
||||
"open-location": "Apri la posizione",
|
||||
"remove-from-map": "Rimuovi dalla mappa",
|
||||
"add-note": "Aggiungi un marcatore in questa posizione"
|
||||
},
|
||||
"debug": {
|
||||
"debug": "Debug"
|
||||
},
|
||||
"database_anonymization": {
|
||||
"light_anonymization": "Anonimizzazione parziale",
|
||||
"title": "Anonimizzazione del Database",
|
||||
"full_anonymization": "Anonimizzazione completa",
|
||||
"full_anonymization_description": "Questa azione creerà una nuova copia del database e lo anonimizzerà (rimuove tutti i contenuti delle note, lasciando solo la struttura e qualche metadato non sensibile) per condividerlo online allo scopo di debugging, senza paura di far trapelare i tuoi dati personali.",
|
||||
"save_fully_anonymized_database": "Salva il database completamente anonimizzato",
|
||||
"light_anonymization_description": "Questa azione creerà una nuova copia del database e lo anonimizzerà in parzialmente — in particolare, solo il contenuto delle note sarà rimosso, ma i titoli e gli attributi rimarranno. Inoltre, note con script personalizzati JS di frontend/backend e widget personalizzati lasciando rimarranno. Ciò mette a disposizione più contesto per il debug dei problemi.",
|
||||
"choose_anonymization": "Puoi decidere da solo se fornire un database completamente o parzialmente anonimizzato. Anche un database completamente anonimizzato è molto utile, sebbene in alcuni casi i database parzialmente anonimizzati possono accelerare il processo di identificazione dei bug e la loro correzione.",
|
||||
"no_anonymized_database_yet": "Nessun database ancora anonimizzato.",
|
||||
"save_lightly_anonymized_database": "Salva il database parzialmente anonimizzato",
|
||||
"successfully_created_fully_anonymized_database": "Database completamente anonimizzato creato in {{anonymizedFilePath}}",
|
||||
"successfully_created_lightly_anonymized_database": "Database parzialmente anonimizzato creato in {{anonymizedFilePath}}"
|
||||
},
|
||||
"cpu_arch_warning": {
|
||||
"title": "Per favore scarica la versione ARM64",
|
||||
"continue_anyway": "Continua Comunque",
|
||||
"dont_show_again": "Non mostrare più questo avviso",
|
||||
"download_link": "Scarica la Versione Nativa"
|
||||
},
|
||||
"editorfeatures": {
|
||||
"title": "Caratteristiche",
|
||||
"emoji_completion_enabled": "Abilita il completamento automatico delle Emoji",
|
||||
"note_completion_enabled": "Abilita il completamento automatico delle note"
|
||||
},
|
||||
"table_view": {
|
||||
"new-row": "Nuova riga",
|
||||
"new-column": "Nuova colonna",
|
||||
"sort-column-by": "Ordina per \"{{title}}\"",
|
||||
"sort-column-ascending": "Ascendente",
|
||||
"sort-column-descending": "Discendente",
|
||||
"sort-column-clear": "Cancella l'ordinamento",
|
||||
"hide-column": "Nascondi la colonna \"{{title}}\"",
|
||||
"show-hide-columns": "Mostra/nascondi le colonne",
|
||||
"row-insert-above": "Inserisci una riga sopra",
|
||||
"row-insert-below": "Inserisci una riga sotto"
|
||||
},
|
||||
"abstract_search_option": {
|
||||
"remove_this_search_option": "Rimuovi questa opzione di ricerca",
|
||||
"failed_rendering": "Opzione di ricerca di rendering non riuscita: {{dto}} con errore: {{error}} {{stack}}"
|
||||
},
|
||||
"ancestor": {
|
||||
"label": "Antenato"
|
||||
},
|
||||
"add_label": {
|
||||
"add_label": "Aggiungi etichetta",
|
||||
"label_name_placeholder": "nome dell'etichetta",
|
||||
"new_value_placeholder": "nuovo valore",
|
||||
"to_value": "al valore"
|
||||
},
|
||||
"update_label_value": {
|
||||
"to_value": "al valore",
|
||||
"label_name_placeholder": "nome dell'etichetta"
|
||||
},
|
||||
"delete_label": {
|
||||
"delete_label": "Elimina etichetta",
|
||||
"label_name_placeholder": "nome dell'etichetta",
|
||||
"label_name_title": "Sono ammessi i caratteri alfanumerici, il carattere di sottolineato e i due punti."
|
||||
},
|
||||
"tree-context-menu": {
|
||||
"move-to": "Muovi in...",
|
||||
"cut": "Taglia"
|
||||
},
|
||||
"electron_context_menu": {
|
||||
"cut": "Taglia",
|
||||
"copy": "Copia",
|
||||
"paste": "Incolla",
|
||||
"copy-link": "Copia collegamento",
|
||||
"paste-as-plain-text": "Incolla come testo semplice"
|
||||
},
|
||||
"editing": {
|
||||
"editor_type": {
|
||||
"multiline-toolbar": "Mostra la barra degli strumenti su più linee se non entra."
|
||||
}
|
||||
},
|
||||
"edit_button": {
|
||||
"edit_this_note": "Modifica questa nota"
|
||||
},
|
||||
"shortcuts": {
|
||||
"shortcuts": "Scorciatoie"
|
||||
},
|
||||
"shared_switch": {
|
||||
"toggle-on-title": "Condividi la nota",
|
||||
"toggle-off-title": "Non condividere la nota"
|
||||
},
|
||||
"search_string": {
|
||||
"search_prefix": "Cerca:"
|
||||
},
|
||||
"attachment_detail": {
|
||||
"open_help_page": "Apri la pagina di aiuto sugli allegati"
|
||||
},
|
||||
"search_definition": {
|
||||
"ancestor": "antenato",
|
||||
"debug": "debug",
|
||||
"action": "azione",
|
||||
"add_search_option": "Aggiungi un opzione di ricerca:",
|
||||
"search_string": "cerca la stringa",
|
||||
"limit": "limite"
|
||||
},
|
||||
"modal": {
|
||||
"close": "Chiudi"
|
||||
},
|
||||
"board_view": {
|
||||
"insert-below": "Inserisci sotto",
|
||||
"delete-column": "Elimina la colonna",
|
||||
"delete-column-confirmation": "Sei sicuro di vole eliminare questa colonna? Il corrispondente attributo sarà eliminato anche nelle note sotto questa colonna."
|
||||
},
|
||||
"backup": {
|
||||
"enable_weekly_backup": "Abilita le archiviazioni settimanali",
|
||||
"enable_monthly_backup": "Abilita le archiviazioni mensili",
|
||||
"backup_recommendation": "Si raccomanda di mantenere attive le archiviazioni, sebbene ciò possa rendere l'avvio dell'applicazione lento con database grandi e/o dispositivi di archiviazione lenti.",
|
||||
"backup_now": "Archivia adesso",
|
||||
"backup_database_now": "Archivia il database adesso",
|
||||
"existing_backups": "Backup esistenti",
|
||||
"date-and-time": "Data e ora",
|
||||
"path": "Percorso",
|
||||
"database_backed_up_to": "Il database è stato archiviato in {{backupFilePath}}",
|
||||
"enable_daily_backup": "Abilita le archiviazioni giornaliere",
|
||||
"no_backup_yet": "Ancora nessuna archiviazione"
|
||||
},
|
||||
"backend_log": {
|
||||
"refresh": "Aggiorna"
|
||||
},
|
||||
"consistency_checks": {
|
||||
"find_and_fix_button": "Trova e correggi i problemi di coerenza",
|
||||
"finding_and_fixing_message": "In cerca e correzione dei problemi di coerenza...",
|
||||
"issues_fixed_message": "Qualsiasi problema di coerenza che possa essere stato trovato ora è corretto."
|
||||
},
|
||||
"database_integrity_check": {
|
||||
"check_button": "Controllo dell'integrità del database",
|
||||
"checking_integrity": "Controllo dell'integrità del database in corso...",
|
||||
"title": "Controllo di Integrità del database",
|
||||
"description": "Controllerà che il database non sia corrotto a livello SQLite. Può durare un po' di tempo, a seconda della grandezza del DB.",
|
||||
"integrity_check_failed": "Controllo di integrità fallito: {{results}}"
|
||||
},
|
||||
"sync": {
|
||||
"title": "Sincronizza",
|
||||
"force_full_sync_button": "Forza una sincronizzazione completa",
|
||||
"failed": "Sincronizzazione fallita: {{message}}"
|
||||
},
|
||||
"sync_2": {
|
||||
"config_title": "Configurazione per la Sincronizzazione",
|
||||
"proxy_label": "Server Proxy per la sincronizzazione (opzionale)",
|
||||
"test_title": "Test di sincronizzazione",
|
||||
"timeout": "Timeout per la sincronizzazione",
|
||||
"timeout_unit": "millisecondi",
|
||||
"save": "Salva",
|
||||
"help": "Aiuto"
|
||||
},
|
||||
"search_engine": {
|
||||
"save_button": "Salva"
|
||||
},
|
||||
"sql_table_schemas": {
|
||||
"tables": "Tabelle"
|
||||
},
|
||||
"tab_row": {
|
||||
"close_tab": "Chiudi la scheda",
|
||||
"add_new_tab": "Aggiungi una nuova scheda",
|
||||
"close": "Chiudi",
|
||||
"close_other_tabs": "Chiudi le altre schede",
|
||||
"close_right_tabs": "Chiudi le schede a destra",
|
||||
"close_all_tabs": "Chiudi tutte le schede",
|
||||
"reopen_last_tab": "Riapri l'ultima scheda chiusa",
|
||||
"move_tab_to_new_window": "Sposta questa scheda in una nuova finestra",
|
||||
"copy_tab_to_new_window": "Copia questa scheda in una nuova finestra",
|
||||
"new_tab": "Nuova scheda"
|
||||
},
|
||||
"toc": {
|
||||
"table_of_contents": "Sommario"
|
||||
},
|
||||
"table_of_contents": {
|
||||
"title": "Sommario"
|
||||
},
|
||||
"tray": {
|
||||
"title": "Vassoio di Sistema",
|
||||
"enable_tray": "Abilita il vassoio (Trilium necessita di essere riavviato affinché la modifica abbia effetto)"
|
||||
},
|
||||
"heading_style": {
|
||||
"title": "Stile dell'Intestazione",
|
||||
"plain": "Normale",
|
||||
"underline": "Sottolineato",
|
||||
"markdown": "Stile Markdown"
|
||||
},
|
||||
"highlights_list": {
|
||||
"title": "Punti salienti"
|
||||
},
|
||||
"highlights_list_2": {
|
||||
"title": "Punti salienti",
|
||||
"options": "Opzioni"
|
||||
},
|
||||
"quick-search": {
|
||||
"placeholder": "Ricerca rapida",
|
||||
"searching": "Ricerca in corso..."
|
||||
},
|
||||
"help": {
|
||||
"goUpDown": "su/giù nella lista delle note",
|
||||
"collapseExpand": "collassa/espande il nodo",
|
||||
"notSet": "non impostato",
|
||||
"goBackForwards": "indietro/avanti nella cronologia",
|
||||
"showJumpToNoteDialog": "mostra <a class=\"external\" href=\"https://triliumnext.github.io/Docs/Wiki/note-navigation.html#jump-to-note\">la finestra di dialogo \"Salta alla nota\"<a>"
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,29 +0,0 @@
|
||||
{
|
||||
"about": {
|
||||
"title": "Trilium Notes에 대해서",
|
||||
"homepage": "홈페이지:",
|
||||
"app_version": "앱 버전:",
|
||||
"db_version": "DB 버전:",
|
||||
"sync_version": "동기화 버전:",
|
||||
"build_date": "빌드 날짜:",
|
||||
"build_revision": "빌드 리비전:",
|
||||
"data_directory": "데이터 경로:"
|
||||
},
|
||||
"toast": {
|
||||
"critical-error": {
|
||||
"title": "심각한 오류",
|
||||
"message": "클라이언트 애플리케이션 시작 도중 심각한 오류가 발생했습니다:\n\n{{message}}\n\n이는 스크립트가 예기치 않게 실패하면서 발생한 것일 수 있습니다. 애플리케이션을 안전 모드로 시작한 뒤 문제를 해결해 보세요."
|
||||
},
|
||||
"widget-error": {
|
||||
"title": "위젯 초기화 실패"
|
||||
}
|
||||
},
|
||||
"add_link": {
|
||||
"add_link": "링크 추가",
|
||||
"note": "노트",
|
||||
"search_note": "이름으로 노트 검색하기"
|
||||
},
|
||||
"branch_prefix": {
|
||||
"save": "저장"
|
||||
}
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
{
|
||||
"about": {
|
||||
"title": "Over Trilium Notes",
|
||||
"homepage": "Homepagina:",
|
||||
"app_version": "App versie:",
|
||||
"db_version": "DB Versie:",
|
||||
"sync_version": "Sync Versie:"
|
||||
}
|
||||
}
|
||||
@@ -1,139 +0,0 @@
|
||||
{
|
||||
"about": {
|
||||
"title": "O notatkach Trilium",
|
||||
"homepage": "Strona główna:",
|
||||
"app_version": "Wersja aplikacji:",
|
||||
"db_version": "Wersja bazy danych:",
|
||||
"sync_version": "Wersja synchronizacji:",
|
||||
"build_date": "Zbudowano:",
|
||||
"build_revision": "Rewizja zbudowania:",
|
||||
"data_directory": "Katalog z danymi:"
|
||||
},
|
||||
"toast": {
|
||||
"critical-error": {
|
||||
"title": "Błąd krytyczny",
|
||||
"message": "Wystąpił krytyczny błąd uniemożliwiający uruchomienie aplikacji:\n\n{{message}}\n\nJest to spowodowane najprawdopodobniej niespodziewanym błędem skryptu. Spróbuj uruchomić aplikację ponownie w trybie bezpiecznym i zaadresuj problem."
|
||||
},
|
||||
"widget-error": {
|
||||
"title": "Nie udało się zainicjować widżetu",
|
||||
"message-custom": "Niestandardowy widżet z notatki o identyfikatorze \"{{id}}\", i tytule \"{{title}}\" nie mógł zostać zainicjowany z powodu:\n\n{{message}}",
|
||||
"message-unknown": "Nieznany widżet nie mógł być zainicjowany z powodu:\n\n{{message}}"
|
||||
},
|
||||
"bundle-error": {
|
||||
"title": "Nie udało się załadować niestandardowego skryptu",
|
||||
"message": "Skrypt z notatki o identyfikatorze \"{{id}}\", tytule \"{{title}}: nie został uruchomiony z powodu:\n\n{{message}}"
|
||||
}
|
||||
},
|
||||
"add_link": {
|
||||
"add_link": "Dodaj link",
|
||||
"note": "Notatka",
|
||||
"search_note": "Wyszukaj notatkę po nazwie",
|
||||
"link_title_arbitrary": "Tytuł linku można dowolnie zmieniać",
|
||||
"link_title": "Tytuł linku",
|
||||
"button_add_link": "Dodaj link"
|
||||
},
|
||||
"branch_prefix": {
|
||||
"save": "Zapisz",
|
||||
"edit_branch_prefix": "Edytuj prefiks gałęzi",
|
||||
"prefix": "Prefiks: ",
|
||||
"branch_prefix_saved": "Zapisano prefiks gałęzi."
|
||||
},
|
||||
"bulk_actions": {
|
||||
"labels": "Etykiety",
|
||||
"notes": "Notatki",
|
||||
"other": "Inne",
|
||||
"relations": "Powiązania",
|
||||
"bulk_actions": "Działania zbiorcze",
|
||||
"include_descendants": "Uwzględnia rozwinięcia wybranych notatek",
|
||||
"available_actions": "Dostępne działania",
|
||||
"chosen_actions": "Wybrane działania",
|
||||
"execute_bulk_actions": "Wykonaj zbiór działań",
|
||||
"bulk_actions_executed": "Zbiór działań został wykonany prawidłowo.",
|
||||
"none_yet": "Brak zaznaczonych działań... dodaj działanie poprzez kliknięcie jednej z dostępnych opcji powyżej."
|
||||
},
|
||||
"confirm": {
|
||||
"ok": "OK",
|
||||
"cancel": "Anuluj",
|
||||
"confirmation": "Potwierdzenie",
|
||||
"are_you_sure_remove_note": "Czy napewno chcesz usunąć notatkę \"{{title}}\" z mapy powiązań? ",
|
||||
"if_you_dont_check": "Jeśli nie zaznaczysz tej opcji, notatka zostanie usunięta jedynie z mapy powiązań.",
|
||||
"also_delete_note": "Usuń dodatkowo notatkę"
|
||||
},
|
||||
"delete_notes": {
|
||||
"cancel": "Anuluj",
|
||||
"close": "Zamknij",
|
||||
"delete_notes_preview": "Usuń podgląd notatek",
|
||||
"delete_all_clones_description": "Usuń również wszystkie sklonowania (działanie może zostać cofnięte w ostatnich zmianach)",
|
||||
"erase_notes_description": "Normalne (miękkie) usuwanie zaznacza jedynie notatki jako usunięte i można je przywrócić (w oknie dialogowym ostatnich zmian) przez wyznaczony okres czasu. Zaznaczenie tej opcji spowoduje natychmiastowe usunięcie notatek, bez możliwości ich przywrócenia.",
|
||||
"erase_notes_warning": "Usuń notatki permanentnie (bez opcji ich przywrócenia), włączając wszystkie kopie. Działanie to wymaga ponownego uruchomienia aplikacji.",
|
||||
"notes_to_be_deleted": "Następujące notatki zostaną usunięte ({{notesCount}})",
|
||||
"no_note_to_delete": "Żadne notatki nie zostaną usunięte (jedynie kopie).",
|
||||
"broken_relations_to_be_deleted": "Następujące powiązania zostaną uszkodzone i usunięte ({{ relationCount}})",
|
||||
"ok": "OK",
|
||||
"deleted_relation_text": "Notatka {{- note}} (do usunięcia) jest powiązana przez relację {{- relation}} pochodzącą z {{- source}}."
|
||||
},
|
||||
"export": {
|
||||
"close": "Zamknij",
|
||||
"export_note_title": "Eksportuj notatkę",
|
||||
"export_type_subtree": "Ta notatka oraz wszystkie podrzędne",
|
||||
"format_html": "HTML - rekomendowany jako zachowujący całość formatowania",
|
||||
"format_html_zip": "HTML w archiwum ZIP - rekomendowany jako zachowujący całość formatowania.",
|
||||
"format_markdown": "Markdown - zachowuje większość formatowania.",
|
||||
"format_opml": "OPML - format wymiany danych dla outlinerów zawierający tylko tekst. Formatowanie, obrazy i pliki nie są uwzględnione.",
|
||||
"opml_version_1": "OPML v1.0 - tylko zwykły tekst",
|
||||
"opml_version_2": "OPML v2.0 - umożliwia również HTML",
|
||||
"export_type_single": "Tylko ta notatka, bez elementów podrzędnych",
|
||||
"export": "Eksportuj",
|
||||
"choose_export_type": "Wybierz najpierw rodzaj pliku do eksportu",
|
||||
"export_status": "Status eksportu",
|
||||
"export_in_progress": "Postęp eksportowania: {{progressCount}}",
|
||||
"export_finished_successfully": "Eksportowanie zakończone.",
|
||||
"format_pdf": "PDF - w celu drukowania lub udostępniania."
|
||||
},
|
||||
"clone_to": {
|
||||
"clone_notes_to": "Sklonuj notatki do...",
|
||||
"notes_to_clone": "Notatki do sklonowania",
|
||||
"search_for_note_by_its_name": "Wyszukaj notatkę po jej nazwie",
|
||||
"cloned_note_prefix_title": "Sklonowana notatka zostanie wyświetlona w drzewie notatki z podanym prefiksem",
|
||||
"prefix_optional": "Prefiks (opcjonalne)",
|
||||
"clone_to_selected_note": "Sklonuj do wybranej notatki",
|
||||
"no_path_to_clone_to": "Brak ścieżki do sklonowania.",
|
||||
"note_cloned": "Notatka \"{{clonedTitle}}\" została sklonowana do \"{{targetTitle}}\""
|
||||
},
|
||||
"help": {
|
||||
"title": "Ściągawka",
|
||||
"noteNavigation": "Nawigacja po notatkach",
|
||||
"goUpDown": "przewijanie w górę/w dół w liście notatek",
|
||||
"collapseExpand": "zwiń/rozwiń zbiór",
|
||||
"notSet": "niezdefiniowany",
|
||||
"goBackForwards": "przewijaj do tyłu/do przodu w historii",
|
||||
"showJumpToNoteDialog": "pokaż <a class=\"external\" href=\"https://triliumnext.github.io/Docs/Wiki/note-navigation.html#jump-to-note\">\"przejdź do dialogu</a>",
|
||||
"scrollToActiveNote": "przewiń do aktywnej notatki",
|
||||
"jumpToParentNote": "przejdź do głównej notatki",
|
||||
"collapseWholeTree": "zwiń całe drzewko notatki",
|
||||
"collapseSubTree": "zwiń gałąź notatki",
|
||||
"tabShortcuts": "Skóry kart",
|
||||
"newTabNoteLink": "link notatki otwiera notatkę w nowej karcie",
|
||||
"newTabWithActivationNoteLink": "link notatki otwiera i aktywuje notatkę w nowej karcie",
|
||||
"onlyInDesktop": "Tylko na komputerze stacjonarnym (wersja Electron)",
|
||||
"openEmptyTab": "Otwórz pustą kartę",
|
||||
"closeActiveTab": "zamknij aktywną kartę",
|
||||
"activateNextTab": "aktywuj następną kartę",
|
||||
"activatePreviousTab": "aktywuj poprzednią kartę",
|
||||
"creatingNotes": "Tworzenie notatek",
|
||||
"createNoteAfter": "Utwórz nową notatkę obok obecnie aktywnej",
|
||||
"createNoteInto": "Utwórz nową podnotatkę w obecnie otwartej",
|
||||
"editBranchPrefix": "edytuj <a class=\"external\" href=\"https://triliumnext.github.io/Docs/Wiki/tree-concepts.html#prefix\">prefiks</a> aktywnej kopii notatki",
|
||||
"movingCloningNotes": "Przenoszenie / kopiowanie notatek",
|
||||
"moveNoteUpDown": "Przenieś notatkę w górę/w dół na liście notatek",
|
||||
"moveNoteUpHierarchy": "Przenieś notatkę w górę w hierarchii",
|
||||
"multiSelectNote": "Zaznacz wiele notatek powyżej/poniżej",
|
||||
"selectAllNotes": "Wybierz wszystkie notatki na obecnym poziomie",
|
||||
"selectNote": "Wybierz notatkę",
|
||||
"copyNotes": "skopiuj obecną notatkę (lub obecną sekcję) do schowka (zastosowanie dla<a class=\"external\" href=\"https://triliumnext.github.io/Docs/Wiki/cloning-notes.html#cloning-notes\">klonowania</a>)",
|
||||
"cutNotes": "przytnij obecną notatkę (lub obecną sekcję) do schowka (zastosowanie dla przenoszenia notatek)",
|
||||
"pasteNotes": "wklej notatkę jako podnotatka w obecnej notatce (rozumiane jako przenieś lub skopiuj, w zależności czy notatka była skopiowana czy wycięta)",
|
||||
"deleteNotes": "usuń notatkę / gałąź",
|
||||
"editingNotes": "Edytowanie notatek"
|
||||
}
|
||||
}
|
||||
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
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -1,88 +1,69 @@
|
||||
{
|
||||
"about": {
|
||||
"homepage": "Trang chủ:",
|
||||
"title": "Về Trilium Notes",
|
||||
"app_version": "Phiên bản:",
|
||||
"db_version": "Phiên bản DB:",
|
||||
"sync_version": "Phiên bản liên kết:"
|
||||
},
|
||||
"add_link": {
|
||||
"add_link": "Thêm liên kết",
|
||||
"button_add_link": "Thêm liên kết"
|
||||
},
|
||||
"bulk_actions": {
|
||||
"other": "Khác"
|
||||
},
|
||||
"branch_prefix": {
|
||||
"save": "Lưu"
|
||||
},
|
||||
"confirm": {
|
||||
"ok": "OK",
|
||||
"cancel": "Huỷ"
|
||||
},
|
||||
"delete_notes": {
|
||||
"close": "Đóng",
|
||||
"ok": "OK",
|
||||
"cancel": "Huỷ"
|
||||
},
|
||||
"export": {
|
||||
"close": "Đóng"
|
||||
},
|
||||
"help": {
|
||||
"other": "Khác",
|
||||
"notSet": "chưa được đặt"
|
||||
},
|
||||
"toast": {
|
||||
"critical-error": {
|
||||
"title": "Lỗi nghiêm trọng"
|
||||
"about": {
|
||||
"homepage": "Trang chủ:",
|
||||
"title": "Về Trilium Notes"
|
||||
},
|
||||
"add_link": {
|
||||
"add_link": "Thêm liên kết",
|
||||
"button_add_link": "Thêm liên kết"
|
||||
},
|
||||
"bulk_actions": {
|
||||
"other": "Khác"
|
||||
},
|
||||
"branch_prefix": {
|
||||
"save": "Lưu"
|
||||
},
|
||||
"confirm": {
|
||||
"ok": "OK",
|
||||
"cancel": "Huỷ"
|
||||
},
|
||||
"delete_notes": {
|
||||
"close": "Đóng",
|
||||
"ok": "OK",
|
||||
"cancel": "Huỷ"
|
||||
},
|
||||
"export": {
|
||||
"close": "Đóng"
|
||||
},
|
||||
"help": {
|
||||
"other": "Khác"
|
||||
},
|
||||
"toast": {
|
||||
"critical-error": {
|
||||
"title": "Lỗi nghiêm trọng"
|
||||
}
|
||||
},
|
||||
"import": {
|
||||
"options": "Tuỳ chọn"
|
||||
},
|
||||
"info": {
|
||||
"okButton": "OK",
|
||||
"closeButton": "Đóng"
|
||||
},
|
||||
"move_to": {
|
||||
"dialog_title": "Chuyển ghi chép tới..."
|
||||
},
|
||||
"prompt": {
|
||||
"ok": "OK"
|
||||
},
|
||||
"protected_session_password": {
|
||||
"close_label": "Đóng"
|
||||
},
|
||||
"revisions": {
|
||||
"restore_button": "Khôi phục",
|
||||
"delete_button": "Xoá"
|
||||
},
|
||||
"upload_attachments": {
|
||||
"options": "Tuỳ chọn"
|
||||
},
|
||||
"attribute_detail": {
|
||||
"name": "Tên",
|
||||
"value": "Giá trị",
|
||||
"text": "Văn bản",
|
||||
"number": "Số",
|
||||
"delete": "Xoá"
|
||||
},
|
||||
"rename_note": {
|
||||
"rename_note": "Đổi tên ghi chép"
|
||||
}
|
||||
},
|
||||
"import": {
|
||||
"options": "Tuỳ chọn"
|
||||
},
|
||||
"info": {
|
||||
"okButton": "OK",
|
||||
"closeButton": "Đóng"
|
||||
},
|
||||
"move_to": {
|
||||
"dialog_title": "Chuyển ghi chép tới..."
|
||||
},
|
||||
"prompt": {
|
||||
"ok": "OK"
|
||||
},
|
||||
"protected_session_password": {
|
||||
"close_label": "Đóng"
|
||||
},
|
||||
"revisions": {
|
||||
"restore_button": "Khôi phục",
|
||||
"delete_button": "Xoá"
|
||||
},
|
||||
"upload_attachments": {
|
||||
"options": "Tuỳ chọn"
|
||||
},
|
||||
"attribute_detail": {
|
||||
"name": "Tên",
|
||||
"value": "Giá trị",
|
||||
"text": "Văn bản",
|
||||
"number": "Số",
|
||||
"delete": "Xoá"
|
||||
},
|
||||
"rename_note": {
|
||||
"rename_note": "Đổi tên ghi chép"
|
||||
},
|
||||
"add_label": {
|
||||
"add_label": "Thêm nhãn",
|
||||
"label_name_placeholder": "tên nhãn",
|
||||
"help_text_item2": "hoặc thay đổi giá trị của nhãn có sẵn",
|
||||
"new_value_placeholder": "giá trị mới"
|
||||
},
|
||||
"rename_label": {
|
||||
"rename_label": "Đặt lại tên nhãn"
|
||||
},
|
||||
"call_to_action": {
|
||||
"dismiss": "Bỏ qua"
|
||||
},
|
||||
"abstract_search_option": {
|
||||
"remove_this_search_option": "Xoá lựa chọn tìm kiếm này"
|
||||
}
|
||||
}
|
||||
|
||||
2
apps/client/src/types-fancytree.d.ts
vendored
2
apps/client/src/types-fancytree.d.ts
vendored
@@ -113,7 +113,7 @@ declare namespace Fancytree {
|
||||
generateFormElements(selected?: boolean, active?: boolean): void;
|
||||
|
||||
/** Return the currently active node or null. */
|
||||
getActiveNode(): FancytreeNode | null;
|
||||
getActiveNode(): FancytreeNode;
|
||||
|
||||
/** Return the first top level node if any (not the invisible root node). */
|
||||
getFirstChild(): FancytreeNode;
|
||||
|
||||
@@ -3,11 +3,7 @@ type DateTimeStyle = "full" | "long" | "medium" | "short" | "none" | undefined;
|
||||
/**
|
||||
* Formats the given date and time to a string based on the current locale.
|
||||
*/
|
||||
export function formatDateTime(date: string | Date | number | null | undefined, dateStyle: DateTimeStyle = "medium", timeStyle: DateTimeStyle = "medium") {
|
||||
if (!date) {
|
||||
return "";
|
||||
}
|
||||
|
||||
export function formatDateTime(date: string | Date | number, dateStyle: DateTimeStyle = "medium", timeStyle: DateTimeStyle = "medium") {
|
||||
const locale = navigator.language;
|
||||
|
||||
let parsedDate;
|
||||
|
||||
@@ -1,163 +0,0 @@
|
||||
/* #region Generic floating buttons styles */
|
||||
.floating-buttons {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.floating-buttons-children,
|
||||
.show-floating-buttons {
|
||||
position: absolute;
|
||||
top: 10px;
|
||||
right: 10px;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
z-index: 100;
|
||||
}
|
||||
|
||||
.note-split.rtl .floating-buttons-children,
|
||||
.note-split.rtl .show-floating-buttons {
|
||||
right: unset;
|
||||
left: 10px;
|
||||
}
|
||||
|
||||
.note-split.rtl .close-floating-buttons {
|
||||
order: -1;
|
||||
}
|
||||
|
||||
.note-split.rtl .close-floating-buttons,
|
||||
.note-split.rtl .show-floating-buttons {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
|
||||
.type-canvas .floating-buttons-children {
|
||||
top: 70px;
|
||||
}
|
||||
|
||||
.type-canvas .floating-buttons-children > * {
|
||||
--border-radius: 0; /* Overridden by themes */
|
||||
}
|
||||
|
||||
.floating-buttons-children > *:not(.hidden-int):not(.no-content-hidden) {
|
||||
margin: 2px;
|
||||
}
|
||||
|
||||
.floating-buttons-children > *:not(.has-overflow) {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.floating-buttons-children > button, .floating-buttons-children .floating-button {
|
||||
font-size: 150%;
|
||||
padding: 5px 10px 4px 10px;
|
||||
width: 40px;
|
||||
cursor: pointer;
|
||||
color: var(--button-text-color);
|
||||
background: var(--button-background-color);
|
||||
border-radius: var(--button-border-radius);
|
||||
border: 1px solid transparent;
|
||||
display: flex;
|
||||
justify-content: space-around;
|
||||
}
|
||||
|
||||
.floating-buttons-children > button:hover, .floating-buttons-children .floating-button:hover {
|
||||
text-decoration: none;
|
||||
border-color: var(--button-border-color);
|
||||
}
|
||||
|
||||
.floating-buttons .floating-buttons-children.temporarily-hidden {
|
||||
display: none;
|
||||
}
|
||||
/* #endregion */
|
||||
|
||||
/* #region Show floating button */
|
||||
.floating-buttons-children.temporarily-hidden+.show-floating-buttons {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.show-floating-buttons {
|
||||
/* display: none;*/
|
||||
margin-left: 5px !important;
|
||||
}
|
||||
|
||||
.show-floating-buttons-button {
|
||||
border: 1px solid transparent;
|
||||
color: var(--button-text-color);
|
||||
padding: 6px;
|
||||
border-radius: 100px !important;
|
||||
}
|
||||
|
||||
.show-floating-buttons-button:hover {
|
||||
border: 1px solid var(--button-border-color);
|
||||
}
|
||||
/* #endregion */
|
||||
|
||||
/* #region Geo map buttons */
|
||||
.leaflet-pane {
|
||||
z-index: 50;
|
||||
}
|
||||
/* #endregion */
|
||||
|
||||
/* #region Close floating buttons */
|
||||
.close-floating-buttons {
|
||||
margin-left: 5px !important;
|
||||
}
|
||||
|
||||
.close-floating-buttons:first-child {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
.close-floating-buttons-button {
|
||||
border: 1px solid transparent;
|
||||
color: var(--button-text-color);
|
||||
padding: 6px;
|
||||
border-radius: 100px;
|
||||
}
|
||||
|
||||
.close-floating-buttons-button:hover {
|
||||
border: 1px solid var(--button-border-color);
|
||||
}
|
||||
/* #endregion */
|
||||
|
||||
/* #region Backlinks */
|
||||
.backlinks-widget {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.backlinks-ticker {
|
||||
border-radius: 10px;
|
||||
border-color: var(--main-border-color);
|
||||
background-color: var(--more-accented-background-color);
|
||||
padding: 4px 10px 4px 10px;
|
||||
opacity: 90%;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.backlinks-count {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.backlinks-items {
|
||||
z-index: 10;
|
||||
position: absolute;
|
||||
top: 50px;
|
||||
right: 10px;
|
||||
width: 400px;
|
||||
border-radius: 10px;
|
||||
background-color: var(--accented-background-color);
|
||||
color: var(--main-text-color);
|
||||
padding: 20px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.backlink-excerpt {
|
||||
border-left: 2px solid var(--main-border-color);
|
||||
padding-left: 10px;
|
||||
opacity: 80%;
|
||||
font-size: 90%;
|
||||
}
|
||||
|
||||
.backlink-excerpt .backlink-link { /* the actual backlink */
|
||||
font-weight: bold;
|
||||
background-color: yellow;
|
||||
}
|
||||
/* #endregion */
|
||||
@@ -1,94 +0,0 @@
|
||||
import { t } from "i18next";
|
||||
import "./FloatingButtons.css";
|
||||
import { useNoteContext, useNoteLabel, useNoteLabelBoolean } from "./react/hooks";
|
||||
import { useContext, useEffect, useMemo, useState } from "preact/hooks";
|
||||
import { ParentComponent } from "./react/react_utils";
|
||||
import { EventData, EventNames } from "../components/app_context";
|
||||
import { type FloatingButtonsList, type FloatingButtonContext } from "./FloatingButtonsDefinitions";
|
||||
import ActionButton from "./react/ActionButton";
|
||||
import { ViewTypeOptions } from "../services/note_list_renderer";
|
||||
|
||||
interface FloatingButtonsProps {
|
||||
items: FloatingButtonsList;
|
||||
}
|
||||
|
||||
/*
|
||||
* Note:
|
||||
*
|
||||
* For floating button widgets that require content to overflow, the has-overflow CSS class should
|
||||
* be applied to the root element of the widget. Additionally, this root element may need to
|
||||
* properly handle rounded corners, as defined by the --border-radius CSS variable.
|
||||
*/
|
||||
export default function FloatingButtons({ items }: FloatingButtonsProps) {
|
||||
const { note, noteContext } = useNoteContext();
|
||||
const parentComponent = useContext(ParentComponent);
|
||||
const [ viewType ] = useNoteLabel(note, "viewType");
|
||||
const [ isReadOnly ] = useNoteLabelBoolean(note, "readOnly");
|
||||
const context = useMemo<FloatingButtonContext | null>(() => {
|
||||
if (!note || !noteContext || !parentComponent) return null;
|
||||
|
||||
return {
|
||||
note,
|
||||
noteContext,
|
||||
parentComponent,
|
||||
isDefaultViewMode: noteContext.viewScope?.viewMode === "default",
|
||||
viewType: viewType as ViewTypeOptions,
|
||||
isReadOnly,
|
||||
triggerEvent<T extends EventNames>(name: T, data?: Omit<EventData<T>, "ntxId">) {
|
||||
parentComponent.triggerEvent(name, {
|
||||
ntxId: noteContext.ntxId,
|
||||
...data
|
||||
} as EventData<T>);
|
||||
}
|
||||
};
|
||||
}, [ note, noteContext, parentComponent, viewType, isReadOnly ]);
|
||||
|
||||
// Manage the user-adjustable visibility of the floating buttons.
|
||||
const [ visible, setVisible ] = useState(true);
|
||||
useEffect(() => setVisible(true), [ note ]);
|
||||
|
||||
return (
|
||||
<div className="floating-buttons no-print">
|
||||
<div className={`floating-buttons-children ${!visible ? "temporarily-hidden" : ""}`}>
|
||||
{context && items.map((Component) => (
|
||||
<Component {...context} />
|
||||
))}
|
||||
|
||||
{visible && <CloseFloatingButton setVisible={setVisible} />}
|
||||
</div>
|
||||
|
||||
{!visible && <ShowFloatingButton setVisible={setVisible} /> }
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Show button that displays floating button after click on close button
|
||||
*/
|
||||
function ShowFloatingButton({ setVisible }: { setVisible(visible: boolean): void }) {
|
||||
return (
|
||||
<div className="show-floating-buttons">
|
||||
<ActionButton
|
||||
className="show-floating-buttons-button"
|
||||
icon="bx bx-chevrons-left"
|
||||
text={t("show_floating_buttons_button.button_title")}
|
||||
onClick={() => setVisible(true)}
|
||||
noIconActionClass
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CloseFloatingButton({ setVisible }: { setVisible(visible: boolean): void }) {
|
||||
return (
|
||||
<div className="close-floating-buttons">
|
||||
<ActionButton
|
||||
className="close-floating-buttons-button"
|
||||
icon="bx bx-chevrons-right"
|
||||
text={t("hide_floating_buttons_button.button_title")}
|
||||
onClick={() => setVisible(false)}
|
||||
noIconActionClass
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,398 +0,0 @@
|
||||
import { VNode } from "preact";
|
||||
import appContext, { EventData, EventNames } from "../components/app_context";
|
||||
import Component from "../components/component";
|
||||
import NoteContext from "../components/note_context";
|
||||
import FNote from "../entities/fnote";
|
||||
import ActionButton, { ActionButtonProps } from "./react/ActionButton";
|
||||
import { useNoteLabelBoolean, useTriliumEvent, useTriliumOption, useWindowSize } from "./react/hooks";
|
||||
import { useEffect, useLayoutEffect, useMemo, useRef, useState } from "preact/hooks";
|
||||
import { createImageSrcUrl, openInAppHelpFromUrl } from "../services/utils";
|
||||
import server from "../services/server";
|
||||
import { BacklinkCountResponse, BacklinksResponse, SaveSqlConsoleResponse } from "@triliumnext/commons";
|
||||
import toast from "../services/toast";
|
||||
import { t } from "../services/i18n";
|
||||
import { copyImageReferenceToClipboard } from "../services/image";
|
||||
import tree from "../services/tree";
|
||||
import protected_session_holder from "../services/protected_session_holder";
|
||||
import options from "../services/options";
|
||||
import { getHelpUrlForNote } from "../services/in_app_help";
|
||||
import froca from "../services/froca";
|
||||
import NoteLink from "./react/NoteLink";
|
||||
import RawHtml from "./react/RawHtml";
|
||||
import { ViewTypeOptions } from "../services/note_list_renderer";
|
||||
|
||||
export interface FloatingButtonContext {
|
||||
parentComponent: Component;
|
||||
note: FNote;
|
||||
noteContext: NoteContext;
|
||||
isDefaultViewMode: boolean;
|
||||
isReadOnly: boolean;
|
||||
/** Shorthand for triggering an event from the parent component. The `ntxId` is automatically handled for convenience. */
|
||||
triggerEvent<T extends EventNames>(name: T, data?: Omit<EventData<T>, "ntxId">): void;
|
||||
viewType?: ViewTypeOptions | null;
|
||||
}
|
||||
|
||||
function FloatingButton({ className, ...props }: ActionButtonProps) {
|
||||
return <ActionButton
|
||||
className={`floating-button ${className ?? ""}`}
|
||||
noIconActionClass
|
||||
{...props}
|
||||
/>
|
||||
}
|
||||
|
||||
export type FloatingButtonsList = ((context: FloatingButtonContext) => false | VNode)[];
|
||||
|
||||
export const DESKTOP_FLOATING_BUTTONS: FloatingButtonsList = [
|
||||
RefreshBackendLogButton,
|
||||
SwitchSplitOrientationButton,
|
||||
ToggleReadOnlyButton,
|
||||
EditButton,
|
||||
ShowTocWidgetButton,
|
||||
ShowHighlightsListWidgetButton,
|
||||
RunActiveNoteButton,
|
||||
OpenTriliumApiDocsButton,
|
||||
SaveToNoteButton,
|
||||
RelationMapButtons,
|
||||
GeoMapButtons,
|
||||
CopyImageReferenceButton,
|
||||
ExportImageButtons,
|
||||
InAppHelpButton,
|
||||
Backlinks
|
||||
];
|
||||
|
||||
export const MOBILE_FLOATING_BUTTONS: FloatingButtonsList = [
|
||||
RefreshBackendLogButton,
|
||||
EditButton,
|
||||
RelationMapButtons,
|
||||
ExportImageButtons,
|
||||
Backlinks
|
||||
]
|
||||
|
||||
function RefreshBackendLogButton({ note, parentComponent, noteContext, isDefaultViewMode }: FloatingButtonContext) {
|
||||
const isEnabled = note.noteId === "_backendLog" && isDefaultViewMode;
|
||||
return isEnabled && <FloatingButton
|
||||
text={t("backend_log.refresh")}
|
||||
icon="bx bx-refresh"
|
||||
onClick={() => parentComponent.triggerEvent("refreshData", { ntxId: noteContext.ntxId })}
|
||||
/>
|
||||
}
|
||||
|
||||
function SwitchSplitOrientationButton({ note, isReadOnly, isDefaultViewMode }: FloatingButtonContext) {
|
||||
const isEnabled = note.type === "mermaid" && note.isContentAvailable() && !isReadOnly && isDefaultViewMode;
|
||||
const [ splitEditorOrientation, setSplitEditorOrientation ] = useTriliumOption("splitEditorOrientation");
|
||||
const upcomingOrientation = splitEditorOrientation === "horizontal" ? "vertical" : "horizontal";
|
||||
|
||||
return isEnabled && <FloatingButton
|
||||
text={upcomingOrientation === "vertical" ? t("switch_layout_button.title_vertical") : t("switch_layout_button.title_horizontal")}
|
||||
icon={upcomingOrientation === "vertical" ? "bx bxs-dock-bottom" : "bx bxs-dock-left"}
|
||||
onClick={() => setSplitEditorOrientation(upcomingOrientation)}
|
||||
/>
|
||||
}
|
||||
|
||||
function ToggleReadOnlyButton({ note, viewType, isDefaultViewMode }: FloatingButtonContext) {
|
||||
const [ isReadOnly, setReadOnly ] = useNoteLabelBoolean(note, "readOnly");
|
||||
const isEnabled = (note.type === "mermaid" || viewType === "geoMap")
|
||||
&& note.isContentAvailable() && isDefaultViewMode;
|
||||
|
||||
return isEnabled && <FloatingButton
|
||||
text={isReadOnly ? t("toggle_read_only_button.unlock-editing") : t("toggle_read_only_button.lock-editing")}
|
||||
icon={isReadOnly ? "bx bx-lock-open-alt" : "bx bx-lock-alt"}
|
||||
onClick={() => setReadOnly(!isReadOnly)}
|
||||
/>
|
||||
}
|
||||
|
||||
function EditButton({ note, noteContext, isDefaultViewMode }: FloatingButtonContext) {
|
||||
const [ animationClass, setAnimationClass ] = useState("");
|
||||
const [ isEnabled, setIsEnabled ] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
noteContext.isReadOnly().then(isReadOnly => {
|
||||
setIsEnabled(
|
||||
isDefaultViewMode
|
||||
&& (!note.isProtected || protected_session_holder.isProtectedSessionAvailable())
|
||||
&& !options.is("databaseReadonly")
|
||||
&& isReadOnly
|
||||
);
|
||||
});
|
||||
}, [ note ]);
|
||||
|
||||
useTriliumEvent("readOnlyTemporarilyDisabled", ({ noteContext: eventNoteContext }) => {
|
||||
if (noteContext?.ntxId === eventNoteContext.ntxId) {
|
||||
setIsEnabled(false);
|
||||
}
|
||||
});
|
||||
|
||||
// make the edit button stand out on the first display, otherwise
|
||||
// it's difficult to notice that the note is readonly
|
||||
useEffect(() => {
|
||||
if (isEnabled) {
|
||||
setAnimationClass("bx-tada bx-lg");
|
||||
setTimeout(() => {
|
||||
setAnimationClass("");
|
||||
}, 1700);
|
||||
}
|
||||
}, [ isEnabled ]);
|
||||
|
||||
return isEnabled && <FloatingButton
|
||||
text={t("edit_button.edit_this_note")}
|
||||
icon="bx bx-pencil"
|
||||
className={animationClass}
|
||||
onClick={() => {
|
||||
if (noteContext.viewScope) {
|
||||
noteContext.viewScope.readOnlyTemporarilyDisabled = true;
|
||||
appContext.triggerEvent("readOnlyTemporarilyDisabled", { noteContext });
|
||||
}
|
||||
}}
|
||||
/>
|
||||
}
|
||||
|
||||
function ShowTocWidgetButton({ note, noteContext, isDefaultViewMode }: FloatingButtonContext) {
|
||||
const [ isEnabled, setIsEnabled ] = useState(false);
|
||||
useTriliumEvent("reEvaluateTocWidgetVisibility", () => {
|
||||
setIsEnabled(note.type === "text" && isDefaultViewMode && !!noteContext.viewScope?.tocTemporarilyHidden);
|
||||
});
|
||||
|
||||
return isEnabled && <FloatingButton
|
||||
text={t("show_toc_widget_button.show_toc")}
|
||||
icon="bx bx-tn-toc"
|
||||
onClick={() => {
|
||||
if (noteContext?.viewScope && noteContext.noteId) {
|
||||
noteContext.viewScope.tocTemporarilyHidden = false;
|
||||
appContext.triggerEvent("showTocWidget", { noteId: noteContext.noteId });
|
||||
}
|
||||
}}
|
||||
/>
|
||||
}
|
||||
|
||||
function ShowHighlightsListWidgetButton({ note, noteContext, isDefaultViewMode }: FloatingButtonContext) {
|
||||
const [ isEnabled, setIsEnabled ] = useState(false);
|
||||
useTriliumEvent("reEvaluateHighlightsListWidgetVisibility", () => {
|
||||
setIsEnabled(note.type === "text" && isDefaultViewMode && !!noteContext.viewScope?.highlightsListTemporarilyHidden);
|
||||
});
|
||||
|
||||
return isEnabled && <FloatingButton
|
||||
text={t("show_highlights_list_widget_button.show_highlights_list")}
|
||||
icon="bx bx-bookmarks"
|
||||
onClick={() => {
|
||||
if (noteContext?.viewScope && noteContext.noteId) {
|
||||
noteContext.viewScope.highlightsListTemporarilyHidden = false;
|
||||
appContext.triggerEvent("showHighlightsListWidget", { noteId: noteContext.noteId });
|
||||
}
|
||||
}}
|
||||
/>
|
||||
}
|
||||
|
||||
function RunActiveNoteButton({ note }: FloatingButtonContext) {
|
||||
const isEnabled = note.mime.startsWith("application/javascript") || note.mime === "text/x-sqlite;schema=trilium";
|
||||
return isEnabled && <FloatingButton
|
||||
icon="bx bx-play"
|
||||
text={t("code_buttons.execute_button_title")}
|
||||
triggerCommand="runActiveNote"
|
||||
/>
|
||||
}
|
||||
|
||||
function OpenTriliumApiDocsButton({ note }: FloatingButtonContext) {
|
||||
const isEnabled = note.mime.startsWith("application/javascript;env=");
|
||||
return isEnabled && <FloatingButton
|
||||
icon="bx bx-help-circle"
|
||||
text={t("code_buttons.trilium_api_docs_button_title")}
|
||||
onClick={() => openInAppHelpFromUrl(note.mime.endsWith("frontend") ? "Q2z6av6JZVWm" : "MEtfsqa5VwNi")}
|
||||
/>
|
||||
}
|
||||
|
||||
function SaveToNoteButton({ note }: FloatingButtonContext) {
|
||||
const isEnabled = note.mime === "text/x-sqlite;schema=trilium" && note.isHiddenCompletely();
|
||||
return isEnabled && <FloatingButton
|
||||
icon="bx bx-save"
|
||||
text={t("code_buttons.save_to_note_button_title")}
|
||||
onClick={async (e) => {
|
||||
e.preventDefault();
|
||||
const { notePath } = await server.post<SaveSqlConsoleResponse>("special-notes/save-sql-console", { sqlConsoleNoteId: note.noteId });
|
||||
if (notePath) {
|
||||
toast.showMessage(t("code_buttons.sql_console_saved_message", { "note_path": await tree.getNotePathTitle(notePath) }));
|
||||
// TODO: This hangs the navigation, for some reason.
|
||||
//await ws.waitForMaxKnownEntityChangeId();
|
||||
await appContext.tabManager.getActiveContext()?.setNote(notePath);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
}
|
||||
|
||||
function RelationMapButtons({ note, triggerEvent }: FloatingButtonContext) {
|
||||
const isEnabled = (note.type === "relationMap");
|
||||
return isEnabled && (
|
||||
<>
|
||||
<FloatingButton
|
||||
icon="bx bx-folder-plus"
|
||||
text={t("relation_map_buttons.create_child_note_title")}
|
||||
onClick={() => triggerEvent("relationMapCreateChildNote")}
|
||||
/>
|
||||
|
||||
<FloatingButton
|
||||
icon="bx bx-crop"
|
||||
text={t("relation_map_buttons.reset_pan_zoom_title")}
|
||||
onClick={() => triggerEvent("relationMapResetPanZoom")}
|
||||
/>
|
||||
|
||||
<div className="btn-group">
|
||||
<FloatingButton
|
||||
icon="bx bx-zoom-in"
|
||||
text={t("relation_map_buttons.zoom_in_title")}
|
||||
onClick={() => triggerEvent("relationMapResetZoomIn")}
|
||||
/>
|
||||
|
||||
<FloatingButton
|
||||
icon="bx bx-zoom-out"
|
||||
text={t("relation_map_buttons.zoom_out_title")}
|
||||
onClick={() => triggerEvent("relationMapResetZoomOut")}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function GeoMapButtons({ triggerEvent, viewType, isReadOnly }: FloatingButtonContext) {
|
||||
const isEnabled = viewType === "geoMap" && !isReadOnly;
|
||||
return isEnabled && (
|
||||
<FloatingButton
|
||||
icon="bx bx-plus-circle"
|
||||
text={t("geo-map.create-child-note-title")}
|
||||
onClick={() => triggerEvent("geoMapCreateChildNote")}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function CopyImageReferenceButton({ note, isDefaultViewMode }: FloatingButtonContext) {
|
||||
const hiddenImageCopyRef = useRef<HTMLDivElement>(null);
|
||||
const isEnabled = ["mermaid", "canvas", "mindMap"].includes(note?.type ?? "")
|
||||
&& note?.isContentAvailable() && isDefaultViewMode;
|
||||
|
||||
return isEnabled && (
|
||||
<>
|
||||
<FloatingButton
|
||||
icon="bx bx-copy"
|
||||
text={t("copy_image_reference_button.button_title")}
|
||||
onClick={() => {
|
||||
if (!hiddenImageCopyRef.current) return;
|
||||
const imageEl = document.createElement("img");
|
||||
imageEl.src = createImageSrcUrl(note);
|
||||
hiddenImageCopyRef.current.replaceChildren(imageEl);
|
||||
copyImageReferenceToClipboard($(hiddenImageCopyRef.current));
|
||||
hiddenImageCopyRef.current.removeChild(imageEl);
|
||||
}}
|
||||
/>
|
||||
|
||||
<div ref={hiddenImageCopyRef} className="hidden-image-copy" style={{
|
||||
position: "absolute" // Take out of the the hidden image from flexbox to prevent the layout being affected
|
||||
}} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function ExportImageButtons({ note, triggerEvent, isDefaultViewMode }: FloatingButtonContext) {
|
||||
const isEnabled = ["mermaid", "mindMap"].includes(note?.type ?? "")
|
||||
&& note?.isContentAvailable() && isDefaultViewMode;
|
||||
return isEnabled && (
|
||||
<>
|
||||
<FloatingButton
|
||||
icon="bx bxs-file-image"
|
||||
text={t("svg_export_button.button_title")}
|
||||
onClick={() => triggerEvent("exportSvg")}
|
||||
/>
|
||||
|
||||
<FloatingButton
|
||||
icon="bx bxs-file-png"
|
||||
text={t("png_export_button.button_title")}
|
||||
onClick={() => triggerEvent("exportPng")}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function InAppHelpButton({ note }: FloatingButtonContext) {
|
||||
const helpUrl = getHelpUrlForNote(note);
|
||||
|
||||
return !!helpUrl && (
|
||||
<FloatingButton
|
||||
icon="bx bx-help-circle"
|
||||
text={t("help-button.title")}
|
||||
onClick={() => helpUrl && openInAppHelpFromUrl(helpUrl)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function Backlinks({ note, isDefaultViewMode }: FloatingButtonContext) {
|
||||
let [ backlinkCount, setBacklinkCount ] = useState(0);
|
||||
let [ popupOpen, setPopupOpen ] = useState(false);
|
||||
const backlinksContainerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isDefaultViewMode) return;
|
||||
|
||||
server.get<BacklinkCountResponse>(`note-map/${note.noteId}/backlink-count`).then(resp => {
|
||||
setBacklinkCount(resp.count);
|
||||
});
|
||||
}, [ note ]);
|
||||
|
||||
// Determine the max height of the container.
|
||||
const { windowHeight } = useWindowSize();
|
||||
useLayoutEffect(() => {
|
||||
const el = backlinksContainerRef.current;
|
||||
if (popupOpen && el) {
|
||||
const box = el.getBoundingClientRect();
|
||||
const maxHeight = windowHeight - box.top - 10;
|
||||
el.style.maxHeight = `${maxHeight}px`;
|
||||
}
|
||||
}, [ popupOpen, windowHeight ]);
|
||||
|
||||
const isEnabled = isDefaultViewMode && backlinkCount > 0;
|
||||
return (isEnabled &&
|
||||
<div className="backlinks-widget has-overflow">
|
||||
<div
|
||||
className="backlinks-ticker"
|
||||
onClick={() => setPopupOpen(!popupOpen)}
|
||||
>
|
||||
<span className="backlinks-count">{t("zpetne_odkazy.backlink", { count: backlinkCount })}</span>
|
||||
</div>
|
||||
|
||||
{popupOpen && (
|
||||
<div ref={backlinksContainerRef} className="backlinks-items dropdown-menu" style={{ display: "block" }}>
|
||||
<BacklinksList noteId={note.noteId} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function BacklinksList({ noteId }: { noteId: string }) {
|
||||
const [ backlinks, setBacklinks ] = useState<BacklinksResponse>([]);
|
||||
|
||||
useEffect(() => {
|
||||
server.get<BacklinksResponse>(`note-map/${noteId}/backlinks`).then(async (backlinks) => {
|
||||
// prefetch all
|
||||
const noteIds = backlinks
|
||||
.filter(bl => "noteId" in bl)
|
||||
.map((bl) => bl.noteId);
|
||||
await froca.getNotes(noteIds);
|
||||
setBacklinks(backlinks);
|
||||
});
|
||||
}, [ noteId ]);
|
||||
|
||||
return backlinks.map(backlink => (
|
||||
<div>
|
||||
<NoteLink
|
||||
notePath={backlink.noteId}
|
||||
showNotePath showNoteIcon
|
||||
noPreview
|
||||
/>
|
||||
|
||||
{"relationName" in backlink ? (
|
||||
<p>{backlink.relationName}</p>
|
||||
) : (
|
||||
backlink.excerpts.map(excerpt => (
|
||||
<RawHtml html={excerpt} />
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
));
|
||||
}
|
||||
@@ -1,28 +0,0 @@
|
||||
.api-log-widget {
|
||||
flex-grow: 1;
|
||||
max-height: 40%;
|
||||
position: relative;
|
||||
border-top: 1px solid var(--main-border-color);
|
||||
background-color: var(--accented-background-color);
|
||||
}
|
||||
|
||||
.api-log-container {
|
||||
overflow: auto;
|
||||
height: 100%;
|
||||
font-family: var(--monospace-font-family);
|
||||
font-size: 0.8em;
|
||||
white-space: pre;
|
||||
padding: 15px;
|
||||
}
|
||||
|
||||
.close-api-log-button {
|
||||
padding: 5px;
|
||||
border: 1px solid var(--button-border-color);
|
||||
background-color: var(--button-background-color);
|
||||
border-radius: var(--button-border-radius);
|
||||
color: var(--button-text-color);
|
||||
position: absolute;
|
||||
top: 10px;
|
||||
right: 10px;
|
||||
cursor: pointer;
|
||||
}
|
||||
80
apps/client/src/widgets/api_log.ts
Normal file
80
apps/client/src/widgets/api_log.ts
Normal file
@@ -0,0 +1,80 @@
|
||||
import type { EventData } from "../components/app_context.js";
|
||||
import type FNote from "../entities/fnote.js";
|
||||
import { t } from "../services/i18n.js";
|
||||
import NoteContextAwareWidget from "./note_context_aware_widget.js";
|
||||
|
||||
const TPL = /*html*/`
|
||||
<div class="api-log-widget">
|
||||
<style>
|
||||
.api-log-widget {
|
||||
padding: 15px;
|
||||
flex-grow: 1;
|
||||
max-height: 40%;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.hidden-api-log {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.api-log-container {
|
||||
overflow: auto;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.close-api-log-button {
|
||||
padding: 5px;
|
||||
border: 1px solid var(--button-border-color);
|
||||
background-color: var(--button-background-color);
|
||||
border-radius: var(--button-border-radius);
|
||||
color: var(--button-text-color);
|
||||
position: absolute;
|
||||
top: 10px;
|
||||
right: 40px;
|
||||
cursor: pointer;
|
||||
}
|
||||
</style>
|
||||
|
||||
<div class="bx bx-x close-api-log-button" title="${t("api_log.close")}"></div>
|
||||
|
||||
<div class="api-log-container"></div>
|
||||
</div>`;
|
||||
|
||||
export default class ApiLogWidget extends NoteContextAwareWidget {
|
||||
|
||||
private $logContainer!: JQuery<HTMLElement>;
|
||||
private $closeButton!: JQuery<HTMLElement>;
|
||||
|
||||
isEnabled() {
|
||||
return !!this.note && this.note.mime.startsWith("application/javascript;env=") && super.isEnabled();
|
||||
}
|
||||
|
||||
doRender() {
|
||||
this.$widget = $(TPL);
|
||||
this.toggle(false);
|
||||
|
||||
this.$logContainer = this.$widget.find(".api-log-container");
|
||||
this.$closeButton = this.$widget.find(".close-api-log-button");
|
||||
this.$closeButton.on("click", () => this.toggle(false));
|
||||
}
|
||||
|
||||
async refreshWithNote(note: FNote) {
|
||||
this.$logContainer.empty();
|
||||
}
|
||||
|
||||
apiLogMessagesEvent({ messages, noteId }: EventData<"apiLogMessages">) {
|
||||
if (!this.isNote(noteId)) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.toggle(true);
|
||||
|
||||
for (const message of messages) {
|
||||
this.$logContainer.append(message).append($("<br>"));
|
||||
}
|
||||
}
|
||||
|
||||
toggle(show: boolean) {
|
||||
this.$widget.toggleClass("hidden-api-log", !show);
|
||||
}
|
||||
}
|
||||
@@ -1,41 +0,0 @@
|
||||
import { useEffect, useState } from "preact/hooks";
|
||||
import "./api_log.css";
|
||||
import { useNoteContext, useTriliumEvent } from "./react/hooks";
|
||||
import ActionButton from "./react/ActionButton";
|
||||
import { t } from "../services/i18n";
|
||||
|
||||
/**
|
||||
* Displays the messages that are logged by the current note via `api.log`, for frontend and backend scripts.
|
||||
*/
|
||||
export default function ApiLog() {
|
||||
const { note, noteId } = useNoteContext();
|
||||
const [ messages, setMessages ] = useState<string[]>();
|
||||
|
||||
useTriliumEvent("apiLogMessages", ({ messages, noteId: eventNoteId }) => {
|
||||
if (eventNoteId !== noteId) return;
|
||||
setMessages(messages);
|
||||
});
|
||||
|
||||
// Clear when navigating away.
|
||||
useEffect(() => setMessages(undefined), [ note ]);
|
||||
|
||||
const isEnabled = note?.mime.startsWith("application/javascript;env=") && messages?.length;
|
||||
return (
|
||||
<div className={`api-log-widget ${!isEnabled ? "hidden-ext" : ""}`}>
|
||||
{isEnabled && (
|
||||
<>
|
||||
<ActionButton
|
||||
icon="bx bx-x"
|
||||
className="close-api-log-button"
|
||||
text={t("api_log.close")}
|
||||
onClick={() => setMessages(undefined)}
|
||||
/>
|
||||
|
||||
<div className="api-log-container">
|
||||
{messages.join("\n")}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -9,6 +9,9 @@ import contentRenderer from "../services/content_renderer.js";
|
||||
import toastService from "../services/toast.js";
|
||||
import type FAttachment from "../entities/fattachment.js";
|
||||
import type { EventData } from "../components/app_context.js";
|
||||
import appContext from "../components/app_context.js";
|
||||
import mediaViewer from "../services/media_viewer.js";
|
||||
import type { MediaItem } from "../services/media_viewer.js";
|
||||
|
||||
const TPL = /*html*/`
|
||||
<div class="attachment-detail-widget">
|
||||
@@ -65,6 +68,12 @@ const TPL = /*html*/`
|
||||
|
||||
.attachment-content-wrapper img {
|
||||
margin: 10px;
|
||||
cursor: zoom-in;
|
||||
transition: opacity 0.2s;
|
||||
}
|
||||
|
||||
.attachment-content-wrapper img:hover {
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
.attachment-detail-wrapper.list-view .attachment-content-wrapper img, .attachment-detail-wrapper.list-view .attachment-content-wrapper video {
|
||||
@@ -77,6 +86,24 @@ const TPL = /*html*/`
|
||||
max-width: 90%;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.attachment-lightbox-hint {
|
||||
position: absolute;
|
||||
top: 10px;
|
||||
right: 10px;
|
||||
background: rgba(0, 0, 0, 0.7);
|
||||
color: white;
|
||||
padding: 5px 10px;
|
||||
border-radius: 4px;
|
||||
font-size: 12px;
|
||||
opacity: 0;
|
||||
transition: opacity 0.3s;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.attachment-content-wrapper:hover .attachment-lightbox-hint {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.attachment-detail-wrapper.scheduled-for-deletion .attachment-content-wrapper img {
|
||||
filter: contrast(10%);
|
||||
@@ -88,7 +115,9 @@ const TPL = /*html*/`
|
||||
<div class="attachment-actions-container"></div>
|
||||
<h4 class="attachment-title"></h4>
|
||||
<div class="attachment-details"></div>
|
||||
<div style="flex: 1 1;"></div>
|
||||
<button class="btn btn-sm back-to-note-btn" style="margin-left: auto;" title="Back to Note">
|
||||
<span class="bx bx-arrow-back"></span> Back to Note
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="attachment-deletion-warning alert alert-info" style="margin-top: 15px;"></div>
|
||||
@@ -124,6 +153,14 @@ export default class AttachmentDetailWidget extends BasicWidget {
|
||||
this.$widget.find(".attachment-detail-wrapper").empty().append($(TPL).find(".attachment-detail-wrapper").html());
|
||||
this.$wrapper = this.$widget.find(".attachment-detail-wrapper");
|
||||
this.$wrapper.addClass(this.isFullDetail ? "full-detail" : "list-view");
|
||||
|
||||
// Setup back to note button (only show in full detail mode)
|
||||
if (this.isFullDetail) {
|
||||
const $backBtn = this.$wrapper.find('.back-to-note-btn');
|
||||
$backBtn.on('click', () => this.handleBackToNote());
|
||||
} else {
|
||||
this.$wrapper.find('.back-to-note-btn').hide();
|
||||
}
|
||||
|
||||
if (!this.isFullDetail) {
|
||||
const $link = await linkService.createLink(this.attachment.ownerId, {
|
||||
@@ -170,7 +207,92 @@ export default class AttachmentDetailWidget extends BasicWidget {
|
||||
this.$wrapper.find(".attachment-actions-container").append(this.attachmentActionsWidget.render());
|
||||
|
||||
const { $renderedContent } = await contentRenderer.getRenderedContent(this.attachment, { imageHasZoom: this.isFullDetail });
|
||||
this.$wrapper.find(".attachment-content-wrapper").append($renderedContent);
|
||||
const $contentWrapper = this.$wrapper.find(".attachment-content-wrapper");
|
||||
$contentWrapper.append($renderedContent);
|
||||
|
||||
// Add PhotoSwipe integration for image attachments
|
||||
if (this.attachment.role === 'image') {
|
||||
this.setupPhotoSwipeIntegration($contentWrapper);
|
||||
}
|
||||
}
|
||||
|
||||
setupPhotoSwipeIntegration($contentWrapper: JQuery<HTMLElement>) {
|
||||
// Add lightbox hint
|
||||
const $hint = $('<div class="attachment-lightbox-hint">Click to view in lightbox</div>');
|
||||
$contentWrapper.css('position', 'relative').append($hint);
|
||||
|
||||
// Find the image element
|
||||
const $img = $contentWrapper.find('img');
|
||||
if (!$img.length) return;
|
||||
|
||||
// Setup click handler for lightbox with namespace for proper cleanup
|
||||
$img.off('click.photoswipe').on('click.photoswipe', (e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
const item: MediaItem = {
|
||||
src: $img.attr('src') || '',
|
||||
alt: this.attachment.title,
|
||||
title: this.attachment.title,
|
||||
noteId: this.attachment.ownerId,
|
||||
element: $img[0] as HTMLElement
|
||||
};
|
||||
|
||||
// Try to get actual dimensions
|
||||
const imgElement = $img[0] as HTMLImageElement;
|
||||
if (imgElement.naturalWidth && imgElement.naturalHeight) {
|
||||
item.width = imgElement.naturalWidth;
|
||||
item.height = imgElement.naturalHeight;
|
||||
}
|
||||
|
||||
mediaViewer.openSingle(item, {
|
||||
bgOpacity: 0.95,
|
||||
showHideOpacity: true,
|
||||
pinchToClose: true,
|
||||
closeOnScroll: false,
|
||||
closeOnVerticalDrag: true,
|
||||
wheelToZoom: true,
|
||||
getThumbBoundsFn: () => {
|
||||
// Get position for zoom animation
|
||||
const rect = imgElement.getBoundingClientRect();
|
||||
return {
|
||||
x: rect.left,
|
||||
y: rect.top,
|
||||
w: rect.width
|
||||
};
|
||||
}
|
||||
}, {
|
||||
onOpen: () => {
|
||||
console.log('Attachment image opened in lightbox');
|
||||
},
|
||||
onClose: () => {
|
||||
// Check if we're in attachment detail view and reset viewScope if needed
|
||||
const activeContext = appContext.tabManager.getActiveContext();
|
||||
if (activeContext?.viewScope?.viewMode === 'attachments' &&
|
||||
activeContext?.viewScope?.attachmentId === this.attachment.attachmentId) {
|
||||
// Reset to normal note view when closing lightbox from attachment detail
|
||||
activeContext.setNote(this.attachment.ownerId, {
|
||||
viewScope: { viewMode: 'default' }
|
||||
});
|
||||
}
|
||||
// Restore focus to the image
|
||||
$img.focus();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Add keyboard support
|
||||
$img.attr('tabindex', '0')
|
||||
.attr('role', 'button')
|
||||
.attr('aria-label', 'Click to view in lightbox');
|
||||
|
||||
// Use namespaced event for proper cleanup
|
||||
$img.off('keydown.photoswipe').on('keydown.photoswipe', (e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
$img.trigger('click');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async copyAttachmentLinkToClipboard() {
|
||||
@@ -204,4 +326,43 @@ 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();
|
||||
}
|
||||
}
|
||||
|
||||
504
apps/client/src/widgets/attribute_widgets/attribute_editor.ts
Normal file
504
apps/client/src/widgets/attribute_widgets/attribute_editor.ts
Normal file
@@ -0,0 +1,504 @@
|
||||
import { t } from "../../services/i18n.js";
|
||||
import NoteContextAwareWidget from "../note_context_aware_widget.js";
|
||||
import noteAutocompleteService, { type Suggestion } from "../../services/note_autocomplete.js";
|
||||
import server from "../../services/server.js";
|
||||
import contextMenuService from "../../menus/context_menu.js";
|
||||
import attributeParser, { type Attribute } from "../../services/attribute_parser.js";
|
||||
import { AttributeEditor, type EditorConfig, type ModelElement, type MentionFeed, type ModelNode, type ModelPosition } from "@triliumnext/ckeditor5";
|
||||
import froca from "../../services/froca.js";
|
||||
import attributeRenderer from "../../services/attribute_renderer.js";
|
||||
import noteCreateService from "../../services/note_create.js";
|
||||
import attributeService from "../../services/attributes.js";
|
||||
import linkService from "../../services/link.js";
|
||||
import type AttributeDetailWidget from "./attribute_detail.js";
|
||||
import type { CommandData, EventData, EventListener, FilteredCommandNames } from "../../components/app_context.js";
|
||||
import type { default as FAttribute, AttributeType } from "../../entities/fattribute.js";
|
||||
import type FNote from "../../entities/fnote.js";
|
||||
import { escapeQuotes } from "../../services/utils.js";
|
||||
|
||||
const HELP_TEXT = `
|
||||
<p>${t("attribute_editor.help_text_body1")}</p>
|
||||
|
||||
<p>${t("attribute_editor.help_text_body2")}</p>
|
||||
|
||||
<p>${t("attribute_editor.help_text_body3")}</p>`;
|
||||
|
||||
const TPL = /*html*/`
|
||||
<div style="position: relative; padding-top: 10px; padding-bottom: 10px">
|
||||
<style>
|
||||
.attribute-list-editor {
|
||||
border: 0 !important;
|
||||
outline: 0 !important;
|
||||
box-shadow: none !important;
|
||||
padding: 0 0 0 5px !important;
|
||||
margin: 0 !important;
|
||||
max-height: 100px;
|
||||
overflow: auto;
|
||||
transition: opacity .1s linear;
|
||||
}
|
||||
|
||||
.attribute-list-editor.ck-content .mention {
|
||||
color: var(--muted-text-color) !important;
|
||||
background: transparent !important;
|
||||
}
|
||||
|
||||
.save-attributes-button {
|
||||
color: var(--muted-text-color);
|
||||
position: absolute;
|
||||
bottom: 14px;
|
||||
right: 25px;
|
||||
cursor: pointer;
|
||||
border: 1px solid transparent;
|
||||
font-size: 130%;
|
||||
}
|
||||
|
||||
.add-new-attribute-button {
|
||||
color: var(--muted-text-color);
|
||||
position: absolute;
|
||||
bottom: 13px;
|
||||
right: 0;
|
||||
cursor: pointer;
|
||||
border: 1px solid transparent;
|
||||
font-size: 130%;
|
||||
}
|
||||
|
||||
.add-new-attribute-button:hover, .save-attributes-button:hover {
|
||||
border: 1px solid var(--button-border-color);
|
||||
border-radius: var(--button-border-radius);
|
||||
background: var(--button-background-color);
|
||||
color: var(--button-text-color);
|
||||
}
|
||||
|
||||
.attribute-errors {
|
||||
color: red;
|
||||
padding: 5px 50px 0px 5px; /* large right padding to avoid buttons */
|
||||
}
|
||||
</style>
|
||||
|
||||
<div class="attribute-list-editor" tabindex="200"></div>
|
||||
|
||||
<div class="bx bx-save save-attributes-button tn-tool-button" title="${escapeQuotes(t("attribute_editor.save_attributes"))}"></div>
|
||||
<div class="bx bx-plus add-new-attribute-button tn-tool-button" title="${escapeQuotes(t("attribute_editor.add_a_new_attribute"))}"></div>
|
||||
|
||||
<div class="attribute-errors" style="display: none;"></div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
const mentionSetup: MentionFeed[] = [
|
||||
{
|
||||
marker: "@",
|
||||
feed: (queryText) => noteAutocompleteService.autocompleteSourceForCKEditor(queryText),
|
||||
itemRenderer: (_item) => {
|
||||
const item = _item as Suggestion;
|
||||
const itemElement = document.createElement("button");
|
||||
|
||||
itemElement.innerHTML = `${item.highlightedNotePathTitle} `;
|
||||
|
||||
return itemElement;
|
||||
},
|
||||
minimumCharacters: 0
|
||||
},
|
||||
{
|
||||
marker: "#",
|
||||
feed: async (queryText) => {
|
||||
const names = await server.get<string[]>(`attribute-names/?type=label&query=${encodeURIComponent(queryText)}`);
|
||||
|
||||
return names.map((name) => {
|
||||
return {
|
||||
id: `#${name}`,
|
||||
name: name
|
||||
};
|
||||
});
|
||||
},
|
||||
minimumCharacters: 0
|
||||
},
|
||||
{
|
||||
marker: "~",
|
||||
feed: async (queryText) => {
|
||||
const names = await server.get<string[]>(`attribute-names/?type=relation&query=${encodeURIComponent(queryText)}`);
|
||||
|
||||
return names.map((name) => {
|
||||
return {
|
||||
id: `~${name}`,
|
||||
name: name
|
||||
};
|
||||
});
|
||||
},
|
||||
minimumCharacters: 0
|
||||
}
|
||||
];
|
||||
|
||||
const editorConfig: EditorConfig = {
|
||||
toolbar: {
|
||||
items: []
|
||||
},
|
||||
placeholder: t("attribute_editor.placeholder"),
|
||||
mention: {
|
||||
feeds: mentionSetup
|
||||
},
|
||||
licenseKey: "GPL"
|
||||
};
|
||||
|
||||
type AttributeCommandNames = FilteredCommandNames<CommandData>;
|
||||
|
||||
export default class AttributeEditorWidget extends NoteContextAwareWidget implements EventListener<"entitiesReloaded">, EventListener<"addNewLabel">, EventListener<"addNewRelation"> {
|
||||
private attributeDetailWidget: AttributeDetailWidget;
|
||||
private $editor!: JQuery<HTMLElement>;
|
||||
private $addNewAttributeButton!: JQuery<HTMLElement>;
|
||||
private $saveAttributesButton!: JQuery<HTMLElement>;
|
||||
private $errors!: JQuery<HTMLElement>;
|
||||
|
||||
private textEditor!: AttributeEditor;
|
||||
private lastUpdatedNoteId!: string | undefined;
|
||||
private lastSavedContent!: string;
|
||||
|
||||
constructor(attributeDetailWidget: AttributeDetailWidget) {
|
||||
super();
|
||||
|
||||
this.attributeDetailWidget = attributeDetailWidget;
|
||||
}
|
||||
|
||||
doRender() {
|
||||
this.$widget = $(TPL);
|
||||
this.$editor = this.$widget.find(".attribute-list-editor");
|
||||
|
||||
this.initialized = this.initEditor();
|
||||
|
||||
this.$editor.on("keydown", async (e) => {
|
||||
if (e.which === 13) {
|
||||
// allow autocomplete to fill the result textarea
|
||||
setTimeout(() => this.save(), 100);
|
||||
}
|
||||
|
||||
this.attributeDetailWidget.hide();
|
||||
});
|
||||
|
||||
this.$editor.on("blur", () => setTimeout(() => this.save(), 100)); // Timeout to fix https://github.com/zadam/trilium/issues/4160
|
||||
|
||||
this.$addNewAttributeButton = this.$widget.find(".add-new-attribute-button");
|
||||
this.$addNewAttributeButton.on("click", (e) => this.addNewAttribute(e));
|
||||
|
||||
this.$saveAttributesButton = this.$widget.find(".save-attributes-button");
|
||||
this.$saveAttributesButton.on("click", () => this.save());
|
||||
|
||||
this.$errors = this.$widget.find(".attribute-errors");
|
||||
}
|
||||
|
||||
addNewAttribute(e: JQuery.ClickEvent) {
|
||||
contextMenuService.show<AttributeCommandNames>({
|
||||
x: e.pageX,
|
||||
y: e.pageY,
|
||||
orientation: "left",
|
||||
items: [
|
||||
{ title: t("attribute_editor.add_new_label"), command: "addNewLabel", uiIcon: "bx bx-hash" },
|
||||
{ title: t("attribute_editor.add_new_relation"), command: "addNewRelation", uiIcon: "bx bx-transfer" },
|
||||
{ title: "----" },
|
||||
{ title: t("attribute_editor.add_new_label_definition"), command: "addNewLabelDefinition", uiIcon: "bx bx-empty" },
|
||||
{ title: t("attribute_editor.add_new_relation_definition"), command: "addNewRelationDefinition", uiIcon: "bx bx-empty" }
|
||||
],
|
||||
selectMenuItemHandler: ({ command }) => this.handleAddNewAttributeCommand(command)
|
||||
});
|
||||
// Prevent automatic hiding of the context menu due to the button being clicked.
|
||||
e.stopPropagation();
|
||||
}
|
||||
|
||||
// triggered from keyboard shortcut
|
||||
async addNewLabelEvent({ ntxId }: EventData<"addNewLabel">) {
|
||||
if (this.isNoteContext(ntxId)) {
|
||||
await this.refresh();
|
||||
|
||||
this.handleAddNewAttributeCommand("addNewLabel");
|
||||
}
|
||||
}
|
||||
|
||||
// triggered from keyboard shortcut
|
||||
async addNewRelationEvent({ ntxId }: EventData<"addNewRelation">) {
|
||||
if (this.isNoteContext(ntxId)) {
|
||||
await this.refresh();
|
||||
|
||||
this.handleAddNewAttributeCommand("addNewRelation");
|
||||
}
|
||||
}
|
||||
|
||||
async handleAddNewAttributeCommand(command: AttributeCommandNames | undefined) {
|
||||
// TODO: Not sure what the relation between FAttribute[] and Attribute[] is.
|
||||
const attrs = this.parseAttributes() as FAttribute[];
|
||||
|
||||
if (!attrs) {
|
||||
return;
|
||||
}
|
||||
|
||||
let type: AttributeType;
|
||||
let name;
|
||||
let value;
|
||||
|
||||
if (command === "addNewLabel") {
|
||||
type = "label";
|
||||
name = "myLabel";
|
||||
value = "";
|
||||
} else if (command === "addNewRelation") {
|
||||
type = "relation";
|
||||
name = "myRelation";
|
||||
value = "";
|
||||
} else if (command === "addNewLabelDefinition") {
|
||||
type = "label";
|
||||
name = "label:myLabel";
|
||||
value = "promoted,single,text";
|
||||
} else if (command === "addNewRelationDefinition") {
|
||||
type = "label";
|
||||
name = "relation:myRelation";
|
||||
value = "promoted,single";
|
||||
} else {
|
||||
return;
|
||||
}
|
||||
|
||||
// TODO: Incomplete type
|
||||
//@ts-ignore
|
||||
attrs.push({
|
||||
type,
|
||||
name,
|
||||
value,
|
||||
isInheritable: false
|
||||
});
|
||||
|
||||
await this.renderOwnedAttributes(attrs, false);
|
||||
|
||||
this.$editor.scrollTop(this.$editor[0].scrollHeight);
|
||||
|
||||
const rect = this.$editor[0].getBoundingClientRect();
|
||||
|
||||
setTimeout(() => {
|
||||
// showing a little bit later because there's a conflict with outside click closing the attr detail
|
||||
this.attributeDetailWidget.showAttributeDetail({
|
||||
allAttributes: attrs,
|
||||
attribute: attrs[attrs.length - 1],
|
||||
isOwned: true,
|
||||
x: (rect.left + rect.right) / 2,
|
||||
y: rect.bottom,
|
||||
focus: "name"
|
||||
});
|
||||
}, 100);
|
||||
}
|
||||
|
||||
async save() {
|
||||
if (this.lastUpdatedNoteId !== this.noteId) {
|
||||
// https://github.com/zadam/trilium/issues/3090
|
||||
console.warn("Ignoring blur event because a different note is loaded.");
|
||||
return;
|
||||
}
|
||||
|
||||
const attributes = this.parseAttributes();
|
||||
|
||||
if (attributes) {
|
||||
await server.put(`notes/${this.noteId}/attributes`, attributes, this.componentId);
|
||||
|
||||
this.$saveAttributesButton.fadeOut();
|
||||
|
||||
// blink the attribute text to give a visual hint that save has been executed
|
||||
this.$editor.css("opacity", 0);
|
||||
|
||||
// revert back
|
||||
setTimeout(() => this.$editor.css("opacity", 1), 100);
|
||||
}
|
||||
}
|
||||
|
||||
parseAttributes() {
|
||||
try {
|
||||
return attributeParser.lexAndParse(this.getPreprocessedData());
|
||||
} catch (e: any) {
|
||||
this.$errors.text(e.message).slideDown();
|
||||
}
|
||||
}
|
||||
|
||||
getPreprocessedData() {
|
||||
const str = this.textEditor
|
||||
.getData()
|
||||
.replace(/<a[^>]+href="(#[A-Za-z0-9_/]*)"[^>]*>[^<]*<\/a>/g, "$1")
|
||||
.replace(/ /g, " "); // otherwise .text() below outputs non-breaking space in unicode
|
||||
|
||||
return $("<div>").html(str).text();
|
||||
}
|
||||
|
||||
async initEditor() {
|
||||
this.$widget.show();
|
||||
|
||||
this.$editor.on("click", (e) => this.handleEditorClick(e));
|
||||
|
||||
this.textEditor = await AttributeEditor.create(this.$editor[0], editorConfig);
|
||||
this.textEditor.model.document.on("change:data", () => this.dataChanged());
|
||||
this.textEditor.editing.view.document.on(
|
||||
"enter",
|
||||
(event, data) => {
|
||||
// disable entering new line - see https://github.com/ckeditor/ckeditor5/issues/9422
|
||||
data.preventDefault();
|
||||
event.stop();
|
||||
},
|
||||
{ priority: "high" }
|
||||
);
|
||||
|
||||
// disable spellcheck for attribute editor
|
||||
const documentRoot = this.textEditor.editing.view.document.getRoot();
|
||||
if (documentRoot) {
|
||||
this.textEditor.editing.view.change((writer) => writer.setAttribute("spellcheck", "false", documentRoot));
|
||||
}
|
||||
}
|
||||
|
||||
dataChanged() {
|
||||
this.lastUpdatedNoteId = this.noteId;
|
||||
|
||||
if (this.lastSavedContent === this.textEditor.getData()) {
|
||||
this.$saveAttributesButton.fadeOut();
|
||||
} else {
|
||||
this.$saveAttributesButton.fadeIn();
|
||||
}
|
||||
|
||||
if (this.$errors.is(":visible")) {
|
||||
// using .hide() instead of .slideUp() since this will also hide the error after confirming
|
||||
// mention for relation name which suits up. When using.slideUp() error will appear and the slideUp which is weird
|
||||
this.$errors.hide();
|
||||
}
|
||||
}
|
||||
|
||||
async handleEditorClick(e: JQuery.ClickEvent) {
|
||||
const pos = this.textEditor.model.document.selection.getFirstPosition();
|
||||
|
||||
if (pos && pos.textNode && pos.textNode.data) {
|
||||
const clickIndex = this.getClickIndex(pos);
|
||||
|
||||
let parsedAttrs;
|
||||
|
||||
try {
|
||||
parsedAttrs = attributeParser.lexAndParse(this.getPreprocessedData(), true);
|
||||
} catch (e) {
|
||||
// the input is incorrect because the user messed up with it and now needs to fix it manually
|
||||
return null;
|
||||
}
|
||||
|
||||
let matchedAttr: Attribute | null = null;
|
||||
|
||||
for (const attr of parsedAttrs) {
|
||||
if (attr.startIndex && clickIndex > attr.startIndex && attr.endIndex && clickIndex <= attr.endIndex) {
|
||||
matchedAttr = attr;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
setTimeout(() => {
|
||||
if (matchedAttr) {
|
||||
this.$editor.tooltip("hide");
|
||||
|
||||
this.attributeDetailWidget.showAttributeDetail({
|
||||
allAttributes: parsedAttrs,
|
||||
attribute: matchedAttr,
|
||||
isOwned: true,
|
||||
x: e.pageX,
|
||||
y: e.pageY
|
||||
});
|
||||
} else {
|
||||
this.showHelpTooltip();
|
||||
}
|
||||
}, 100);
|
||||
} else {
|
||||
this.showHelpTooltip();
|
||||
}
|
||||
}
|
||||
|
||||
showHelpTooltip() {
|
||||
this.attributeDetailWidget.hide();
|
||||
|
||||
this.$editor.tooltip({
|
||||
trigger: "focus",
|
||||
html: true,
|
||||
title: HELP_TEXT,
|
||||
placement: "bottom",
|
||||
offset: "0,30"
|
||||
});
|
||||
|
||||
this.$editor.tooltip("show");
|
||||
}
|
||||
|
||||
getClickIndex(pos: ModelPosition) {
|
||||
let clickIndex = pos.offset - (pos.textNode?.startOffset ?? 0);
|
||||
|
||||
let curNode: ModelNode | Text | ModelElement | null = pos.textNode;
|
||||
|
||||
while (curNode?.previousSibling) {
|
||||
curNode = curNode.previousSibling;
|
||||
|
||||
if ((curNode as ModelElement).name === "reference") {
|
||||
clickIndex += (curNode.getAttribute("href") as string).length + 1;
|
||||
} else if ("data" in curNode) {
|
||||
clickIndex += (curNode.data as string).length;
|
||||
}
|
||||
}
|
||||
|
||||
return clickIndex;
|
||||
}
|
||||
|
||||
async loadReferenceLinkTitle($el: JQuery<HTMLElement>, href: string) {
|
||||
const { noteId } = linkService.parseNavigationStateFromUrl(href);
|
||||
const note = noteId ? await froca.getNote(noteId, true) : null;
|
||||
const title = note ? note.title : "[missing]";
|
||||
|
||||
$el.text(title);
|
||||
}
|
||||
|
||||
async refreshWithNote(note: FNote) {
|
||||
await this.renderOwnedAttributes(note.getOwnedAttributes(), true);
|
||||
}
|
||||
|
||||
async renderOwnedAttributes(ownedAttributes: FAttribute[], saved: boolean) {
|
||||
// attrs are not resorted if position changes after the initial load
|
||||
ownedAttributes.sort((a, b) => a.position - b.position);
|
||||
|
||||
let htmlAttrs = (await attributeRenderer.renderAttributes(ownedAttributes, true)).html();
|
||||
|
||||
if (htmlAttrs.length > 0) {
|
||||
htmlAttrs += " ";
|
||||
}
|
||||
|
||||
this.textEditor.setData(htmlAttrs);
|
||||
|
||||
if (saved) {
|
||||
this.lastSavedContent = this.textEditor.getData();
|
||||
|
||||
this.$saveAttributesButton.fadeOut(0);
|
||||
}
|
||||
}
|
||||
|
||||
async createNoteForReferenceLink(title: string) {
|
||||
let result;
|
||||
if (this.notePath) {
|
||||
result = await noteCreateService.createNoteWithTypePrompt(this.notePath, {
|
||||
activate: false,
|
||||
title: title
|
||||
});
|
||||
}
|
||||
|
||||
return result?.note?.getBestNotePathString();
|
||||
}
|
||||
|
||||
async updateAttributeList(attributes: FAttribute[]) {
|
||||
await this.renderOwnedAttributes(attributes, false);
|
||||
}
|
||||
|
||||
focus() {
|
||||
this.$editor.trigger("focus");
|
||||
|
||||
this.textEditor.model.change((writer) => {
|
||||
const documentRoot = this.textEditor.editing.model.document.getRoot();
|
||||
if (!documentRoot) {
|
||||
return;
|
||||
}
|
||||
|
||||
const positionAt = writer.createPositionAt(documentRoot, "end");
|
||||
writer.setSelection(positionAt);
|
||||
});
|
||||
}
|
||||
|
||||
entitiesReloadedEvent({ loadResults }: EventData<"entitiesReloaded">) {
|
||||
if (loadResults.getAttributeRows(this.componentId).find((attr) => attributeService.isAffecting(attr, this.note))) {
|
||||
this.refresh();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,11 +1,7 @@
|
||||
import { isValidElement, VNode } from "preact";
|
||||
import Component, { TypedComponent } from "../components/component.js";
|
||||
import froca from "../services/froca.js";
|
||||
import { t } from "../services/i18n.js";
|
||||
import toastService from "../services/toast.js";
|
||||
import { renderReactWidget } from "./react/react_utils.jsx";
|
||||
import { EventNames, EventData } from "../components/app_context.js";
|
||||
import { Handler } from "leaflet";
|
||||
|
||||
export class TypedBasicWidget<T extends TypedComponent<any>> extends TypedComponent<T> {
|
||||
protected attrs: Record<string, string>;
|
||||
@@ -26,14 +22,11 @@ export class TypedBasicWidget<T extends TypedComponent<any>> extends TypedCompon
|
||||
this.childPositionCounter = 10;
|
||||
}
|
||||
|
||||
child(..._components: (T | VNode)[]) {
|
||||
if (!_components) {
|
||||
child(...components: T[]) {
|
||||
if (!components) {
|
||||
return this;
|
||||
}
|
||||
|
||||
// Convert any React components to legacy wrapped components.
|
||||
const components = wrapReactWidgets(_components);
|
||||
|
||||
super.child(...components);
|
||||
|
||||
for (const component of components) {
|
||||
@@ -55,7 +48,7 @@ export class TypedBasicWidget<T extends TypedComponent<any>> extends TypedCompon
|
||||
* @param components the components to be added as children to this component provided the condition is truthy.
|
||||
* @returns self for chaining.
|
||||
*/
|
||||
optChild(condition: boolean, ...components: (T | VNode)[]) {
|
||||
optChild(condition: boolean, ...components: T[]) {
|
||||
if (condition) {
|
||||
return this.child(...components);
|
||||
} else {
|
||||
@@ -265,30 +258,3 @@ export class TypedBasicWidget<T extends TypedComponent<any>> extends TypedCompon
|
||||
* For information on using widgets, see the tutorial {@tutorial widget_basics}.
|
||||
*/
|
||||
export default class BasicWidget extends TypedBasicWidget<Component> {}
|
||||
|
||||
export function wrapReactWidgets<T extends TypedComponent<any>>(components: (T | VNode)[]) {
|
||||
const wrappedResult: T[] = [];
|
||||
for (const component of components) {
|
||||
if (isValidElement(component)) {
|
||||
wrappedResult.push(new ReactWrappedWidget(component) as unknown as T);
|
||||
} else {
|
||||
wrappedResult.push(component);
|
||||
}
|
||||
}
|
||||
return wrappedResult;
|
||||
}
|
||||
|
||||
export class ReactWrappedWidget extends BasicWidget {
|
||||
|
||||
private el: VNode;
|
||||
|
||||
constructor(el: VNode) {
|
||||
super();
|
||||
this.el = el;
|
||||
}
|
||||
|
||||
doRender() {
|
||||
this.$widget = renderReactWidget(this, this.el);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
54
apps/client/src/widgets/bookmark_switch.ts
Normal file
54
apps/client/src/widgets/bookmark_switch.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
import SwitchWidget from "./switch.js";
|
||||
import server from "../services/server.js";
|
||||
import toastService from "../services/toast.js";
|
||||
import { t } from "../services/i18n.js";
|
||||
import type FNote from "../entities/fnote.js";
|
||||
import type { EventData } from "../components/app_context.js";
|
||||
|
||||
// TODO: Deduplicate
|
||||
type Response = {
|
||||
success: true;
|
||||
} | {
|
||||
success: false;
|
||||
message: string;
|
||||
}
|
||||
|
||||
export default class BookmarkSwitchWidget extends SwitchWidget {
|
||||
isEnabled() {
|
||||
return (
|
||||
super.isEnabled() &&
|
||||
// it's not possible to bookmark root because that would clone it under bookmarks and thus create a cycle
|
||||
!["root", "_hidden"].includes(this.noteId ?? "")
|
||||
);
|
||||
}
|
||||
|
||||
doRender() {
|
||||
super.doRender();
|
||||
|
||||
this.switchOnName = t("bookmark_switch.bookmark");
|
||||
this.switchOnTooltip = t("bookmark_switch.bookmark_this_note");
|
||||
|
||||
this.switchOffName = t("bookmark_switch.bookmark");
|
||||
this.switchOffTooltip = t("bookmark_switch.remove_bookmark");
|
||||
}
|
||||
|
||||
async toggle(state: boolean | null | undefined) {
|
||||
const resp = await server.put<Response>(`notes/${this.noteId}/toggle-in-parent/_lbBookmarks/${!!state}`);
|
||||
|
||||
if (!resp.success && "message" in resp) {
|
||||
toastService.showError(resp.message);
|
||||
}
|
||||
}
|
||||
|
||||
async refreshWithNote(note: FNote) {
|
||||
const isBookmarked = !!note.getParentBranches().find((b) => b.parentNoteId === "_lbBookmarks");
|
||||
|
||||
this.isToggled = isBookmarked;
|
||||
}
|
||||
|
||||
entitiesReloadedEvent({ loadResults }: EventData<"entitiesReloaded">) {
|
||||
if (loadResults.getBranchRows().find((b) => b.noteId === this.noteId)) {
|
||||
this.refresh();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,6 @@
|
||||
import { ComponentChildren } from "preact";
|
||||
import { memo } from "preact/compat";
|
||||
import AbstractBulkAction from "./abstract_bulk_action";
|
||||
import HelpRemoveButtons from "../react/HelpRemoveButtons";
|
||||
|
||||
interface BulkActionProps {
|
||||
label: string | ComponentChildren;
|
||||
@@ -25,11 +24,19 @@ const BulkAction = memo(({ label, children, helpText, bulkAction }: BulkActionPr
|
||||
{children}
|
||||
</div>
|
||||
</td>
|
||||
<HelpRemoveButtons
|
||||
help={helpText}
|
||||
removeText="Delete"
|
||||
onRemove={() => bulkAction?.deleteAction()}
|
||||
/>
|
||||
<td className="button-column">
|
||||
{helpText && <div className="dropdown help-dropdown">
|
||||
<span className="bx bx-help-circle icon-action" data-bs-toggle="dropdown" aria-haspopup="true" aria-expanded="false"></span>
|
||||
<div className="dropdown-menu dropdown-menu-right p-4">
|
||||
{helpText}
|
||||
</div>
|
||||
</div>}
|
||||
|
||||
<span
|
||||
className="bx bx-x icon-action action-conf-del"
|
||||
onClick={() => bulkAction?.deleteAction()}
|
||||
/>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
});
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import { t } from "../../../services/i18n.js";
|
||||
import AbstractBulkAction from "../abstract_bulk_action.js";
|
||||
import AbstractBulkAction, { ActionDefinition } from "../abstract_bulk_action.js";
|
||||
import BulkAction, { BulkActionText } from "../BulkAction.jsx";
|
||||
import NoteAutocomplete from "../../react/NoteAutocomplete.jsx";
|
||||
import { useEffect, useState } from "preact/hooks";
|
||||
import { useSpacedUpdate } from "../../react/hooks.jsx";
|
||||
|
||||
function MoveNoteBulkActionComponent({ bulkAction }: { bulkAction: AbstractBulkAction }) {
|
||||
function MoveNoteBulkActionComponent({ bulkAction, actionDef }: { bulkAction: AbstractBulkAction, actionDef: ActionDefinition }) {
|
||||
const [ targetParentNoteId, setTargetParentNoteId ] = useState<string>();
|
||||
const spacedUpdate = useSpacedUpdate(() => {
|
||||
return bulkAction.saveAction({ targetParentNoteId: targetParentNoteId })
|
||||
@@ -45,6 +45,6 @@ export default class MoveNoteBulkAction extends AbstractBulkAction {
|
||||
}
|
||||
|
||||
doRender() {
|
||||
return <MoveNoteBulkActionComponent bulkAction={this} />
|
||||
return <MoveNoteBulkActionComponent bulkAction={this} actionDef={this.actionDef} />
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import SpacedUpdate from "../../../services/spaced_update.js";
|
||||
import AbstractBulkAction, { ActionDefinition } from "../abstract_bulk_action.js";
|
||||
import { t } from "../../../services/i18n.js";
|
||||
import BulkAction from "../BulkAction.jsx";
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import SpacedUpdate from "../../../services/spaced_update.js";
|
||||
import AbstractBulkAction, { ActionDefinition } from "../abstract_bulk_action.js";
|
||||
import noteAutocompleteService from "../../../services/note_autocomplete.js";
|
||||
import { t } from "../../../services/i18n.js";
|
||||
import BulkAction, { BulkActionText } from "../BulkAction.jsx";
|
||||
import NoteAutocomplete from "../../react/NoteAutocomplete.jsx";
|
||||
|
||||
@@ -1,102 +0,0 @@
|
||||
.global-menu {
|
||||
width: 53px;
|
||||
height: 53px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.global-menu .dropdown-menu {
|
||||
min-width: 20em;
|
||||
}
|
||||
|
||||
.global-menu-button {
|
||||
width: 100% !important;
|
||||
height: 100% !important;
|
||||
position: relative;
|
||||
padding: 6px;
|
||||
border: 0;
|
||||
}
|
||||
|
||||
.global-menu-button svg path {
|
||||
fill: var(--launcher-pane-text-color);
|
||||
}
|
||||
|
||||
.global-menu-button:hover { border: 0; }
|
||||
.global-menu-button:hover svg path {
|
||||
transition: 200ms ease-in-out fill;
|
||||
}
|
||||
.global-menu-button:hover svg path.st0 { fill:#95C980; }
|
||||
.global-menu-button:hover svg path.st1 { fill:#72B755; }
|
||||
.global-menu-button:hover svg path.st2 { fill:#4FA52B; }
|
||||
.global-menu-button:hover svg path.st3 { fill:#EE8C89; }
|
||||
.global-menu-button:hover svg path.st4 { fill:#E96562; }
|
||||
.global-menu-button:hover svg path.st5 { fill:#E33F3B; }
|
||||
.global-menu-button:hover svg path.st6 { fill:#EFB075; }
|
||||
.global-menu-button:hover svg path.st7 { fill:#E99547; }
|
||||
.global-menu-button:hover svg path.st8 { fill:#E47B19; }
|
||||
|
||||
.global-menu-button-update-available {
|
||||
position: absolute;
|
||||
right: -30px;
|
||||
bottom: -30px;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.global-menu .zoom-container {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: baseline;
|
||||
}
|
||||
|
||||
.global-menu .zoom-buttons {
|
||||
margin-left: 2em;
|
||||
}
|
||||
|
||||
.global-menu .zoom-buttons a {
|
||||
display: inline-block;
|
||||
border: 1px solid var(--button-border-color);
|
||||
border-radius: var(--button-border-radius);
|
||||
color: var(--button-text-color);
|
||||
background-color: var(--button-background-color);
|
||||
padding: 3px;
|
||||
margin-left: 3px;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.global-menu .zoom-buttons a:hover {
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.global-menu .zoom-state {
|
||||
margin-left: 5px;
|
||||
margin-right: 5px;
|
||||
}
|
||||
|
||||
.global-menu .dropdown-item .bx {
|
||||
position: relative;
|
||||
top: 3px;
|
||||
font-size: 120%;
|
||||
margin-right: 6px;
|
||||
}
|
||||
|
||||
/* #region Update available */
|
||||
.global-menu-button-update-available-button {
|
||||
width: 21px !important;
|
||||
height: 21px !important;
|
||||
padding: 0 !important;
|
||||
|
||||
border-radius: var(--button-border-radius);
|
||||
transform: scale(0.9);
|
||||
border: none;
|
||||
opacity: 0.8;
|
||||
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.global-menu-button-wrapper:hover .global-menu-button-update-available-button {
|
||||
opacity: 1;
|
||||
}
|
||||
/* #endregion */
|
||||
436
apps/client/src/widgets/buttons/global_menu.ts
Normal file
436
apps/client/src/widgets/buttons/global_menu.ts
Normal file
@@ -0,0 +1,436 @@
|
||||
import { t } from "../../services/i18n.js";
|
||||
import BasicWidget from "../basic_widget.js";
|
||||
import utils from "../../services/utils.js";
|
||||
import UpdateAvailableWidget from "./update_available.js";
|
||||
import options from "../../services/options.js";
|
||||
import { Tooltip, Dropdown } from "bootstrap";
|
||||
|
||||
const TPL = /*html*/`
|
||||
<div class="dropdown global-menu">
|
||||
<style>
|
||||
.global-menu {
|
||||
width: 53px;
|
||||
height: 53px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.global-menu .dropdown-menu {
|
||||
min-width: 20em;
|
||||
}
|
||||
|
||||
.global-menu-button {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
position: relative;
|
||||
padding: 6px;
|
||||
border: 0;
|
||||
}
|
||||
|
||||
.global-menu-button > svg path {
|
||||
fill: var(--launcher-pane-text-color);
|
||||
}
|
||||
|
||||
.global-menu-button:hover { border: 0; }
|
||||
.global-menu-button:hover > svg path {
|
||||
transition: 200ms ease-in-out fill;
|
||||
}
|
||||
.global-menu-button:hover > svg path.st0 { fill:#95C980; }
|
||||
.global-menu-button:hover > svg path.st1 { fill:#72B755; }
|
||||
.global-menu-button:hover > svg path.st2 { fill:#4FA52B; }
|
||||
.global-menu-button:hover > svg path.st3 { fill:#EE8C89; }
|
||||
.global-menu-button:hover > svg path.st4 { fill:#E96562; }
|
||||
.global-menu-button:hover > svg path.st5 { fill:#E33F3B; }
|
||||
.global-menu-button:hover > svg path.st6 { fill:#EFB075; }
|
||||
.global-menu-button:hover > svg path.st7 { fill:#E99547; }
|
||||
.global-menu-button:hover > svg path.st8 { fill:#E47B19; }
|
||||
|
||||
.global-menu-button-update-available {
|
||||
position: absolute;
|
||||
right: -30px;
|
||||
bottom: -30px;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.global-menu .zoom-container {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: space-between;
|
||||
align-items: baseline;
|
||||
}
|
||||
|
||||
.global-menu .zoom-buttons a {
|
||||
display: inline-block;
|
||||
border: 1px solid var(--button-border-color);
|
||||
border-radius: var(--button-border-radius);
|
||||
color: var(--button-text-color);
|
||||
background-color: var(--button-background-color);
|
||||
padding: 3px;
|
||||
margin-left: 3px;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.global-menu .zoom-buttons a:hover {
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.global-menu .zoom-state {
|
||||
margin-left: 5px;
|
||||
margin-right: 5px;
|
||||
}
|
||||
|
||||
.global-menu .dropdown-item .bx {
|
||||
position: relative;
|
||||
top: 3px;
|
||||
font-size: 120%;
|
||||
margin-right: 6px;
|
||||
}
|
||||
</style>
|
||||
|
||||
<button type="button" data-bs-toggle="dropdown" aria-haspopup="true"
|
||||
aria-expanded="false" class="icon-action global-menu-button">
|
||||
<div class="global-menu-button-update-available"></div>
|
||||
</button>
|
||||
|
||||
<ul class="dropdown-menu dropdown-menu-right">
|
||||
<li class="dropdown-item" data-trigger-command="openNewWindow">
|
||||
<span class="bx bx-window-open"></span>
|
||||
${t("global_menu.open_new_window")}
|
||||
<kbd data-command="openNewWindow"></kbd>
|
||||
</li>
|
||||
|
||||
<li class="dropdown-item" data-trigger-command="showShareSubtree">
|
||||
<span class="bx bx-share-alt"></span>
|
||||
${t("global_menu.show_shared_notes_subtree")}
|
||||
</li>
|
||||
|
||||
<div class="dropdown-divider"></div>
|
||||
|
||||
<span class="zoom-container dropdown-item dropdown-item-container">
|
||||
<div>
|
||||
<span class="bx bx-empty"></span>
|
||||
${t("global_menu.zoom")}
|
||||
</div>
|
||||
|
||||
<div class="zoom-buttons">
|
||||
<a data-trigger-command="toggleFullscreen" title="${t("global_menu.toggle_fullscreen")}" class="bx bx-expand-alt"></a>
|
||||
|
||||
|
||||
|
||||
<a data-trigger-command="zoomOut" title="${t("global_menu.zoom_out")}" class="bx bx-minus"></a>
|
||||
|
||||
<span data-trigger-command="zoomReset" title="${t("global_menu.reset_zoom_level")}" class="zoom-state"></span>
|
||||
|
||||
<a data-trigger-command="zoomIn" title="${t("global_menu.zoom_in")}" class="bx bx-plus"></a>
|
||||
</div>
|
||||
</span>
|
||||
|
||||
<li class="dropdown-item toggle-pin">
|
||||
<span class="bx bx-pin"></span>
|
||||
${t("title_bar_buttons.window-on-top")}
|
||||
</li>
|
||||
|
||||
<li class="dropdown-item" data-trigger-command="toggleZenMode">
|
||||
<span class="bx bxs-yin-yang"></span>
|
||||
${t("global_menu.toggle-zen-mode")}
|
||||
<kbd data-command="toggleZenMode"></kbd>
|
||||
</li>
|
||||
|
||||
<div class="dropdown-divider desktop-only"></div>
|
||||
|
||||
<li class="dropdown-item switch-to-mobile-version-button" data-trigger-command="switchToMobileVersion">
|
||||
<span class="bx bx-mobile"></span>
|
||||
${t("global_menu.switch_to_mobile_version")}
|
||||
</li>
|
||||
|
||||
<li class="dropdown-item switch-to-desktop-version-button" data-trigger-command="switchToDesktopVersion">
|
||||
<span class="bx bx-desktop"></span>
|
||||
${t("global_menu.switch_to_desktop_version")}
|
||||
</li>
|
||||
|
||||
<li class="dropdown-item" data-trigger-command="showLaunchBarSubtree">
|
||||
<span class="bx ${utils.isMobile() ? "bx-mobile" : "bx-sidebar"}"></span>
|
||||
${t("global_menu.configure_launchbar")}
|
||||
</li>
|
||||
|
||||
<li class="dropdown-item dropdown-submenu">
|
||||
<span class="dropdown-toggle">
|
||||
<span class="bx bx-chip"></span>${t("global_menu.advanced")}
|
||||
</span>
|
||||
|
||||
<ul class="dropdown-menu">
|
||||
<li class="dropdown-item" data-trigger-command="showHiddenSubtree">
|
||||
<span class="bx bx-hide"></span>
|
||||
${t("global_menu.show_hidden_subtree")}
|
||||
</li>
|
||||
|
||||
<li class="dropdown-item" data-trigger-command="showSearchHistory">
|
||||
<span class="bx bx-search-alt"></span>
|
||||
${t("global_menu.open_search_history")}
|
||||
</li>
|
||||
|
||||
<div class="dropdown-divider"></div>
|
||||
|
||||
<li class="dropdown-item" data-trigger-command="showBackendLog">
|
||||
<span class="bx bx-detail"></span>
|
||||
${t("global_menu.show_backend_log")}
|
||||
<kbd data-command="showBackendLog"></kbd>
|
||||
</li>
|
||||
|
||||
<li class="dropdown-item" data-trigger-command="showSQLConsole">
|
||||
<span class="bx bx-data"></span>
|
||||
${t("global_menu.open_sql_console")}
|
||||
<kbd data-command="showSQLConsole"></kbd>
|
||||
</li>
|
||||
|
||||
<li class="dropdown-item" data-trigger-command="showSQLConsoleHistory">
|
||||
<span class="bx bx-data"></span>
|
||||
${t("global_menu.open_sql_console_history")}
|
||||
</li>
|
||||
|
||||
<div class="dropdown-divider"></div>
|
||||
|
||||
<li class="dropdown-item open-dev-tools-button" data-trigger-command="openDevTools">
|
||||
<span class="bx bx-bug-alt"></span>
|
||||
${t("global_menu.open_dev_tools")}
|
||||
<kbd data-command="openDevTools"></kbd>
|
||||
</li>
|
||||
|
||||
<li class="dropdown-item" data-trigger-command="reloadFrontendApp"
|
||||
title="${t("global_menu.reload_hint")}">
|
||||
<span class="bx bx-refresh"></span>
|
||||
${t("global_menu.reload_frontend")}
|
||||
<kbd data-command="reloadFrontendApp"></kbd>
|
||||
</li>
|
||||
|
||||
</ul>
|
||||
</li>
|
||||
|
||||
<li class="dropdown-item" data-trigger-command="showOptions">
|
||||
<span class="bx bx-cog"></span>
|
||||
${t("global_menu.options")}
|
||||
</li>
|
||||
|
||||
<div class="dropdown-divider desktop-only"></div>
|
||||
|
||||
<li class="dropdown-item show-help-button" data-trigger-command="showHelp">
|
||||
<span class="bx bx-help-circle"></span>
|
||||
${t("global_menu.show_help")}
|
||||
<kbd data-command="showHelp"></kbd>
|
||||
</li>
|
||||
|
||||
<li class="dropdown-item show-help-button" data-trigger-command="showCheatsheet">
|
||||
<span class="bx bxs-keyboard"></span>
|
||||
${t("global_menu.show-cheatsheet")}
|
||||
<kbd data-command="showCheatsheet"></kbd>
|
||||
</li>
|
||||
|
||||
<li class="dropdown-item show-about-dialog-button">
|
||||
<span class="bx bx-info-circle"></span>
|
||||
${t("global_menu.about")}
|
||||
</li>
|
||||
|
||||
<li class="dropdown-item update-to-latest-version-button" style="display: none;" data-trigger-command="downloadLatestVersion">
|
||||
<span class="bx bx-sync"></span>
|
||||
|
||||
<span class="version-text"></span>
|
||||
</li>
|
||||
|
||||
<div class="dropdown-divider logout-button-separator"></div>
|
||||
|
||||
<li class="dropdown-item logout-button" data-trigger-command="logout">
|
||||
<span class="bx bx-log-out"></span>
|
||||
${t("global_menu.logout")}
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
`;
|
||||
|
||||
export default class GlobalMenuWidget extends BasicWidget {
|
||||
private updateAvailableWidget: UpdateAvailableWidget;
|
||||
private isHorizontalLayout: boolean;
|
||||
private tooltip!: Tooltip;
|
||||
private dropdown!: Dropdown;
|
||||
|
||||
private $updateToLatestVersionButton!: JQuery<HTMLElement>;
|
||||
private $zoomState!: JQuery<HTMLElement>;
|
||||
private $toggleZenMode!: JQuery<HTMLElement>;
|
||||
|
||||
constructor(isHorizontalLayout: boolean) {
|
||||
super();
|
||||
|
||||
this.updateAvailableWidget = new UpdateAvailableWidget();
|
||||
this.isHorizontalLayout = isHorizontalLayout;
|
||||
}
|
||||
|
||||
doRender() {
|
||||
this.$widget = $(TPL);
|
||||
|
||||
if (!this.isHorizontalLayout) {
|
||||
this.$widget.addClass("dropend");
|
||||
}
|
||||
|
||||
const $globalMenuButton = this.$widget.find(".global-menu-button");
|
||||
if (!this.isHorizontalLayout) {
|
||||
$globalMenuButton.prepend(
|
||||
$(`\
|
||||
<svg viewBox="0 0 256 256" data-bs-toggle="tooltip" title="${t("global_menu.menu")}">
|
||||
<g>
|
||||
<path class="st0" d="m202.9 112.7c-22.5 16.1-54.5 12.8-74.9 6.3l14.8-11.8 14.1-11.3 49.1-39.3-51.2 35.9-14.3 10-14.9 10.5c0.7-21.2 7-49.9 28.6-65.4 1.8-1.3 3.9-2.6 6.1-3.8 2.7-1.5 5.7-2.9 8.8-4.1 27.1-11.1 68.5-15.3 85.2-9.5 0.1 16.2-15.9 45.4-33.9 65.9-2.4 2.8-4.9 5.4-7.4 7.8-3.4 3.5-6.8 6.4-10.1 8.8z"/>
|
||||
<path class="st1" d="m213.1 104c-22.2 12.6-51.4 9.3-70.3 3.2l14.1-11.3 49.1-39.3-51.2 35.9-14.3 10c0.5-18.1 4.9-42.1 19.7-58.6 2.7-1.5 5.7-2.9 8.8-4.1 27.1-11.1 68.5-15.3 85.2-9.5 0.1 16.2-15.9 45.4-33.9 65.9-2.3 2.8-4.8 5.4-7.2 7.8z"/>
|
||||
<path class="st2" d="m220.5 96.2c-21.1 8.6-46.6 5.3-63.7-0.2l49.2-39.4-51.2 35.9c0.3-15.8 3.5-36.6 14.3-52.8 27.1-11.1 68.5-15.3 85.2-9.5 0.1 16.2-15.9 45.4-33.8 66z"/>
|
||||
|
||||
<path class="st3" d="m106.7 179c-5.8-21 5.2-43.8 15.5-57.2l4.8 14.2 4.5 13.4 15.9 47-12.8-47.6-3.6-13.2-3.7-13.9c15.5 6.2 35.1 18.6 40.7 38.8 0.5 1.7 0.9 3.6 1.2 5.5 0.4 2.4 0.6 5 0.7 7.7 0.9 23.1-7.1 54.9-15.9 65.7-12-4.3-29.3-24-39.7-42.8-1.4-2.6-2.7-5.1-3.8-7.6-1.6-3.5-2.9-6.8-3.8-10z"/>
|
||||
<path class="st4" d="m110.4 188.9c-3.4-19.8 6.9-40.5 16.6-52.9l4.5 13.4 15.9 47-12.8-47.6-3.6-13.2c13.3 5.2 29.9 15 38.1 30.4 0.4 2.4 0.6 5 0.7 7.7 0.9 23.1-7.1 54.9-15.9 65.7-12-4.3-29.3-24-39.7-42.8-1.4-2.6-2.7-5.2-3.8-7.7z"/>
|
||||
<path class="st5" d="m114.2 196.5c-0.7-18 8.6-35.9 17.3-47.1l15.9 47-12.8-47.6c11.6 4.4 26.1 12.4 35.2 24.8 0.9 23.1-7.1 54.9-15.9 65.7-12-4.3-29.3-24-39.7-42.8z"/>
|
||||
|
||||
<path class="st6" d="m86.3 59.1c21.7 10.9 32.4 36.6 35.8 54.9l-15.2-6.6-14.5-6.3-50.6-22 48.8 24.9 13.6 6.9 14.3 7.3c-16.6 7.9-41.3 14.5-62.1 4.1-1.8-0.9-3.6-1.9-5.4-3.2-2.3-1.5-4.5-3.2-6.8-5.1-19.9-16.4-40.3-46.4-42.7-61.5 12.4-6.5 41.5-5.8 64.8-0.3 3.2 0.8 6.2 1.6 9.1 2.5 4 1.3 7.6 2.8 10.9 4.4z"/>
|
||||
<path class="st7" d="m75.4 54.8c18.9 12 28.4 35.6 31.6 52.6l-14.5-6.3-50.6-22 48.7 24.9 13.6 6.9c-14.1 6.8-34.5 13-53.3 8.2-2.3-1.5-4.5-3.2-6.8-5.1-19.8-16.4-40.2-46.4-42.6-61.5 12.4-6.5 41.5-5.8 64.8-0.3 3.1 0.8 6.2 1.6 9.1 2.6z"/>
|
||||
<path class="st8" d="m66.3 52.2c15.3 12.8 23.3 33.6 26.1 48.9l-50.6-22 48.8 24.9c-12.2 6-29.6 11.8-46.5 10-19.8-16.4-40.2-46.4-42.6-61.5 12.4-6.5 41.5-5.8 64.8-0.3z"/>
|
||||
</g>
|
||||
</svg>`)
|
||||
);
|
||||
|
||||
this.tooltip = new Tooltip(this.$widget.find("[data-bs-toggle='tooltip']")[0], { trigger: "hover" });
|
||||
} else {
|
||||
$globalMenuButton.toggleClass("bx bx-menu");
|
||||
}
|
||||
|
||||
this.dropdown = Dropdown.getOrCreateInstance(this.$widget.find("[data-bs-toggle='dropdown']")[0], {
|
||||
popperConfig: {
|
||||
placement: "bottom"
|
||||
}
|
||||
});
|
||||
|
||||
this.$widget.find(".show-about-dialog-button").on("click", () => this.triggerCommand("openAboutDialog"));
|
||||
|
||||
const isElectron = utils.isElectron();
|
||||
|
||||
this.$widget.find(".toggle-pin").toggle(isElectron);
|
||||
if (isElectron) {
|
||||
this.$widget.on("click", ".toggle-pin", (e) => {
|
||||
const $el = $(e.target);
|
||||
const remote = utils.dynamicRequire("@electron/remote");
|
||||
const focusedWindow = remote.BrowserWindow.getFocusedWindow();
|
||||
const isAlwaysOnTop = focusedWindow.isAlwaysOnTop();
|
||||
if (isAlwaysOnTop) {
|
||||
focusedWindow.setAlwaysOnTop(false);
|
||||
$el.removeClass("active");
|
||||
} else {
|
||||
focusedWindow.setAlwaysOnTop(true);
|
||||
$el.addClass("active");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
this.$widget.find(".logout-button").toggle(!isElectron);
|
||||
this.$widget.find(".logout-button-separator").toggle(!isElectron);
|
||||
|
||||
this.$widget.find(".open-dev-tools-button").toggle(isElectron);
|
||||
this.$widget.find(".switch-to-mobile-version-button").toggle(!isElectron && utils.isDesktop());
|
||||
this.$widget.find(".switch-to-desktop-version-button").toggle(!isElectron && utils.isMobile());
|
||||
|
||||
this.$widget.on("click", ".dropdown-item", (e) => {
|
||||
if ($(e.target).parent(".zoom-buttons")) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.dropdown.toggle();
|
||||
});
|
||||
if (utils.isMobile()) {
|
||||
this.$widget.on("click", ".dropdown-submenu .dropdown-toggle", (e) => {
|
||||
const $submenu = $(e.target).closest(".dropdown-item");
|
||||
$submenu.toggleClass("submenu-open");
|
||||
$submenu.find("ul.dropdown-menu").toggleClass("show");
|
||||
e.stopPropagation();
|
||||
return;
|
||||
});
|
||||
}
|
||||
this.$widget.on("click", ".dropdown-submenu", (e) => {
|
||||
if ($(e.target).children(".dropdown-menu").length === 1 || $(e.target).hasClass("dropdown-toggle")) {
|
||||
e.stopPropagation();
|
||||
}
|
||||
});
|
||||
|
||||
this.$widget.find(".global-menu-button-update-available").append(this.updateAvailableWidget.render());
|
||||
|
||||
this.$updateToLatestVersionButton = this.$widget.find(".update-to-latest-version-button");
|
||||
|
||||
if (!utils.isElectron()) {
|
||||
this.$widget.find(".zoom-container").hide();
|
||||
}
|
||||
|
||||
this.$zoomState = this.$widget.find(".zoom-state");
|
||||
this.$toggleZenMode = this.$widget.find('[data-trigger-command="toggleZenMode"');
|
||||
this.$widget.on("show.bs.dropdown", () => this.#onShown());
|
||||
if (this.tooltip) {
|
||||
this.$widget.on("hide.bs.dropdown", () => this.tooltip.enable());
|
||||
}
|
||||
|
||||
this.$widget.find(".zoom-buttons").on(
|
||||
"click",
|
||||
// delay to wait for the actual zoom change
|
||||
() => setTimeout(() => this.updateZoomState(), 300)
|
||||
);
|
||||
|
||||
this.updateVersionStatus();
|
||||
|
||||
setInterval(() => this.updateVersionStatus(), 8 * 60 * 60 * 1000);
|
||||
}
|
||||
|
||||
#onShown() {
|
||||
this.$toggleZenMode.toggleClass("active", $("body").hasClass("zen"));
|
||||
this.updateZoomState();
|
||||
if (this.tooltip) {
|
||||
this.tooltip.hide();
|
||||
this.tooltip.disable();
|
||||
}
|
||||
}
|
||||
|
||||
updateZoomState() {
|
||||
if (!utils.isElectron()) {
|
||||
return;
|
||||
}
|
||||
|
||||
const zoomFactor = utils.dynamicRequire("electron").webFrame.getZoomFactor();
|
||||
const zoomPercent = Math.round(zoomFactor * 100);
|
||||
|
||||
this.$zoomState.text(`${zoomPercent}%`);
|
||||
}
|
||||
|
||||
async updateVersionStatus() {
|
||||
await options.initializedPromise;
|
||||
|
||||
if (options.get("checkForUpdates") !== "true") {
|
||||
return;
|
||||
}
|
||||
|
||||
const latestVersion = await this.fetchLatestVersion();
|
||||
this.updateAvailableWidget.updateVersionStatus(latestVersion);
|
||||
// Show "click to download" button in options menu if there's a new version available
|
||||
this.$updateToLatestVersionButton.toggle(utils.isUpdateAvailable(latestVersion, glob.triliumVersion));
|
||||
this.$updateToLatestVersionButton.find(".version-text").text(`Version ${latestVersion} is available, click to download.`);
|
||||
}
|
||||
|
||||
async fetchLatestVersion() {
|
||||
const RELEASES_API_URL = "https://api.github.com/repos/TriliumNext/Notes/releases/latest";
|
||||
|
||||
const resp = await fetch(RELEASES_API_URL);
|
||||
const data = await resp.json();
|
||||
|
||||
return data?.tag_name?.substring(1);
|
||||
}
|
||||
|
||||
downloadLatestVersionCommand() {
|
||||
window.open("https://github.com/TriliumNext/Trilium/releases/latest");
|
||||
}
|
||||
|
||||
activeContextChangedEvent() {
|
||||
this.dropdown.hide();
|
||||
}
|
||||
|
||||
noteSwitchedEvent() {
|
||||
this.dropdown.hide();
|
||||
}
|
||||
}
|
||||
@@ -1,239 +0,0 @@
|
||||
import Dropdown from "../react/Dropdown";
|
||||
import "./global_menu.css";
|
||||
import { useStaticTooltip, useStaticTooltipWithKeyboardShortcut, useTriliumOption, useTriliumOptionBool } from "../react/hooks";
|
||||
import { useContext, useEffect, useRef, useState } from "preact/hooks";
|
||||
import { t } from "../../services/i18n";
|
||||
import { FormDropdownDivider, FormDropdownSubmenu, FormListItem } from "../react/FormList";
|
||||
import { CommandNames } from "../../components/app_context";
|
||||
import KeyboardShortcut from "../react/KeyboardShortcut";
|
||||
import { KeyboardActionNames } from "@triliumnext/commons";
|
||||
import { ComponentChildren } from "preact";
|
||||
import Component from "../../components/component";
|
||||
import { ParentComponent } from "../react/react_utils";
|
||||
import utils, { dynamicRequire, isElectron, isMobile } from "../../services/utils";
|
||||
|
||||
interface MenuItemProps<T> {
|
||||
icon: string,
|
||||
text: ComponentChildren,
|
||||
title?: string,
|
||||
command: T,
|
||||
disabled?: boolean
|
||||
active?: boolean;
|
||||
outsideChildren?: ComponentChildren;
|
||||
}
|
||||
|
||||
export default function GlobalMenu({ isHorizontalLayout }: { isHorizontalLayout: boolean }) {
|
||||
const isVerticalLayout = !isHorizontalLayout;
|
||||
const parentComponent = useContext(ParentComponent);
|
||||
const { isUpdateAvailable, latestVersion } = useTriliumUpdateStatus();
|
||||
|
||||
return (
|
||||
<Dropdown
|
||||
className="global-menu"
|
||||
buttonClassName={`global-menu-button ${isHorizontalLayout ? "bx bx-menu" : ""}`} noSelectButtonStyle iconAction hideToggleArrow
|
||||
text={<>
|
||||
{isVerticalLayout && <VerticalLayoutIcon />}
|
||||
{isUpdateAvailable && <div class="global-menu-button-update-available">
|
||||
<span className="bx bx-sync global-menu-button-update-available-button" title={t("update_available.update_available")}></span>
|
||||
</div>}
|
||||
</>}
|
||||
>
|
||||
|
||||
<MenuItem command="openNewWindow" icon="bx bx-window-open" text={t("global_menu.open_new_window")} />
|
||||
<MenuItem command="showShareSubtree" icon="bx bx-share-alt" text={t("global_menu.show_shared_notes_subtree")} />
|
||||
<FormDropdownDivider />
|
||||
|
||||
<ZoomControls parentComponent={parentComponent} />
|
||||
<ToggleWindowOnTop />
|
||||
<KeyboardActionMenuItem command="toggleZenMode" icon="bx bxs-yin-yang" text={t("global_menu.toggle-zen-mode")} />
|
||||
<FormDropdownDivider />
|
||||
|
||||
<SwitchToOptions />
|
||||
<MenuItem command="showLaunchBarSubtree" icon={`bx ${isMobile() ? "bx-mobile" : "bx-sidebar"}`} text={t("global_menu.configure_launchbar")} />
|
||||
<AdvancedMenu />
|
||||
<MenuItem command="showOptions" icon="bx bx-cog" text={t("global_menu.options")} />
|
||||
<FormDropdownDivider />
|
||||
|
||||
<KeyboardActionMenuItem command="showHelp" icon="bx bx-help-circle" text={t("global_menu.show_help")} />
|
||||
<KeyboardActionMenuItem command="showCheatsheet" icon="bx bxs-keyboard" text={t("global_menu.show-cheatsheet")} />
|
||||
<MenuItem command="openAboutDialog" icon="bx bx-info-circle" text={t("global_menu.about")} />
|
||||
{isUpdateAvailable && <MenuItem command={() => window.open("https://github.com/TriliumNext/Trilium/releases/latest")} icon="bx bx-sync" text={`Version ${latestVersion} is available, click to download.`} /> }
|
||||
{!isElectron() && <BrowserOnlyOptions />}
|
||||
</Dropdown>
|
||||
)
|
||||
}
|
||||
|
||||
function AdvancedMenu() {
|
||||
return (
|
||||
<FormDropdownSubmenu icon="bx bx-chip" title={t("global_menu.advanced")}>
|
||||
<MenuItem command="showHiddenSubtree" icon="bx bx-hide" text={t("global_menu.show_hidden_subtree")} />
|
||||
<MenuItem command="showSearchHistory" icon="bx bx-search-alt" text={t("global_menu.open_search_history")} />
|
||||
<FormDropdownDivider />
|
||||
|
||||
<KeyboardActionMenuItem command="showBackendLog" icon="bx bx-detail" text={t("global_menu.show_backend_log")} />
|
||||
<KeyboardActionMenuItem command="showSQLConsole" icon="bx bx-data" text={t("global_menu.open_sql_console")} />
|
||||
<MenuItem command="showSQLConsoleHistory" icon="bx bx-data" text={t("global_menu.open_sql_console_history")} />
|
||||
<FormDropdownDivider />
|
||||
|
||||
{isElectron() && <MenuItem command="openDevTools" icon="bx bx-bug-alt" text={t("global_menu.open_dev_tools")} />}
|
||||
<KeyboardActionMenuItem command="reloadFrontendApp" icon="bx bx-refresh" text={t("global_menu.reload_frontend")} title={t("global_menu.reload_hint")} />
|
||||
</FormDropdownSubmenu>
|
||||
)
|
||||
}
|
||||
|
||||
function BrowserOnlyOptions() {
|
||||
return <>
|
||||
<FormDropdownDivider />
|
||||
<MenuItem command="logout" icon="bx bx-log-out" text={t("global_menu.logout")} />
|
||||
</>;
|
||||
}
|
||||
|
||||
function SwitchToOptions() {
|
||||
if (isElectron()) {
|
||||
return;
|
||||
} else if (!isMobile()) {
|
||||
return <MenuItem command="switchToMobileVersion" icon="bx bx-mobile" text={t("global_menu.switch_to_mobile_version")} />
|
||||
} else {
|
||||
return <MenuItem command="switchToDesktopVersion" icon="bx bx-desktop" text={t("global_menu.switch_to_desktop_version")} />
|
||||
}
|
||||
}
|
||||
|
||||
function MenuItem({ icon, text, title, command, disabled, active }: MenuItemProps<KeyboardActionNames | CommandNames | (() => void)>) {
|
||||
return <FormListItem
|
||||
icon={icon}
|
||||
title={title}
|
||||
triggerCommand={typeof command === "string" ? command : undefined}
|
||||
onClick={typeof command === "function" ? command : undefined}
|
||||
disabled={disabled}
|
||||
active={active}
|
||||
>{text}</FormListItem>
|
||||
}
|
||||
|
||||
function KeyboardActionMenuItem({ text, command, ...props }: MenuItemProps<KeyboardActionNames>) {
|
||||
return <MenuItem
|
||||
{...props}
|
||||
command={command}
|
||||
text={<>{text} <KeyboardShortcut actionName={command as KeyboardActionNames} /></>}
|
||||
/>
|
||||
}
|
||||
|
||||
function VerticalLayoutIcon() {
|
||||
const logoRef = useRef<SVGSVGElement>(null);
|
||||
useStaticTooltip(logoRef);
|
||||
|
||||
return (
|
||||
<svg ref={logoRef} viewBox="0 0 256 256" title={t("global_menu.menu")}>
|
||||
<g>
|
||||
<path className="st0" d="m202.9 112.7c-22.5 16.1-54.5 12.8-74.9 6.3l14.8-11.8 14.1-11.3 49.1-39.3-51.2 35.9-14.3 10-14.9 10.5c0.7-21.2 7-49.9 28.6-65.4 1.8-1.3 3.9-2.6 6.1-3.8 2.7-1.5 5.7-2.9 8.8-4.1 27.1-11.1 68.5-15.3 85.2-9.5 0.1 16.2-15.9 45.4-33.9 65.9-2.4 2.8-4.9 5.4-7.4 7.8-3.4 3.5-6.8 6.4-10.1 8.8z"/>
|
||||
<path className="st1" d="m213.1 104c-22.2 12.6-51.4 9.3-70.3 3.2l14.1-11.3 49.1-39.3-51.2 35.9-14.3 10c0.5-18.1 4.9-42.1 19.7-58.6 2.7-1.5 5.7-2.9 8.8-4.1 27.1-11.1 68.5-15.3 85.2-9.5 0.1 16.2-15.9 45.4-33.9 65.9-2.3 2.8-4.8 5.4-7.2 7.8z"/>
|
||||
<path className="st2" d="m220.5 96.2c-21.1 8.6-46.6 5.3-63.7-0.2l49.2-39.4-51.2 35.9c0.3-15.8 3.5-36.6 14.3-52.8 27.1-11.1 68.5-15.3 85.2-9.5 0.1 16.2-15.9 45.4-33.8 66z"/>
|
||||
|
||||
<path className="st3" d="m106.7 179c-5.8-21 5.2-43.8 15.5-57.2l4.8 14.2 4.5 13.4 15.9 47-12.8-47.6-3.6-13.2-3.7-13.9c15.5 6.2 35.1 18.6 40.7 38.8 0.5 1.7 0.9 3.6 1.2 5.5 0.4 2.4 0.6 5 0.7 7.7 0.9 23.1-7.1 54.9-15.9 65.7-12-4.3-29.3-24-39.7-42.8-1.4-2.6-2.7-5.1-3.8-7.6-1.6-3.5-2.9-6.8-3.8-10z"/>
|
||||
<path className="st4" d="m110.4 188.9c-3.4-19.8 6.9-40.5 16.6-52.9l4.5 13.4 15.9 47-12.8-47.6-3.6-13.2c13.3 5.2 29.9 15 38.1 30.4 0.4 2.4 0.6 5 0.7 7.7 0.9 23.1-7.1 54.9-15.9 65.7-12-4.3-29.3-24-39.7-42.8-1.4-2.6-2.7-5.2-3.8-7.7z"/>
|
||||
<path className="st5" d="m114.2 196.5c-0.7-18 8.6-35.9 17.3-47.1l15.9 47-12.8-47.6c11.6 4.4 26.1 12.4 35.2 24.8 0.9 23.1-7.1 54.9-15.9 65.7-12-4.3-29.3-24-39.7-42.8z"/>
|
||||
|
||||
<path className="st6" d="m86.3 59.1c21.7 10.9 32.4 36.6 35.8 54.9l-15.2-6.6-14.5-6.3-50.6-22 48.8 24.9 13.6 6.9 14.3 7.3c-16.6 7.9-41.3 14.5-62.1 4.1-1.8-0.9-3.6-1.9-5.4-3.2-2.3-1.5-4.5-3.2-6.8-5.1-19.9-16.4-40.3-46.4-42.7-61.5 12.4-6.5 41.5-5.8 64.8-0.3 3.2 0.8 6.2 1.6 9.1 2.5 4 1.3 7.6 2.8 10.9 4.4z"/>
|
||||
<path className="st7" d="m75.4 54.8c18.9 12 28.4 35.6 31.6 52.6l-14.5-6.3-50.6-22 48.7 24.9 13.6 6.9c-14.1 6.8-34.5 13-53.3 8.2-2.3-1.5-4.5-3.2-6.8-5.1-19.8-16.4-40.2-46.4-42.6-61.5 12.4-6.5 41.5-5.8 64.8-0.3 3.1 0.8 6.2 1.6 9.1 2.6z"/>
|
||||
<path className="st8" d="m66.3 52.2c15.3 12.8 23.3 33.6 26.1 48.9l-50.6-22 48.8 24.9c-12.2 6-29.6 11.8-46.5 10-19.8-16.4-40.2-46.4-42.6-61.5 12.4-6.5 41.5-5.8 64.8-0.3z"/>
|
||||
</g>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
function ZoomControls({ parentComponent }: { parentComponent?: Component | null }) {
|
||||
const [ zoomLevel, setZoomLevel ] = useState(100);
|
||||
|
||||
function updateZoomState() {
|
||||
if (!isElectron()) {
|
||||
return;
|
||||
}
|
||||
|
||||
const zoomFactor = dynamicRequire("electron").webFrame.getZoomFactor();
|
||||
setZoomLevel(Math.round(zoomFactor * 100));
|
||||
}
|
||||
|
||||
useEffect(updateZoomState, []);
|
||||
|
||||
function ZoomControlButton({ command, title, icon, children }: { command: KeyboardActionNames, title: string, icon?: string, children?: ComponentChildren }) {
|
||||
const linkRef = useRef<HTMLAnchorElement>(null);
|
||||
useStaticTooltipWithKeyboardShortcut(linkRef, title, command);
|
||||
return (
|
||||
<a
|
||||
ref={linkRef}
|
||||
onClick={(e) => {
|
||||
parentComponent?.triggerCommand(command);
|
||||
setTimeout(() => updateZoomState(), 300)
|
||||
e.stopPropagation();
|
||||
}}
|
||||
className={icon}
|
||||
>{children}</a>
|
||||
)
|
||||
}
|
||||
|
||||
return isElectron() ? (
|
||||
<FormListItem
|
||||
icon="bx bx-empty"
|
||||
className="zoom-container"
|
||||
>
|
||||
{t("global_menu.zoom")}
|
||||
<>
|
||||
<div className="zoom-buttons">
|
||||
<ZoomControlButton command="toggleFullscreen" title={t("global_menu.toggle_fullscreen")} icon="bx bx-expand-alt" />
|
||||
|
||||
<ZoomControlButton command="zoomOut" title={t("global_menu.zoom_out")} icon="bx bx-minus" />
|
||||
<ZoomControlButton command="zoomReset" title={t("global_menu.reset_zoom_level")}>{zoomLevel}{t("units.percentage")}</ZoomControlButton>
|
||||
<ZoomControlButton command="zoomIn" title={t("global_menu.zoom_in")} icon="bx bx-plus" />
|
||||
</div>
|
||||
</>
|
||||
</FormListItem>
|
||||
) : (
|
||||
<MenuItem icon="bx bx-expand-alt" command="toggleFullscreen" text={t("global_menu.toggle_fullscreen")} />
|
||||
);
|
||||
}
|
||||
|
||||
function ToggleWindowOnTop() {
|
||||
const focusedWindow = isElectron() ? dynamicRequire("@electron/remote").BrowserWindow.getFocusedWindow() : null;
|
||||
const [ isAlwaysOnTop, setIsAlwaysOnTop ] = useState(focusedWindow?.isAlwaysOnTop());
|
||||
|
||||
return (isElectron() &&
|
||||
<MenuItem
|
||||
icon="bx bx-pin"
|
||||
text={t("title_bar_buttons.window-on-top")}
|
||||
active={isAlwaysOnTop}
|
||||
command={() => {
|
||||
const newState = !isAlwaysOnTop;
|
||||
focusedWindow?.setAlwaysOnTop(newState);
|
||||
setIsAlwaysOnTop(newState);
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function useTriliumUpdateStatus() {
|
||||
const [ latestVersion, setLatestVersion ] = useState<string>();
|
||||
const [ checkForUpdates ] = useTriliumOptionBool("checkForUpdates");
|
||||
const isUpdateAvailable = utils.isUpdateAvailable(latestVersion, glob.triliumVersion);
|
||||
|
||||
async function updateVersionStatus() {
|
||||
const RELEASES_API_URL = "https://api.github.com/repos/TriliumNext/Trilium/releases/latest";
|
||||
|
||||
const resp = await fetch(RELEASES_API_URL);
|
||||
const data = await resp.json();
|
||||
const latestVersion = data?.tag_name?.substring(1);
|
||||
setLatestVersion(latestVersion);
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (!checkForUpdates) {
|
||||
setLatestVersion(undefined);
|
||||
return;
|
||||
}
|
||||
|
||||
updateVersionStatus();
|
||||
|
||||
const interval = setInterval(() => updateVersionStatus(), 8 * 60 * 60 * 1000);
|
||||
return () => clearInterval(interval);
|
||||
}, [ checkForUpdates ]);
|
||||
|
||||
return { isUpdateAvailable, latestVersion };
|
||||
}
|
||||
@@ -1,11 +1,24 @@
|
||||
import utils from "../../services/utils.js";
|
||||
import contextMenu, { MenuCommandItem } from "../../menus/context_menu.js";
|
||||
import contextMenu from "../../menus/context_menu.js";
|
||||
import treeService from "../../services/tree.js";
|
||||
import ButtonFromNoteWidget from "./button_from_note.js";
|
||||
import type FNote from "../../entities/fnote.js";
|
||||
import type { CommandNames } from "../../components/app_context.js";
|
||||
import type { WebContents } from "electron";
|
||||
import link from "../../services/link.js";
|
||||
|
||||
interface WebContents {
|
||||
history: string[];
|
||||
getActiveIndex(): number;
|
||||
clearHistory(): void;
|
||||
canGoBack(): boolean;
|
||||
canGoForward(): boolean;
|
||||
goToIndex(index: string): void;
|
||||
}
|
||||
|
||||
interface ContextMenuItem {
|
||||
title: string;
|
||||
idx: string;
|
||||
uiIcon: string;
|
||||
}
|
||||
|
||||
export default class HistoryNavigationButton extends ButtonFromNoteWidget {
|
||||
private webContents?: WebContents;
|
||||
@@ -38,24 +51,28 @@ export default class HistoryNavigationButton extends ButtonFromNoteWidget {
|
||||
async showContextMenu(e: JQuery.ContextMenuEvent) {
|
||||
e.preventDefault();
|
||||
|
||||
if (!this.webContents || this.webContents.navigationHistory.length() < 2) {
|
||||
if (!this.webContents || this.webContents.history.length < 2) {
|
||||
return;
|
||||
}
|
||||
|
||||
let items: MenuCommandItem<string>[] = [];
|
||||
let items: ContextMenuItem[] = [];
|
||||
|
||||
const history = this.webContents.navigationHistory.getAllEntries();
|
||||
const activeIndex = this.webContents.navigationHistory.getActiveIndex();
|
||||
const activeIndex = this.webContents.getActiveIndex();
|
||||
const history = this.webContents.history;
|
||||
|
||||
for (const idx in history) {
|
||||
const { notePath } = link.parseNavigationStateFromUrl(history[idx].url);
|
||||
if (!notePath) continue;
|
||||
const url = history[idx];
|
||||
const parts = url.split("#");
|
||||
if (parts.length < 2) continue;
|
||||
|
||||
const notePathWithTab = parts[1];
|
||||
const notePath = notePathWithTab.split("-")[0];
|
||||
|
||||
const title = await treeService.getNotePathTitle(notePath);
|
||||
|
||||
items.push({
|
||||
title,
|
||||
command: idx,
|
||||
idx,
|
||||
uiIcon:
|
||||
parseInt(idx) === activeIndex
|
||||
? "bx bx-radio-circle-marked" // compare with type coercion!
|
||||
@@ -75,10 +92,9 @@ export default class HistoryNavigationButton extends ButtonFromNoteWidget {
|
||||
x: e.pageX,
|
||||
y: e.pageY,
|
||||
items,
|
||||
selectMenuItemHandler: (item: MenuCommandItem<string>) => {
|
||||
if (item && item.command && this.webContents) {
|
||||
const idx = parseInt(item.command, 10);
|
||||
this.webContents.navigationHistory.goToIndex(idx);
|
||||
selectMenuItemHandler: (item: any) => {
|
||||
if (item && item.idx && this.webContents) {
|
||||
this.webContents.goToIndex(item.idx);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
43
apps/client/src/widgets/buttons/left_pane_toggle.ts
Normal file
43
apps/client/src/widgets/buttons/left_pane_toggle.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
import options from "../../services/options.js";
|
||||
import splitService from "../../services/resizer.js";
|
||||
import CommandButtonWidget from "./command_button.js";
|
||||
import { t } from "../../services/i18n.js";
|
||||
import type { EventData } from "../../components/app_context.js";
|
||||
|
||||
export default class LeftPaneToggleWidget extends CommandButtonWidget {
|
||||
private currentLeftPaneVisible: boolean;
|
||||
|
||||
constructor(isHorizontalLayout: boolean) {
|
||||
super();
|
||||
|
||||
this.currentLeftPaneVisible = options.is("leftPaneVisible");
|
||||
|
||||
this.class(isHorizontalLayout ? "toggle-button" : "launcher-button");
|
||||
|
||||
this.settings.icon = () => {
|
||||
if (options.get("layoutOrientation") === "horizontal") {
|
||||
return "bx-sidebar";
|
||||
}
|
||||
|
||||
return this.currentLeftPaneVisible ? "bx-chevrons-left" : "bx-chevrons-right";
|
||||
};
|
||||
|
||||
this.settings.title = () => (this.currentLeftPaneVisible ? t("left_pane_toggle.hide_panel") : t("left_pane_toggle.show_panel"));
|
||||
|
||||
this.settings.command = () => (this.currentLeftPaneVisible ? "hideLeftPane" : "showLeftPane");
|
||||
|
||||
if (isHorizontalLayout) {
|
||||
this.settings.titlePlacement = "bottom";
|
||||
}
|
||||
}
|
||||
|
||||
refreshIcon() {
|
||||
super.refreshIcon();
|
||||
splitService.setupLeftPaneResizer(this.currentLeftPaneVisible);
|
||||
}
|
||||
|
||||
setLeftPaneVisibilityEvent({ leftPaneVisible }: EventData<"setLeftPaneVisibility">) {
|
||||
this.currentLeftPaneVisible = leftPaneVisible ?? !this.currentLeftPaneVisible;
|
||||
this.refreshIcon();
|
||||
}
|
||||
}
|
||||
@@ -1,29 +0,0 @@
|
||||
import { useEffect, useState } from "preact/hooks";
|
||||
import ActionButton from "../react/ActionButton";
|
||||
import options from "../../services/options";
|
||||
import { t } from "../../services/i18n";
|
||||
import { useTriliumEvent } from "../react/hooks";
|
||||
import resizer from "../../services/resizer";
|
||||
|
||||
export default function LeftPaneToggle({ isHorizontalLayout }: { isHorizontalLayout: boolean }) {
|
||||
const [ currentLeftPaneVisible, setCurrentLeftPaneVisible ] = useState(options.is("leftPaneVisible"));
|
||||
|
||||
useTriliumEvent("setLeftPaneVisibility", ({ leftPaneVisible }) => {
|
||||
setCurrentLeftPaneVisible(leftPaneVisible ?? !currentLeftPaneVisible);
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
resizer.setupLeftPaneResizer(currentLeftPaneVisible);
|
||||
}, [ currentLeftPaneVisible ]);
|
||||
|
||||
return (
|
||||
<ActionButton
|
||||
className={`${isHorizontalLayout ? "toggle-button" : "launcher-button"}`}
|
||||
text={currentLeftPaneVisible ? t("left_pane_toggle.hide_panel") : t("left_pane_toggle.show_panel")}
|
||||
triggerCommand={currentLeftPaneVisible ? "hideLeftPane" : "showLeftPane"}
|
||||
icon={isHorizontalLayout
|
||||
? "bx bx-sidebar"
|
||||
: (currentLeftPaneVisible ? "bx bx-chevrons-left" : "bx bx-chevrons-right" )}
|
||||
/>
|
||||
)
|
||||
}
|
||||
252
apps/client/src/widgets/buttons/note_actions.ts
Normal file
252
apps/client/src/widgets/buttons/note_actions.ts
Normal file
@@ -0,0 +1,252 @@
|
||||
import NoteContextAwareWidget from "../note_context_aware_widget.js";
|
||||
import utils from "../../services/utils.js";
|
||||
import branchService from "../../services/branches.js";
|
||||
import dialogService from "../../services/dialog.js";
|
||||
import server from "../../services/server.js";
|
||||
import toastService from "../../services/toast.js";
|
||||
import ws from "../../services/ws.js";
|
||||
import appContext, { type EventData } from "../../components/app_context.js";
|
||||
import { t } from "../../services/i18n.js";
|
||||
import type FNote from "../../entities/fnote.js";
|
||||
import type { FAttachmentRow } from "../../entities/fattachment.js";
|
||||
|
||||
// TODO: Deduplicate with server
|
||||
interface ConvertToAttachmentResponse {
|
||||
attachment: FAttachmentRow;
|
||||
}
|
||||
|
||||
const TPL = /*html*/`
|
||||
<div class="dropdown note-actions">
|
||||
<style>
|
||||
.note-actions {
|
||||
width: 35px;
|
||||
height: 35px;
|
||||
}
|
||||
|
||||
.note-actions .dropdown-menu {
|
||||
min-width: 15em;
|
||||
}
|
||||
|
||||
.note-actions .dropdown-item .bx {
|
||||
position: relative;
|
||||
top: 3px;
|
||||
font-size: 120%;
|
||||
margin-right: 5px;
|
||||
}
|
||||
|
||||
.note-actions .dropdown-item[disabled], .note-actions .dropdown-item[disabled]:hover {
|
||||
color: var(--muted-text-color) !important;
|
||||
background-color: transparent !important;
|
||||
pointer-events: none; /* makes it unclickable */
|
||||
}
|
||||
|
||||
</style>
|
||||
|
||||
<button type="button" data-bs-toggle="dropdown" aria-haspopup="true" aria-expanded="false"
|
||||
class="icon-action bx bx-dots-vertical-rounded"></button>
|
||||
|
||||
<div class="dropdown-menu dropdown-menu-right">
|
||||
<li data-trigger-command="convertNoteIntoAttachment" class="dropdown-item">
|
||||
<span class="bx bx-paperclip"></span> ${t("note_actions.convert_into_attachment")}
|
||||
</li>
|
||||
|
||||
<li data-trigger-command="renderActiveNote" class="dropdown-item render-note-button">
|
||||
<span class="bx bx-extension"></span> ${t("note_actions.re_render_note")}<kbd data-command="renderActiveNote"></kbd>
|
||||
</li>
|
||||
|
||||
<li data-trigger-command="findInText" class="dropdown-item find-in-text-button">
|
||||
<span class='bx bx-search'></span> ${t("note_actions.search_in_note")}<kbd data-command="findInText"></kbd>
|
||||
</li>
|
||||
|
||||
<li data-trigger-command="printActiveNote" class="dropdown-item print-active-note-button">
|
||||
<span class="bx bx-printer"></span> ${t("note_actions.print_note")}<kbd data-command="printActiveNote"></kbd>
|
||||
</li>
|
||||
|
||||
<li data-trigger-command="exportAsPdf" class="dropdown-item export-as-pdf-button">
|
||||
<span class="bx bxs-file-pdf"></span> ${t("note_actions.print_pdf")}<kbd data-command="exportAsPdf"></kbd>
|
||||
</li>
|
||||
|
||||
<div class="dropdown-divider"></div>
|
||||
|
||||
|
||||
<li class="dropdown-item import-files-button"><span class="bx bx-import"></span> ${t("note_actions.import_files")}</li>
|
||||
|
||||
<li class="dropdown-item export-note-button"><span class="bx bx-export"></span> ${t("note_actions.export_note")}</li>
|
||||
|
||||
|
||||
<div class="dropdown-divider"></div>
|
||||
|
||||
|
||||
|
||||
<li data-trigger-command="openNoteExternally" class="dropdown-item open-note-externally-button" title="${t("note_actions.open_note_externally_title")}">
|
||||
<span class="bx bx-file-find"></span> ${t("note_actions.open_note_externally")}<kbd data-command="openNoteExternally"></kbd>
|
||||
</li>
|
||||
|
||||
<li data-trigger-command="openNoteCustom" class="dropdown-item open-note-custom-button">
|
||||
<span class="bx bx-customize"></span> ${t("note_actions.open_note_custom")}<kbd data-command="openNoteCustom"></kbd>
|
||||
</li>
|
||||
|
||||
<li data-trigger-command="showNoteSource" class="dropdown-item show-source-button">
|
||||
<span class="bx bx-code"></span> ${t("note_actions.note_source")}<kbd data-command="showNoteSource"></kbd>
|
||||
</li>
|
||||
|
||||
|
||||
<div class="dropdown-divider"></div>
|
||||
|
||||
|
||||
<li data-trigger-command="forceSaveRevision" class="dropdown-item save-revision-button">
|
||||
<span class="bx bx-save"></span> ${t("note_actions.save_revision")}<kbd data-command="forceSaveRevision"></kbd>
|
||||
</li>
|
||||
|
||||
<li class="dropdown-item delete-note-button"><span class="bx bx-trash destructive-action-icon"></span> ${t("note_actions.delete_note")}</li>
|
||||
|
||||
|
||||
<div class="dropdown-divider"></div>
|
||||
|
||||
|
||||
<li data-trigger-command="showAttachments" class="dropdown-item show-attachments-button">
|
||||
<span class="bx bx-paperclip"></span> ${t("note_actions.note_attachments")}<kbd data-command="showAttachments"></kbd>
|
||||
</li>
|
||||
</div>
|
||||
</div>`;
|
||||
|
||||
export default class NoteActionsWidget extends NoteContextAwareWidget {
|
||||
|
||||
private $convertNoteIntoAttachmentButton!: JQuery<HTMLElement>;
|
||||
private $findInTextButton!: JQuery<HTMLElement>;
|
||||
private $printActiveNoteButton!: JQuery<HTMLElement>;
|
||||
private $exportAsPdfButton!: JQuery<HTMLElement>;
|
||||
private $showSourceButton!: JQuery<HTMLElement>;
|
||||
private $showAttachmentsButton!: JQuery<HTMLElement>;
|
||||
private $renderNoteButton!: JQuery<HTMLElement>;
|
||||
private $saveRevisionButton!: JQuery<HTMLElement>;
|
||||
private $exportNoteButton!: JQuery<HTMLElement>;
|
||||
private $importNoteButton!: JQuery<HTMLElement>;
|
||||
private $openNoteExternallyButton!: JQuery<HTMLElement>;
|
||||
private $openNoteCustomButton!: JQuery<HTMLElement>;
|
||||
private $deleteNoteButton!: JQuery<HTMLElement>;
|
||||
|
||||
isEnabled() {
|
||||
return this.note?.type !== "launcher";
|
||||
}
|
||||
|
||||
doRender() {
|
||||
this.$widget = $(TPL);
|
||||
this.$widget.on("show.bs.dropdown", () => {
|
||||
if (this.note) {
|
||||
this.refreshVisibility(this.note);
|
||||
}
|
||||
});
|
||||
|
||||
this.$convertNoteIntoAttachmentButton = this.$widget.find("[data-trigger-command='convertNoteIntoAttachment']");
|
||||
this.$findInTextButton = this.$widget.find(".find-in-text-button");
|
||||
this.$printActiveNoteButton = this.$widget.find(".print-active-note-button");
|
||||
this.$exportAsPdfButton = this.$widget.find(".export-as-pdf-button");
|
||||
this.$showSourceButton = this.$widget.find(".show-source-button");
|
||||
this.$showAttachmentsButton = this.$widget.find(".show-attachments-button");
|
||||
this.$renderNoteButton = this.$widget.find(".render-note-button");
|
||||
this.$saveRevisionButton = this.$widget.find(".save-revision-button");
|
||||
|
||||
this.$exportNoteButton = this.$widget.find(".export-note-button");
|
||||
this.$exportNoteButton.on("click", () => {
|
||||
if (this.$exportNoteButton.hasClass("disabled") || !this.noteContext?.notePath) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.triggerCommand("showExportDialog", {
|
||||
notePath: this.noteContext.notePath,
|
||||
defaultType: "single"
|
||||
});
|
||||
});
|
||||
|
||||
this.$importNoteButton = this.$widget.find(".import-files-button");
|
||||
this.$importNoteButton.on("click", () => {
|
||||
if (this.noteId) {
|
||||
this.triggerCommand("showImportDialog", { noteId: this.noteId });
|
||||
}
|
||||
});
|
||||
|
||||
this.$widget.on("click", ".dropdown-item", () => this.$widget.find("[data-bs-toggle='dropdown']").dropdown("toggle"));
|
||||
|
||||
this.$openNoteExternallyButton = this.$widget.find(".open-note-externally-button");
|
||||
this.$openNoteCustomButton = this.$widget.find(".open-note-custom-button");
|
||||
|
||||
this.$deleteNoteButton = this.$widget.find(".delete-note-button");
|
||||
this.$deleteNoteButton.on("click", () => {
|
||||
if (!this.note || this.note.noteId === "root") {
|
||||
return;
|
||||
}
|
||||
|
||||
branchService.deleteNotes([this.note.getParentBranches()[0].branchId], true);
|
||||
});
|
||||
}
|
||||
|
||||
async refreshVisibility(note: FNote) {
|
||||
const isInOptions = note.noteId.startsWith("_options");
|
||||
|
||||
this.$convertNoteIntoAttachmentButton.toggle(note.isEligibleForConversionToAttachment());
|
||||
|
||||
this.toggleDisabled(this.$findInTextButton, ["text", "code", "book", "mindMap", "doc"].includes(note.type));
|
||||
|
||||
this.toggleDisabled(this.$showAttachmentsButton, !isInOptions);
|
||||
this.toggleDisabled(this.$showSourceButton, ["text", "code", "relationMap", "mermaid", "canvas", "mindMap"].includes(note.type));
|
||||
|
||||
const canPrint = ["text", "code"].includes(note.type);
|
||||
this.toggleDisabled(this.$printActiveNoteButton, canPrint);
|
||||
this.toggleDisabled(this.$exportAsPdfButton, canPrint);
|
||||
this.$exportAsPdfButton.toggleClass("hidden-ext", !utils.isElectron());
|
||||
|
||||
this.$renderNoteButton.toggle(note.type === "render");
|
||||
|
||||
this.toggleDisabled(this.$openNoteExternallyButton, utils.isElectron() && !["search", "book"].includes(note.type));
|
||||
this.toggleDisabled(
|
||||
this.$openNoteCustomButton,
|
||||
utils.isElectron() &&
|
||||
!utils.isMac() && // no implementation for Mac yet
|
||||
!["search", "book"].includes(note.type)
|
||||
);
|
||||
|
||||
// I don't want to handle all special notes like this, but intuitively user might want to export content of backend log
|
||||
this.toggleDisabled(this.$exportNoteButton, !["_backendLog"].includes(note.noteId) && !isInOptions);
|
||||
|
||||
this.toggleDisabled(this.$importNoteButton, !["search"].includes(note.type) && !isInOptions);
|
||||
this.toggleDisabled(this.$deleteNoteButton, !isInOptions);
|
||||
this.toggleDisabled(this.$saveRevisionButton, !isInOptions);
|
||||
}
|
||||
|
||||
async convertNoteIntoAttachmentCommand() {
|
||||
if (!this.note || !(await dialogService.confirm(t("note_actions.convert_into_attachment_prompt", { title: this.note.title })))) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { attachment: newAttachment } = await server.post<ConvertToAttachmentResponse>(`notes/${this.noteId}/convert-to-attachment`);
|
||||
|
||||
if (!newAttachment) {
|
||||
toastService.showMessage(t("note_actions.convert_into_attachment_failed", { title: this.note.title }));
|
||||
return;
|
||||
}
|
||||
|
||||
toastService.showMessage(t("note_actions.convert_into_attachment_successful", { title: newAttachment.title }));
|
||||
await ws.waitForMaxKnownEntityChangeId();
|
||||
await appContext.tabManager.getActiveContext()?.setNote(newAttachment.ownerId, {
|
||||
viewScope: {
|
||||
viewMode: "attachments",
|
||||
attachmentId: newAttachment.attachmentId
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
toggleDisabled($el: JQuery<HTMLElement>, enable: boolean) {
|
||||
if (enable) {
|
||||
$el.removeAttr("disabled");
|
||||
} else {
|
||||
$el.attr("disabled", "disabled");
|
||||
}
|
||||
}
|
||||
|
||||
entitiesReloadedEvent({ loadResults }: EventData<"entitiesReloaded">) {
|
||||
if (loadResults.isNoteReloaded(this.noteId)) {
|
||||
this.refresh();
|
||||
}
|
||||
}
|
||||
}
|
||||
14
apps/client/src/widgets/buttons/revisions_button.ts
Normal file
14
apps/client/src/widgets/buttons/revisions_button.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import { t } from "../../services/i18n.js";
|
||||
import CommandButtonWidget from "./command_button.js";
|
||||
|
||||
export default class RevisionsButton extends CommandButtonWidget {
|
||||
constructor() {
|
||||
super();
|
||||
|
||||
this.icon("bx-history").title(t("revisions_button.note_revisions")).command("showRevisions").titlePlacement("bottom").class("icon-action");
|
||||
}
|
||||
|
||||
isEnabled() {
|
||||
return super.isEnabled() && !["launcher", "doc"].includes(this.note?.type ?? "");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
import OnClickButtonWidget from "./onclick_button.js";
|
||||
import appContext from "../../components/app_context.js";
|
||||
import attributeService from "../../services/attributes.js";
|
||||
import { t } from "../../services/i18n.js";
|
||||
import LoadResults from "../../services/load_results.js";
|
||||
import type { AttributeRow } from "../../services/load_results.js";
|
||||
|
||||
export default class ShowHighlightsListWidgetButton extends OnClickButtonWidget {
|
||||
isEnabled(): boolean {
|
||||
return Boolean(super.isEnabled() && this.note && this.note.type === "text" && this.noteContext?.viewScope?.viewMode === "default");
|
||||
}
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
|
||||
this.icon("bx-bookmarks")
|
||||
.title(t("show_highlights_list_widget_button.show_highlights_list"))
|
||||
.titlePlacement("bottom")
|
||||
.onClick(() => {
|
||||
if (this.noteContext?.viewScope && this.noteId) {
|
||||
this.noteContext.viewScope.highlightsListTemporarilyHidden = false;
|
||||
appContext.triggerEvent("showHighlightsListWidget", { noteId: this.noteId });
|
||||
}
|
||||
this.toggleInt(false);
|
||||
});
|
||||
}
|
||||
|
||||
async refreshWithNote(): Promise<void> {
|
||||
if (this.noteContext?.viewScope) {
|
||||
this.toggleInt(this.noteContext.viewScope.highlightsListTemporarilyHidden);
|
||||
}
|
||||
}
|
||||
|
||||
async reEvaluateHighlightsListWidgetVisibilityEvent({ noteId }: { noteId: string }): Promise<void> {
|
||||
if (noteId === this.noteId) {
|
||||
await this.refresh();
|
||||
}
|
||||
}
|
||||
|
||||
async entitiesReloadedEvent({ loadResults }: { loadResults: LoadResults }): Promise<void> {
|
||||
if (this.noteId && loadResults.isNoteContentReloaded(this.noteId)) {
|
||||
await this.refresh();
|
||||
} else if (
|
||||
loadResults
|
||||
.getAttributeRows()
|
||||
.find((attr: AttributeRow) =>
|
||||
attr.type === "label" &&
|
||||
(attr.name?.toLowerCase().includes("readonly") || attr.name === "hideHighlightWidget") &&
|
||||
this.note &&
|
||||
attributeService.isAffecting(attr, this.note)
|
||||
)
|
||||
) {
|
||||
await this.refresh();
|
||||
}
|
||||
}
|
||||
|
||||
async noteTypeMimeChangedEvent({ noteId }: { noteId: string }): Promise<void> {
|
||||
if (this.isNote(noteId)) {
|
||||
await this.refresh();
|
||||
}
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user