Compare commits

..

36 Commits

Author SHA1 Message Date
Elian Doran
b9cef158d8 Merge remote-tracking branch 'origin/main' into feat/add-ocr-capabilities 2025-07-31 08:25:30 +03:00
Elian Doran
5ec6141369 feat(ocr): filter out text based on confidence 2025-07-26 14:57:12 +03:00
Elian Doran
55ac1e01f2 chore(ocr): improve ocr search result style 2025-07-26 14:15:45 +03:00
Elian Doran
65b58c3668 feat(ocr): auto-process images only if enabled in settings 2025-07-26 14:12:22 +03:00
Elian Doran
2cb4e5e8dc feat(ocr): run the image operation in the background 2025-07-26 14:07:23 +03:00
Elian Doran
72cea245f1 feat(ocr): automatically process images 2025-07-26 14:00:35 +03:00
Elian Doran
08ca86c68a chore(deps): move workspace dependencies to server 2025-07-26 13:48:28 +03:00
Elian Doran
925c9c1e7b feat(ocr): display OCR text only in search results 2025-07-26 12:55:52 +03:00
Elian Doran
6212ea0304 feat(ocr): display OCR text in search results 2025-07-26 12:41:30 +03:00
Elian Doran
f295592134 fix(ocr): search error due to scoring 2025-07-26 12:33:45 +03:00
Elian Doran
69b0973e6d feat(ocr): add a button to trigger an OCR manually 2025-07-26 12:18:20 +03:00
Elian Doran
422d318dac feat(ocr): add an option to display OCR text 2025-07-26 12:08:04 +03:00
Elian Doran
c55aa6ee88 refactor(ocr): unnecessary initialization logic 2025-07-26 11:56:48 +03:00
Elian Doran
090b175152 refactor(ocr): deduplicate mime types partially 2025-07-26 11:51:53 +03:00
Elian Doran
11e9b097a2 feat(ocr): basic processing of new files 2025-07-26 11:46:28 +03:00
Elian Doran
2adfc1d32b chore(ci): remove unnecessary change 2025-07-26 11:24:42 +03:00
Elian Doran
99fa5d89e7 Merge remote-tracking branch 'origin/main' into feat/add-ocr-capabilities 2025-07-26 10:33:01 +03:00
perf3ct
ca8cbf8ccf feat(ocr): add additional processors for OCR feature 2025-07-16 20:10:56 +00:00
perf3ct
6722d2d266 feat(ocr): implement new language selection form 2025-07-16 20:10:41 +00:00
perf3ct
508cbeaa1b feat(ocr): update this new migration to also add a ocr_last_processed column 2025-07-16 20:10:07 +00:00
perf3ct
e040865905 feat(ocr): add officeparser, pdf-parse, and sharp dependencies for ocr 2025-07-16 20:09:41 +00:00
perf3ct
a7878dd2c6 Merge branch 'main' into feat/add-ocr-capabilities 2025-07-16 17:54:32 +00:00
Jon Fuller
02980834ad Merge branch 'main' into feat/add-ocr-capabilities 2025-07-15 10:10:47 -07:00
perf3ct
2a8c8871c4 fix(dev): resolve issues with pnpm-lock.yaml 2025-07-14 16:41:02 +00:00
perf3ct
893be24c1d merge main into feature branch 2025-07-14 16:38:22 +00:00
perf3ct
9029f59410 feat(ocr): swap from custom table to using the blobs table, with a new column 2025-07-14 16:15:15 +00:00
Jon Fuller
4b5e8d33a6 Update playwright.yml 2025-06-10 15:37:05 -07:00
perf3ct
09196c045f fix(ocr): obviously don't need this migration file anymore 2025-06-10 20:59:17 +00:00
perf3ct
7868ebec1e fix(unit): also fix broken llm test 2025-06-10 20:51:34 +00:00
perf3ct
80a9182f05 feat(unit): ocr tests almost pass... 2025-06-10 20:41:40 +00:00
perf3ct
d20b3d854f feat(unit): ocr tests almost pass... 2025-06-10 20:36:52 +00:00
perf3ct
f1356228a3 feat(unit): ocr unit tests almost pass 2025-06-10 20:22:31 +00:00
perf3ct
a4adc51e50 fix(unit): resolve typecheck errors 2025-06-10 19:48:48 +00:00
perf3ct
864543e4f9 feat(ocr): drop confidence down a little bit 2025-06-10 19:22:46 +00:00
perf3ct
33a549202b fix(package): referenced wrong tesseract.js lol 2025-06-10 19:19:17 +00:00
perf3ct
c4a0219b18 feat(ocr): add unit tests, resolve double sent headers, and fix the wonderful tesseract.js path issues 2025-06-10 19:12:50 +00:00
1184 changed files with 55379 additions and 83651 deletions

View File

@@ -1,6 +1,6 @@
root = true
[*.{js,ts,tsx}]
[*.{js,ts}]
charset = utf-8
end_of_line = lf
indent_size = 4

1
.env Normal file
View File

@@ -0,0 +1 @@
NODE_OPTIONS=--max_old_space_size=4096

View File

@@ -74,7 +74,7 @@ runs:
- name: Update build info
shell: ${{ inputs.shell }}
run: pnpm run chore:update-build-info
run: npm run chore:update-build-info
# Critical debugging configuration
- name: Run electron-forge build with enhanced logging
@@ -86,8 +86,7 @@ runs:
APPLE_ID_PASSWORD: ${{ env.APPLE_ID_PASSWORD }}
WINDOWS_SIGN_EXECUTABLE: ${{ env.WINDOWS_SIGN_EXECUTABLE }}
TRILIUM_ARTIFACT_NAME_HINT: TriliumNotes-${{ github.ref_name }}-${{ inputs.os }}-${{ inputs.arch }}
TARGET_ARCH: ${{ inputs.arch }}
run: pnpm run --filter desktop electron-forge:make --arch=${{ inputs.arch }} --platform=${{ inputs.forge_platform }}
run: pnpm nx --project=desktop electron-forge:make -- --arch=${{ inputs.arch }} --platform=${{ inputs.forge_platform }}
# Add DMG signing step
- name: Sign DMG
@@ -163,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

View File

@@ -10,7 +10,7 @@ runs:
steps:
- uses: pnpm/action-setup@v4
- name: Set up node & dependencies
uses: actions/setup-node@v5
uses: actions/setup-node@v4
with:
node-version: 22
cache: "pnpm"
@@ -23,7 +23,7 @@ runs:
shell: bash
run: |
pnpm run chore:update-build-info
pnpm run --filter server package
pnpm nx --project=server package
- name: Prepare artifacts
shell: bash
run: |

View File

@@ -1,103 +0,0 @@
name: "Deploy to Cloudflare Pages"
description: "Deploys to Cloudflare Pages on either a temporary branch with preview comment, or on the production version if on the main branch."
inputs:
project_name:
description: "CloudFlare Pages project name"
comment_body:
description: "The message to display when deployment is ready"
default: "Deployment is ready."
required: false
production_url:
description: "The URL to mention as the production URL."
required: true
deploy_dir:
description: "The directory from which to deploy."
required: true
cloudflare_api_token:
description: "The Cloudflare API token to use for deployment."
required: true
cloudflare_account_id:
description: "The Cloudflare account ID to use for deployment."
required: true
github_token:
description: "The GitHub token to use for posting PR comments."
required: true
runs:
using: composite
steps:
# Install wrangler globally to avoid workspace issues
- name: Install Wrangler
shell: bash
run: npm install -g wrangler
# Deploy using Wrangler (use pre-installed wrangler)
- name: Deploy to Cloudflare Pages
id: deploy
if: github.event_name == 'push' || github.event_name == 'workflow_dispatch'
uses: cloudflare/wrangler-action@v3
with:
apiToken: ${{ inputs.cloudflare_api_token }}
accountId: ${{ inputs.cloudflare_account_id }}
command: pages deploy ${{ inputs.deploy_dir }} --project-name=${{ inputs.project_name}} --branch=${{ github.ref_name }}
wranglerVersion: '' # Use pre-installed version
# Deploy preview for PRs
- name: Deploy Preview to Cloudflare Pages
id: preview-deployment
if: github.event_name == 'pull_request'
uses: cloudflare/wrangler-action@v3
with:
apiToken: ${{ inputs.cloudflare_api_token }}
accountId: ${{ inputs.cloudflare_account_id }}
command: pages deploy ${{ inputs.deploy_dir }} --project-name=${{ inputs.project_name}} --branch=pr-${{ github.event.pull_request.number }}
wranglerVersion: '' # Use pre-installed version
# Post deployment URL as PR comment
- name: Comment PR with Preview URL
if: github.event_name == 'pull_request'
uses: actions/github-script@v8
env:
COMMENT_BODY: ${{ inputs.comment_body }}
PRODUCTION_URL: ${{ inputs.production_url }}
PROJECT_NAME: ${{ inputs.project_name }}
with:
github-token: ${{ inputs.github_token }}
script: |
const prNumber = context.issue.number;
// Construct preview URL based on Cloudflare Pages pattern
const projectName = process.env.PROJECT_NAME;
const previewUrl = `https://pr-${prNumber}.${projectName}.pages.dev`;
// Check if we already commented
const comments = await github.rest.issues.listComments({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: prNumber
});
const customMessage = process.env.COMMENT_BODY;
const botComment = comments.data.find(comment =>
comment.user.type === 'Bot' &&
comment.body.includes(customMessage)
);
const mainUrl = process.env.PRODUCTION_URL;
const commentBody = `${customMessage}!\n\n🔗 Preview URL: ${previewUrl}\n📖 Production URL: ${mainUrl}\n\n✅ All checks passed\n\n_This preview will be updated automatically with new commits._`;
if (botComment) {
// Update existing comment
await github.rest.issues.updateComment({
owner: context.repo.owner,
repo: context.repo.repo,
comment_id: botComment.id,
body: commentBody
});
} else {
// Create new comment
await github.rest.issues.createComment({
issue_number: prNumber,
owner: context.repo.owner,
repo: context.repo.repo,
body: commentBody
});
}

View File

@@ -44,7 +44,7 @@ runs:
steps:
# Checkout branch to compare to [required]
- name: Checkout base branch
uses: actions/checkout@v5
uses: actions/checkout@v4
with:
ref: ${{ inputs.branch }}
path: br-base

40
.github/instructions/nx.instructions.md vendored Normal file
View File

@@ -0,0 +1,40 @@
---
applyTo: '**'
---
// This file is automatically generated by Nx Console
You are in an nx workspace using Nx 21.3.7 and pnpm as the package manager.
You have access to the Nx MCP server and the tools it provides. Use them. Follow these guidelines in order to best help the user:
# General Guidelines
- When answering questions, use the nx_workspace tool first to gain an understanding of the workspace architecture
- For questions around nx configuration, best practices or if you're unsure, use the nx_docs tool to get relevant, up-to-date docs!! Always use this instead of assuming things about nx configuration
- If the user needs help with an Nx configuration or project graph error, use the 'nx_workspace' tool to get any errors
- To help answer questions about the workspace structure or simply help with demonstrating how tasks depend on each other, use the 'nx_visualize_graph' tool
# Generation Guidelines
If the user wants to generate something, use the following flow:
- learn about the nx workspace and any specifics the user needs by using the 'nx_workspace' tool and the 'nx_project_details' tool if applicable
- get the available generators using the 'nx_generators' tool
- decide which generator to use. If no generators seem relevant, check the 'nx_available_plugins' tool to see if the user could install a plugin to help them
- get generator details using the 'nx_generator_schema' tool
- you may use the 'nx_docs' tool to learn more about a specific generator or technology if you're unsure
- decide which options to provide in order to best complete the user's request. Don't make any assumptions and keep the options minimalistic
- open the generator UI using the 'nx_open_generate_ui' tool
- wait for the user to finish the generator
- read the generator log file using the 'nx_read_generator_log' tool
- use the information provided in the log file to answer the user's question or continue with what they were doing
# Running Tasks Guidelines
If the user wants help with tasks or commands (which include keywords like "test", "build", "lint", or other similar actions), use the following flow:
- Use the 'nx_current_running_tasks_details' tool to get the list of tasks (this can include tasks that were completed, stopped or failed).
- If there are any tasks, ask the user if they would like help with a specific task then use the 'nx_current_running_task_output' tool to get the terminal output for that task/command
- Use the terminal output from 'nx_current_running_task_output' to see what's wrong and help the user fix their problem. Use the appropriate tools if necessary
- If the user would like to rerun the task or command, always use `nx run <taskId>` to rerun in the terminal. This will ensure that the task will run in the nx context and will be run the same way it originally executed
- If the task was marked as "continuous" do not offer to rerun the task. This task is already running and the user can see the output in the terminal. You can use 'nx_current_running_task_output' to get the output of the task to verify the output.

View File

@@ -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 }}"

View File

@@ -57,7 +57,7 @@ jobs:
# your codebase is analyzed, see https://docs.github.com/en/code-security/code-scanning/creating-an-advanced-setup-for-code-scanning/codeql-code-scanning-for-compiled-languages
steps:
- name: Checkout repository
uses: actions/checkout@v5
uses: actions/checkout@v4
# Add any setup steps before running the `github/codeql-action/init` action.
# This includes steps like installing compilers or runtimes (`actions/setup-node`

View File

@@ -1,128 +0,0 @@
# GitHub Actions workflow for deploying MkDocs documentation to Cloudflare Pages
# This workflow builds and deploys your MkDocs site when changes are pushed to main
name: Deploy MkDocs Documentation
on:
# Trigger on push to main branch
push:
branches:
- main
- master # Also support master branch
# Only run when docs files change
paths:
- 'docs/**'
- 'README.md' # README is synced to docs/index.md
- 'mkdocs.yml'
- 'requirements-docs.txt'
- '.github/workflows/deploy-docs.yml'
- 'scripts/fix-mkdocs-structure.ts'
# Allow manual triggering from Actions tab
workflow_dispatch:
# Run on pull requests for preview deployments
pull_request:
branches:
- main
- master
paths:
- 'docs/**'
- 'README.md' # README is synced to docs/index.md
- 'mkdocs.yml'
- 'requirements-docs.txt'
- '.github/workflows/deploy-docs.yml'
- 'scripts/fix-mkdocs-structure.ts'
jobs:
build-and-deploy:
name: Build and Deploy MkDocs
runs-on: ubuntu-latest
timeout-minutes: 10
# Required permissions for deployment
permissions:
contents: read
deployments: write
pull-requests: write # For PR preview comments
id-token: write # For OIDC authentication (if needed)
steps:
- name: Checkout Repository
uses: actions/checkout@v5
with:
fetch-depth: 0 # Fetch all history for git info and mkdocs-git-revision-date plugin
- name: Setup Python
uses: actions/setup-python@v6
with:
python-version: '3.13'
cache: 'pip'
cache-dependency-path: 'requirements-docs.txt'
- name: Install MkDocs and Dependencies
run: |
pip install --upgrade pip
pip install -r requirements-docs.txt
env:
PIP_DISABLE_PIP_VERSION_CHECK: 1
# Setup pnpm before fixing docs structure
- name: Setup pnpm
uses: pnpm/action-setup@v4
# Setup Node.js with pnpm
- name: Setup Node.js
uses: actions/setup-node@v5
with:
node-version: '22'
cache: 'pnpm'
# Install Node.js dependencies for the TypeScript script
- name: Install Dependencies
run: |
pnpm install --frozen-lockfile
- name: Fix Documentation Structure
run: |
# Fix duplicate navigation entries by moving overview pages to index.md
pnpm run chore:fix-mkdocs-structure
- name: Build MkDocs Site
run: |
# Build with strict mode but allow expected warnings
mkdocs build --verbose || {
EXIT_CODE=$?
# Check if the only issue is expected warnings
if mkdocs build 2>&1 | grep -E "WARNING.*(README|not found)" && \
[ $(mkdocs build 2>&1 | grep -c "ERROR") -eq 0 ]; then
echo "✅ Build succeeded with expected warnings"
mkdocs build --verbose
else
echo "❌ Build failed with unexpected errors"
exit $EXIT_CODE
fi
}
- name: Fix HTML Links
run: |
# Remove .md extensions from links in generated HTML
pnpm tsx ./scripts/fix-html-links.ts site
- name: Validate Built Site
run: |
# Basic validation that important files exist
test -f site/index.html || (echo "ERROR: site/index.html not found" && exit 1)
test -f site/sitemap.xml || (echo "ERROR: site/sitemap.xml not found" && exit 1)
test -d site/assets || (echo "ERROR: site/assets directory not found" && exit 1)
echo "✅ Site validation passed"
- name: Deploy
uses: ./.github/actions/deploy-to-cloudflare-pages
with:
project_name: "trilium-docs"
comment_body: "📚 Documentation preview is ready"
production_url: "https://docs.triliumnotes.org"
deploy_dir: "site"
cloudflare_api_token: ${{ secrets.CLOUDFLARE_API_TOKEN }}
cloudflare_account_id: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
github_token: ${{ secrets.GITHUB_TOKEN }}

View File

@@ -19,24 +19,45 @@ permissions:
pull-requests: write # for PR comments
jobs:
test_dev:
name: Test development
check-affected:
name: Check affected jobs (NX)
runs-on: ubuntu-latest
steps:
- name: Checkout the repository
uses: actions/checkout@v5
uses: actions/checkout@v4
with:
fetch-depth: 0 # needed for https://github.com/marketplace/actions/nx-set-shas
- uses: pnpm/action-setup@v4
- name: Set up node & dependencies
uses: actions/setup-node@v5
uses: actions/setup-node@v4
with:
node-version: 22
cache: 'pnpm'
- name: Install dependencies
run: pnpm install --frozen-lockfile
- uses: nrwl/nx-set-shas@v4
- name: Check affected
run: pnpm nx affected --verbose -t typecheck build rebuild-deps test-build
test_dev:
name: Test development
runs-on: ubuntu-latest
needs:
- check-affected
steps:
- name: Checkout the repository
uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
- name: Set up node & dependencies
uses: actions/setup-node@v4
with:
node-version: 22
cache: "pnpm"
- run: pnpm install --frozen-lockfile
- name: Typecheck
run: pnpm typecheck
- name: Run the unit tests
run: pnpm run test:all
@@ -45,15 +66,16 @@ jobs:
runs-on: ubuntu-latest
needs:
- test_dev
- check-affected
steps:
- uses: actions/checkout@v5
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Update build info
run: pnpm run chore:update-build-info
- name: Trigger client build
run: pnpm client:build
run: pnpm nx run client:build
- name: Send client bundle stats to RelativeCI
if: false
uses: relative-ci/agent-action@v3
@@ -61,7 +83,7 @@ jobs:
webpackStatsFile: ./apps/client/dist/webpack-stats.json
key: ${{ secrets.RELATIVE_CI_CLIENT_KEY }}
- name: Trigger server build
run: pnpm run server:build
run: pnpm nx run server:build
- uses: docker/setup-buildx-action@v3
- uses: docker/build-push-action@v6
with:
@@ -73,6 +95,7 @@ jobs:
runs-on: ubuntu-latest
needs:
- build_docker
- check-affected
strategy:
matrix:
include:
@@ -80,7 +103,7 @@ jobs:
- dockerfile: Dockerfile
steps:
- name: Checkout the repository
uses: actions/checkout@v5
uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
- name: Install dependencies
@@ -89,7 +112,7 @@ jobs:
- name: Update build info
run: pnpm run chore:update-build-info
- name: Trigger build
run: pnpm server:build
run: pnpm nx run server:build
- name: Set IMAGE_NAME to lowercase
run: echo "IMAGE_NAME=${IMAGE_NAME,,}" >> $GITHUB_ENV

View File

@@ -32,7 +32,7 @@ jobs:
- dockerfile: Dockerfile
steps:
- name: Checkout the repository
uses: actions/checkout@v5
uses: actions/checkout@v4
- name: Set IMAGE_NAME to lowercase
run: echo "IMAGE_NAME=${IMAGE_NAME,,}" >> $GITHUB_ENV
@@ -44,7 +44,7 @@ jobs:
- uses: pnpm/action-setup@v4
- name: Set up node & dependencies
uses: actions/setup-node@v5
uses: actions/setup-node@v4
with:
node-version: 22
cache: "pnpm"
@@ -82,7 +82,7 @@ jobs:
require-healthy: true
- name: Run Playwright tests
run: TRILIUM_DOCKER=1 TRILIUM_PORT=8082 pnpm --filter=server-e2e e2e
run: TRILIUM_DOCKER=1 TRILIUM_PORT=8082 pnpm exec nx run server-e2e:e2e
- name: Upload Playwright trace
if: failure()
@@ -141,10 +141,10 @@ jobs:
run: echo "TEST_TAG=${TEST_TAG,,}" >> $GITHUB_ENV
- name: Checkout repository
uses: actions/checkout@v5
uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
- name: Set up node & dependencies
uses: actions/setup-node@v5
uses: actions/setup-node@v4
with:
node-version: 22
cache: 'pnpm'
@@ -152,12 +152,12 @@ jobs:
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Update build info
run: pnpm run chore:update-build-info
- name: Run the TypeScript build
run: pnpm run server:build
- name: Update build info
run: pnpm run chore:update-build-info
- name: Docker meta
id: meta
uses: docker/metadata-action@v5
@@ -211,7 +211,7 @@ jobs:
- name: Upload digest
uses: actions/upload-artifact@v4
with:
name: digests-${{ env.PLATFORM_PAIR }}-${{ matrix.dockerfile }}
name: digests-${{ env.PLATFORM_PAIR }}
path: /tmp/digests/*
if-no-files-found: error
retention-days: 1
@@ -223,7 +223,7 @@ jobs:
- build
steps:
- name: Download digests
uses: actions/download-artifact@v5
uses: actions/download-artifact@v4
with:
path: /tmp/digests
pattern: digests-*

View File

@@ -19,6 +19,7 @@ concurrency:
cancel-in-progress: true
env:
GITHUB_UPLOAD_URL: https://uploads.github.com/repos/TriliumNext/Notes/releases/179589950/assets{?name,label}
GITHUB_RELEASE_ID: 179589950
permissions:
@@ -26,7 +27,6 @@ permissions:
jobs:
nightly-electron:
if: github.repository == ${{ vars.REPO_MAIN }}
name: Deploy nightly
strategy:
fail-fast: false
@@ -47,15 +47,16 @@ jobs:
forge_platform: win32
runs-on: ${{ matrix.os.image }}
steps:
- uses: actions/checkout@v5
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
- name: Set up node & dependencies
uses: actions/setup-node@v5
uses: actions/setup-node@v4
with:
node-version: 22
cache: 'pnpm'
- name: Install dependencies
run: pnpm install --frozen-lockfile
- uses: nrwl/nx-set-shas@v4
- name: Update nightly version
run: npm run chore:ci-update-nightly-version
- name: Run the build
@@ -74,10 +75,9 @@ 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.4
uses: softprops/action-gh-release@v2.3.2
if: ${{ github.event_name != 'pull_request' }}
with:
make_latest: false
@@ -96,7 +96,6 @@ jobs:
path: apps/desktop/upload
nightly-server:
if: github.repository == ${{ vars.REPO_MAIN }}
name: Deploy server nightly
strategy:
fail-fast: false
@@ -109,7 +108,7 @@ jobs:
runs-on: ubuntu-24.04-arm
runs-on: ${{ matrix.runs-on }}
steps:
- uses: actions/checkout@v5
- uses: actions/checkout@v4
- name: Run the build
uses: ./.github/actions/build-server
@@ -118,7 +117,7 @@ jobs:
arch: ${{ matrix.arch }}
- name: Publish release
uses: softprops/action-gh-release@v2.3.4
uses: softprops/action-gh-release@v2.3.2
if: ${{ github.event_name != 'pull_request' }}
with:
make_latest: false

View File

@@ -4,8 +4,6 @@ on:
push:
branches:
- main
paths-ignore:
- "apps/website/**"
pull_request:
permissions:
@@ -16,13 +14,19 @@ jobs:
main:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
- uses: actions/checkout@v4
with:
filter: tree:0
fetch-depth: 0
# This enables task distribution via Nx Cloud
# Run this command as early as possible, before dependencies are installed
# Learn more at https://nx.dev/ci/reference/nx-cloud-cli#npx-nxcloud-startcirun
# Connect your workspace by running "nx connect" and uncomment this line to enable task distribution
# - run: npx nx-cloud start-ci-run --distribute-on="3 linux-medium-js" --stop-agents-after="e2e-ci"
- uses: pnpm/action-setup@v4
- uses: actions/setup-node@v5
- uses: actions/setup-node@v4
with:
node-version: 22
cache: 'pnpm'
@@ -30,12 +34,9 @@ jobs:
- name: Install dependencies
run: pnpm install --frozen-lockfile
- run: pnpm exec playwright install --with-deps
- run: pnpm --filter server-e2e e2e
- name: Upload test report
if: failure()
uses: actions/upload-artifact@v4
with:
name: e2e report
path: apps/server-e2e/test-output
- uses: nrwl/nx-set-shas@v4
# Prepend any command with "nx-cloud record --" to record its logs to Nx Cloud
# - run: npx nx-cloud record -- echo Hello World
# Nx Affected runs only tasks affected by the changes in this PR/commit. Learn more: https://nx.dev/ci/features/affected
# When you enable task distribution, run the e2e-ci task instead of e2e
- run: pnpm exec nx affected -t e2e --exclude desktop-e2e

View File

@@ -30,30 +30,18 @@ jobs:
image: win-signing
shell: cmd
forge_platform: win32
# Exclude ARM64 Linux from default matrix to use native runner
exclude:
- arch: arm64
os:
name: linux
# Add ARM64 Linux with native ubuntu-24.04-arm runner for better-sqlite3 compatibility
include:
- arch: arm64
os:
name: linux
image: ubuntu-24.04-arm
shell: bash
forge_platform: linux
runs-on: ${{ matrix.os.image }}
steps:
- uses: actions/checkout@v5
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
- name: Set up node & dependencies
uses: actions/setup-node@v5
uses: actions/setup-node@v4
with:
node-version: 22
cache: 'pnpm'
- name: Install dependencies
run: pnpm install --frozen-lockfile
- uses: nrwl/nx-set-shas@v4
- name: Run the build
uses: ./.github/actions/build-electron
with:
@@ -70,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
@@ -91,7 +78,7 @@ jobs:
runs-on: ubuntu-24.04-arm
runs-on: ${{ matrix.runs-on }}
steps:
- uses: actions/checkout@v5
- uses: actions/checkout@v4
- name: Run the build
uses: ./.github/actions/build-server
@@ -114,20 +101,20 @@ jobs:
steps:
- run: mkdir upload
- uses: actions/checkout@v5
- uses: actions/checkout@v4
with:
sparse-checkout: |
docs/Release Notes
- name: Download all artifacts
uses: actions/download-artifact@v5
uses: actions/download-artifact@v4
with:
merge-multiple: true
pattern: release-*
path: upload
- name: Publish stable release
uses: softprops/action-gh-release@v2.3.4
uses: softprops/action-gh-release@v2.3.2
with:
draft: false
body_path: docs/Release Notes/Release Notes/${{ github.ref_name }}.md

View File

@@ -1,11 +0,0 @@
name: Unblock signing
on:
workflow_dispatch:
jobs:
unblock-win-signing:
runs-on: win-signing
steps:
- run: |
cat ${{ vars.WINDOWS_SIGN_ERROR_LOG }}
rm ${{ vars.WINDOWS_SIGN_ERROR_LOG }}

View File

@@ -1,48 +0,0 @@
name: Deploy website
on:
push:
branches:
- main
paths:
- "apps/website/**"
pull_request:
paths:
- "apps/website/**"
jobs:
build-and-deploy:
runs-on: ubuntu-latest
name: Build & deploy website
permissions:
contents: read
deployments: write
pull-requests: write # For PR preview comments
steps:
- uses: actions/checkout@v5
- uses: pnpm/action-setup@v4
- name: Set up node & dependencies
uses: actions/setup-node@v5
with:
node-version: 22
cache: "pnpm"
- name: Install dependencies
run: pnpm install --filter website --frozen-lockfile
- name: Build the website
run: pnpm website:build
- name: Deploy
uses: ./.github/actions/deploy-to-cloudflare-pages
with:
project_name: "trilium-homepage"
comment_body: "📚 Website preview is ready"
production_url: "https://triliumnotes.org"
deploy_dir: "apps/website/dist"
cloudflare_api_token: ${{ secrets.CLOUDFLARE_API_TOKEN }}
cloudflare_account_id: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
github_token: ${{ secrets.GITHUB_TOKEN }}

12
.gitignore vendored
View File

@@ -1,5 +1,4 @@
# See https://docs.github.com/en/get-started/getting-started-with-git/ignoring-files for more about ignoring files.
/.cache
# compiled output
dist
@@ -11,7 +10,6 @@ node_modules
# IDEs and editors
/.idea
.idea
.project
.classpath
.c9/
@@ -33,11 +31,14 @@ testem.log
.DS_Store
Thumbs.db
.nx/cache
.nx/workspace-data
vite.config.*.timestamp*
vitest.config.*.timestamp*
test-output
apps/*/data*
apps/*/data
apps/*/out
upload
@@ -45,7 +46,4 @@ upload
*.tsbuildinfo
/result
.svelte-kit
# docs
site/
.svelte-kit

6
.idea/.gitignore generated vendored Normal file
View File

@@ -0,0 +1,6 @@
# Default ignored files
/workspace.xml
# Datasource local storage ignored files
/dataSources.local.xml
/dataSources/

15
.idea/codeStyles/Project.xml generated Normal file
View File

@@ -0,0 +1,15 @@
<component name="ProjectCodeStyleConfiguration">
<code_scheme name="Project" version="173">
<option name="OTHER_INDENT_OPTIONS">
<value>
<option name="INDENT_SIZE" value="2" />
<option name="TAB_SIZE" value="2" />
</value>
</option>
<codeStyleSettings language="JSON">
<indentOptions>
<option name="INDENT_SIZE" value="4" />
</indentOptions>
</codeStyleSettings>
</code_scheme>
</component>

5
.idea/codeStyles/codeStyleConfig.xml generated Normal file
View File

@@ -0,0 +1,5 @@
<component name="ProjectCodeStyleConfiguration">
<state>
<option name="USE_PER_PROJECT_SETTINGS" value="true" />
</state>
</component>

12
.idea/dataSources.xml generated Normal file
View File

@@ -0,0 +1,12 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="DataSourceManagerImpl" format="xml" multifile-model="true">
<data-source source="LOCAL" name="document.db" uuid="2a4ac1e6-b828-4a2a-8e4a-3f59f10aff26">
<driver-ref>sqlite.xerial</driver-ref>
<synchronize>true</synchronize>
<jdbc-driver>org.sqlite.JDBC</jdbc-driver>
<jdbc-url>jdbc:sqlite:$PROJECT_DIR$/data/document.db</jdbc-url>
<working-dir>$ProjectFileDir$</working-dir>
</data-source>
</component>
</project>

4
.idea/encodings.xml generated Normal file
View File

@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="Encoding" addBOMForNewFiles="with NO BOM" />
</project>

15
.idea/git_toolbox_prj.xml generated Normal file
View File

@@ -0,0 +1,15 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="GitToolBoxProjectSettings">
<option name="commitMessageIssueKeyValidationOverride">
<BoolValueOverride>
<option name="enabled" value="true" />
</BoolValueOverride>
</option>
<option name="commitMessageValidationEnabledOverride">
<BoolValueOverride>
<option name="enabled" value="true" />
</BoolValueOverride>
</option>
</component>
</project>

View File

@@ -0,0 +1,11 @@
<component name="InspectionProjectProfileManager">
<profile version="1.0">
<option name="myName" value="Project Default" />
<inspection_tool class="Eslint" enabled="true" level="WARNING" enabled_by_default="true" />
<inspection_tool class="SpellCheckingInspection" enabled="false" level="TYPO" enabled_by_default="false">
<option name="processCode" value="true" />
<option name="processLiterals" value="true" />
<option name="processComments" value="true" />
</inspection_tool>
</profile>
</component>

6
.idea/jsLibraryMappings.xml generated Normal file
View File

@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="JavaScriptLibraryMappings">
<includedPredefinedLibrary name="Node.js Core" />
</component>
</project>

9
.idea/jsLinters/jslint.xml generated Normal file
View File

@@ -0,0 +1,9 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="JSLintConfiguration">
<option devel="true" />
<option es6="true" />
<option maxerr="50" />
<option node="true" />
</component>
</project>

8
.idea/misc.xml generated Normal file
View File

@@ -0,0 +1,8 @@
<project version="4">
<component name="JavaScriptSettings">
<option name="languageLevel" value="ES6" />
</component>
<component name="ProjectRootManager" version="2" languageLevel="JDK_16" default="true" project-jdk-name="openjdk-16" project-jdk-type="JavaSDK">
<output url="file://$PROJECT_DIR$/out" />
</component>
</project>

8
.idea/modules.xml generated Normal file
View File

@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="ProjectModuleManager">
<modules>
<module fileurl="file://$PROJECT_DIR$/trilium.iml" filepath="$PROJECT_DIR$/trilium.iml" />
</modules>
</component>
</project>

7
.idea/sqldialects.xml generated Normal file
View File

@@ -0,0 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="SqlDialectMappings">
<file url="file://$PROJECT_DIR$" dialect="SQLite" />
<file url="PROJECT" dialect="SQLite" />
</component>
</project>

6
.idea/vcs.xml generated Normal file
View File

@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="VcsDirectoryMappings">
<mapping directory="" vcs="Git" />
</component>
</project>

2
.nvmrc
View File

@@ -1 +1 @@
22.20.0
22.17.1

View File

@@ -5,6 +5,7 @@
"lokalise.i18n-ally",
"ms-azuretools.vscode-docker",
"ms-playwright.playwright",
"nrwl.angular-console",
"redhat.vscode-yaml",
"tobermory.es6-string-html",
"vitest.explorer",

View File

@@ -3,7 +3,6 @@
languageIds:
- javascript
- typescript
- typescriptreact
- html
# An array of RegExes to find the key usage. **The key should be captured in the first match group**.
@@ -26,10 +25,9 @@ scopeRangeRegex: "useTranslation\\(\\s*\\[?\\s*['\"`](.*?)['\"`]"
# The "$1" will be replaced by the keypath specified.
refactorTemplates:
- t("$1")
- {t("$1")}
- ${t("$1")}
- <%= t("$1") %>
# If set to true, only enables this custom framework (will disable all built-in frameworks)
monopoly: true
monopoly: true

8
.vscode/mcp.json vendored Normal file
View File

@@ -0,0 +1,8 @@
{
"servers": {
"nx-mcp": {
"type": "http",
"url": "http://localhost:9461/mcp"
}
}
}

View File

@@ -35,5 +35,6 @@
"docs/**/*.png": true,
"apps/server/src/assets/doc_notes/**": true,
"apps/edit-docs/demo/**": true
}
},
"nxConsole.generateAiAgentRules": true
}

View File

@@ -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 */"]
}
}

View File

@@ -4,7 +4,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
## Overview
Trilium Notes is a hierarchical note-taking application with advanced features like synchronization, scripting, and rich text editing. It's built as a TypeScript monorepo using pnpm, with multiple applications and shared packages.
Trilium Notes is a hierarchical note-taking application with advanced features like synchronization, scripting, and rich text editing. It's built as a TypeScript monorepo using NX, with multiple applications and shared packages.
## Development Commands
@@ -14,9 +14,12 @@ Trilium Notes is a hierarchical note-taking application with advanced features l
### Running Applications
- `pnpm run server:start` - Start development server (http://localhost:8080)
- `pnpm nx run server:serve` - Alternative server start command
- `pnpm nx run desktop:serve` - Run desktop Electron app
- `pnpm run server:start-prod` - Run server in production mode
### Building
- `pnpm nx build <project>` - Build specific project (server, client, desktop, etc.)
- `pnpm run client:build` - Build client application
- `pnpm run server:build` - Build server application
- `pnpm run electron:build` - Build desktop application
@@ -25,8 +28,13 @@ Trilium Notes is a hierarchical note-taking application with advanced features l
- `pnpm test:all` - Run all tests (parallel + sequential)
- `pnpm test:parallel` - Run tests that can run in parallel
- `pnpm test:sequential` - Run tests that must run sequentially (server, ckeditor5-mermaid, ckeditor5-math)
- `pnpm nx test <project>` - Run tests for specific project
- `pnpm coverage` - Generate coverage reports
### Linting & Type Checking
- `pnpm nx run <project>:lint` - Lint specific project
- `pnpm nx run <project>:typecheck` - Type check specific project
## Architecture Overview
### Monorepo Structure
@@ -86,6 +94,7 @@ Frontend uses a widget system (`apps/client/src/widgets/`):
- `apps/server/src/assets/db/schema.sql` - Core database structure
4. **Configuration**:
- `nx.json` - NX workspace configuration
- `package.json` - Project dependencies and scripts
## Note Types and Features
@@ -145,7 +154,7 @@ Trilium provides powerful user scripting capabilities:
- Update schema in `apps/server/src/assets/db/schema.sql`
## Build System Notes
- Uses pnpm for monorepo management
- Uses NX for monorepo management with build caching
- Vite for fast development builds
- ESBuild for production optimization
- pnpm workspaces for dependency management

115
README.md
View File

@@ -1,11 +1,12 @@
# Trilium Notes
![GitHub Sponsors](https://img.shields.io/github/sponsors/eliandoran) ![LiberaPay patrons](https://img.shields.io/liberapay/patrons/ElianDoran)
![Docker Pulls](https://img.shields.io/docker/pulls/triliumnext/trilium)
![GitHub Downloads (all assets, all releases)](https://img.shields.io/github/downloads/triliumnext/trilium/total)
[![RelativeCI](https://badges.relative-ci.com/badges/Di5q7dz9daNDZ9UXi0Bp?branch=develop)](https://app.relative-ci.com/projects/Di5q7dz9daNDZ9UXi0Bp) [![Translation status](https://hosted.weblate.org/widget/trilium/svg-badge.svg)](https://hosted.weblate.org/engage/trilium/)
Donate: ![GitHub Sponsors](https://img.shields.io/github/sponsors/eliandoran?style=flat-square) ![LiberaPay patrons](https://img.shields.io/liberapay/patrons/ElianDoran?style=flat-square)
[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)
![Docker Pulls](https://img.shields.io/docker/pulls/triliumnext/notes?style=flat-square)
![GitHub Downloads (all assets, all releases)](https://img.shields.io/github/downloads/triliumnext/notes/total?style=flat-square)
[![RelativeCI](https://badges.relative-ci.com/badges/Di5q7dz9daNDZ9UXi0Bp?branch=develop&style=flat-square)](https://app.relative-ci.com/projects/Di5q7dz9daNDZ9UXi0Bp)
[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.
@@ -13,23 +14,6 @@ See [screenshots](https://triliumnext.github.io/Docs/Wiki/screenshot-tour) for q
<a href="https://triliumnext.github.io/Docs/Wiki/screenshot-tour"><img src="./docs/app.png" alt="Trilium Screenshot" width="1000"></a>
## 📚 Documentation
**Visit our comprehensive documentation at [docs.triliumnotes.org](https://docs.triliumnotes.org/)**
Our documentation is available in multiple formats:
- **Online Documentation**: Browse the full documentation at [docs.triliumnotes.org](https://docs.triliumnotes.org/)
- **In-App Help**: Press `F1` within Trilium to access the same documentation directly in the application
- **GitHub**: Navigate through the [User Guide](./docs/User%20Guide/User%20Guide/) in this repository
### Quick Links
- [Getting Started Guide](https://docs.triliumnotes.org/)
- [Installation Instructions](./docs/User%20Guide/User%20Guide/Installation%20&%20Setup/Server%20Installation.md)
- [Docker Setup](./docs/User%20Guide/User%20Guide/Installation%20&%20Setup/Server%20Installation/1.%20Installing%20the%20server/Using%20Docker.md)
- [Upgrading TriliumNext](./docs/User%20Guide/User%20Guide/Installation%20%26%20Setup/Upgrading%20TriliumNext.md)
- [Basic Concepts and Features](./docs/User%20Guide/User%20Guide/Basic%20Concepts%20and%20Features/Notes.md)
- [Patterns of Personal Knowledge Base](https://triliumnext.github.io/Docs/Wiki/patterns-of-personal-knowledge)
## 🎁 Features
* Notes can be arranged into arbitrarily deep tree. Single note can be placed into multiple places in the tree (see [cloning](https://triliumnext.github.io/Docs/Wiki/cloning-notes))
@@ -63,15 +47,28 @@ Our documentation is available in multiple formats:
- [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
We're currently in the progress of moving the documentation to in-app (hit the `F1` key within Trilium). As a result, there may be some missing parts until we've completed the migration. If you'd prefer to navigate through the documentation within GitHub, you can navigate the [User Guide](./docs/User%20Guide/User%20Guide/) documentation.
Below are some quick links for your convenience to navigate the documentation:
- [Server installation](./docs/User%20Guide/User%20Guide/Installation%20&%20Setup/Server%20Installation.md)
- [Docker installation](./docs/User%20Guide/User%20Guide/Installation%20&%20Setup/Server%20Installation/1.%20Installing%20the%20server/Using%20Docker.md)
- [Upgrading TriliumNext](./docs/User%20Guide/User%20Guide/Installation%20%26%20Setup/Upgrading%20TriliumNext.md)
- [Concepts and Features - Note](./docs/User%20Guide/User%20Guide/Basic%20Concepts%20and%20Features/Notes.md)
- [Patterns of personal knowledge base](https://triliumnext.github.io/Docs/Wiki/patterns-of-personal-knowledge)
Until we finish reorganizing the documentation, you may also want to [browse the old documentation](https://triliumnext.github.io/Docs).
## 💬 Discuss with us
@@ -79,14 +76,14 @@ 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
### Windows / MacOS
Download the binary release for your platform from the [latest release page](https://github.com/TriliumNext/Trilium/releases/latest), unzip the package and run the `trilium` executable.
Download the binary release for your platform from the [latest release page](https://github.com/TriliumNext/Notes/releases/latest), unzip the package and run the `trilium` executable.
### Linux
@@ -94,7 +91,7 @@ If your distribution is listed in the table below, use your distribution's packa
[![Packaging status](https://repology.org/badge/vertical-allrepos/triliumnext.svg)](https://repology.org/project/triliumnext/versions)
You may also download the binary release for your platform from the [latest release page](https://github.com/TriliumNext/Trilium/releases/latest), unzip the package and run the `trilium` executable.
You may also download the binary release for your platform from the [latest release page](https://github.com/TriliumNext/Notes/releases/latest), unzip the package and run the `trilium` executable.
TriliumNext is also provided as a Flatpak, but not yet published on FlatHub.
@@ -108,27 +105,17 @@ 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
### Translations
If you are a native speaker, help us translate Trilium by heading over to our [Weblate page](https://hosted.weblate.org/engage/trilium/).
Here's the language coverage we have so far:
[![Translation status](https://hosted.weblate.org/widget/trilium/multi-auto.svg)](https://hosted.weblate.org/engage/trilium/)
### Code
Download the repository, install dependencies using `pnpm` and then run the server (available at http://localhost:8080):
@@ -146,7 +133,7 @@ Download the repository, install dependencies using `pnpm` and then run the envi
git clone https://github.com/TriliumNext/Trilium.git
cd Trilium
pnpm install
pnpm edit-docs:edit-docs
pnpm nx run edit-docs:edit-docs
```
### Building the Executable
@@ -155,45 +142,27 @@ Download the repository, install dependencies using `pnpm` and then build the de
git clone https://github.com/TriliumNext/Trilium.git
cd Trilium
pnpm install
pnpm run --filter desktop electron-forge:make --arch=x64 --platform=win32
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
* [zadam](https://github.com/zadam) for the original concept and implementation of the application.
* [Larsa](https://github.com/LarsaSara) for designing the application icon.
* [nriver](https://github.com/nriver) for his work on internationalization.
* [Thomas Frei](https://github.com/thfrei) for his original work on the Canvas.
* [antoniotejada](https://github.com/nriver) for the original syntax highlight widget.
* [Dosu](https://dosu.dev/) for providing us with the automated responses to GitHub issues and discussions.
* [Tabler Icons](https://tabler.io/icons) for the system tray icons.
Trilium would not be possible without the technologies behind it:
* [CKEditor 5](https://github.com/ckeditor/ckeditor5) - the visual editor behind text notes. We are grateful for being offered a set of the premium features.
* [CodeMirror](https://github.com/codemirror/CodeMirror) - code editor with support for huge amount of languages.
* [Excalidraw](https://github.com/excalidraw/excalidraw) - the infinite whiteboard used in Canvas notes.
* [Mind Elixir](https://github.com/SSShooter/mind-elixir-core) - providing the mind map functionality.
* [Leaflet](https://github.com/Leaflet/Leaflet) - for rendering geographical maps.
* [Tabulator](https://github.com/olifolkerd/tabulator) - for the interactive table used in collections.
* [FancyTree](https://github.com/mar10/fancytree) - feature-rich tree library without real competition.
* [jsPlumb](https://github.com/jsplumb/jsplumb) - visual connectivity library. Used in [relation maps](https://triliumnext.github.io/Docs/Wiki/relation-map.html) and [link maps](https://triliumnext.github.io/Docs/Wiki/note-map.html#link-map)
* [CKEditor 5](https://github.com/ckeditor/ckeditor5) - best WYSIWYG editor on the market, very interactive and listening team
* [FancyTree](https://github.com/mar10/fancytree) - very feature rich tree library without real competition. Trilium Notes would not be the same without it.
* [CodeMirror](https://github.com/codemirror/CodeMirror) - code editor with support for huge amount of languages
* [jsPlumb](https://github.com/jsplumb/jsplumb) - visual connectivity library without competition. Used in [relation maps](https://triliumnext.github.io/Docs/Wiki/relation-map.html) and [link maps](https://triliumnext.github.io/Docs/Wiki/note-map.html#link-map)
## 🤝 Support
Trilium is built and maintained with [hundreds of hours of work](https://github.com/TriliumNext/Trilium/graphs/commit-activity). Your support keeps it open-source, improves features, and covers costs such as hosting.
Consider supporting the main developer ([eliandoran](https://github.com/eliandoran)) of the application via:
- [GitHub Sponsors](https://github.com/sponsors/eliandoran)
- [PayPal](https://paypal.me/eliandoran)
- [Buy Me a Coffee](https://buymeacoffee.com/eliandoran)
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/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).
## 🔑 License

View File

@@ -35,13 +35,13 @@
"chore:generate-openapi": "tsx bin/generate-openapi.js"
},
"devDependencies": {
"@playwright/test": "1.55.1",
"@stylistic/eslint-plugin": "5.4.0",
"@playwright/test": "1.54.1",
"@stylistic/eslint-plugin": "5.2.2",
"@types/express": "5.0.3",
"@types/node": "22.18.8",
"@types/node": "22.17.0",
"@types/yargs": "17.0.33",
"@vitest/coverage-v8": "3.2.4",
"eslint": "9.37.0",
"eslint": "9.32.0",
"eslint-plugin-simple-import-sort": "12.1.1",
"esm": "3.2.25",
"jsdoc": "4.0.4",
@@ -49,8 +49,8 @@
"rcedit": "4.0.1",
"rimraf": "6.0.1",
"tslib": "2.8.1",
"typedoc": "0.28.13",
"typedoc-plugin-missing-exports": "4.1.0"
"typedoc": "0.28.8",
"typedoc-plugin-missing-exports": "4.0.0"
},
"optionalDependencies": {
"appdmg": "0.6.6"

View File

@@ -1,4 +1,5 @@
# The development license key for premium CKEditor features.
# Note: This key must only be used for the Trilium Notes project.
VITE_CKEDITOR_KEY=eyJhbGciOiJFUzI1NiJ9.eyJleHAiOjE3ODcyNzA0MDAsImp0aSI6IjkyMWE1MWNlLTliNDMtNGRlMC1iOTQwLTc5ZjM2MDBkYjg1NyIsImRpc3RyaWJ1dGlvbkNoYW5uZWwiOiJ0cmlsaXVtIiwiZmVhdHVyZXMiOlsiVFJJTElVTSJdLCJ2YyI6ImU4YzRhMjBkIn0.hny77p-U4-jTkoqbwPytrEar5ylGCWBN7Ez3SlB8i6_mJCBIeCSTOlVQk_JMiOEq3AGykUMHzWXzjdMFwgniOw
# Expires on: 2025-09-13
VITE_CKEDITOR_KEY=eyJhbGciOiJFUzI1NiJ9.eyJleHAiOjE3NTc3MjE1OTksImp0aSI6ImFiN2E0NjZmLWJlZGMtNDNiYy1iMzU4LTk0NGQ0YWJhY2I3ZiIsImRpc3RyaWJ1dGlvbkNoYW5uZWwiOlsic2giLCJkcnVwYWwiXSwid2hpdGVMYWJlbCI6dHJ1ZSwiZmVhdHVyZXMiOlsiRFJVUCIsIkNNVCIsIkRPIiwiRlAiLCJTQyIsIlRPQyIsIlRQTCIsIlBPRSIsIkNDIiwiTUYiLCJTRUUiLCJFQ0giLCJFSVMiXSwidmMiOiI1MzlkOWY5YyJ9.2rvKPql4hmukyXhEtWPZ8MLxKvzPIwzCdykO653g7IxRRZy2QJpeRszElZx9DakKYZKXekVRAwQKgHxwkgbE_w
VITE_CKEDITOR_ENABLE_INSPECTOR=false

View File

@@ -1,30 +1,25 @@
{
"name": "@triliumnext/client",
"version": "0.99.1",
"version": "0.97.1",
"description": "JQuery-based client for TriliumNext, used for both web and desktop (via Electron)",
"private": true,
"license": "AGPL-3.0-only",
"author": {
"name": "Trilium Notes Team",
"email": "contact@eliandoran.me",
"url": "https://github.com/TriliumNext/Trilium"
},
"scripts": {
"build": "cross-env NODE_OPTIONS=--max-old-space-size=4096 vite build",
"test": "vitest",
"circular-deps": "dpdm -T src/**/*.ts --tree=false --warning=false --skip-dynamic-imports=circular"
"url": "https://github.com/TriliumNext/Notes"
},
"dependencies": {
"@eslint/js": "9.37.0",
"@eslint/js": "9.32.0",
"@excalidraw/excalidraw": "0.18.0",
"@fullcalendar/core": "6.1.19",
"@fullcalendar/daygrid": "6.1.19",
"@fullcalendar/interaction": "6.1.19",
"@fullcalendar/list": "6.1.19",
"@fullcalendar/multimonth": "6.1.19",
"@fullcalendar/timegrid": "6.1.19",
"@fullcalendar/core": "6.1.18",
"@fullcalendar/daygrid": "6.1.18",
"@fullcalendar/interaction": "6.1.18",
"@fullcalendar/list": "6.1.18",
"@fullcalendar/multimonth": "6.1.18",
"@fullcalendar/timegrid": "6.1.18",
"@maplibre/maplibre-gl-leaflet": "0.1.3",
"@mermaid-js/layout-elk": "0.2.0",
"@mermaid-js/layout-elk": "0.1.8",
"@mind-elixir/node-menu": "5.0.0",
"@popperjs/core": "2.11.8",
"@triliumnext/ckeditor5": "workspace:*",
@@ -33,31 +28,31 @@
"@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.18",
"dayjs": "1.11.13",
"dayjs-plugin-utc": "0.1.2",
"debounce": "2.2.0",
"draggabilly": "3.0.0",
"force-graph": "1.51.0",
"globals": "16.4.0",
"i18next": "25.5.3",
"force-graph": "1.50.1",
"globals": "16.3.0",
"i18next": "25.3.2",
"i18next-http-backend": "3.0.2",
"jquery": "3.7.1",
"jquery-hotkeys": "0.2.2",
"jquery.fancytree": "2.38.5",
"jsplumb": "2.15.6",
"katex": "0.16.23",
"katex": "0.16.22",
"knockout": "3.5.1",
"leaflet": "1.9.4",
"leaflet-gpx": "2.2.0",
"mark.js": "8.11.1",
"marked": "16.3.0",
"mermaid": "11.12.0",
"mind-elixir": "5.1.1",
"marked": "16.1.1",
"mermaid": "11.9.0",
"mind-elixir": "5.0.4",
"normalize.css": "8.0.1",
"panzoom": "9.4.3",
"preact": "10.27.2",
"react-i18next": "16.0.0",
"preact": "10.27.0",
"split.js": "1.6.5",
"svg-pan-zoom": "3.6.2",
"tabulator-tables": "6.3.1",
@@ -65,16 +60,28 @@
},
"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.8",
"@types/leaflet-gpx": "1.3.7",
"@types/mark.js": "8.11.12",
"@types/tabulator-tables": "6.2.11",
"copy-webpack-plugin": "13.0.1",
"happy-dom": "19.0.2",
"@types/tabulator-tables": "6.2.8",
"copy-webpack-plugin": "13.0.0",
"happy-dom": "18.0.1",
"script-loader": "0.7.2",
"vite-plugin-static-copy": "3.1.3"
"vite-plugin-static-copy": "3.1.1"
},
"nx": {
"name": "client",
"targets": {
"serve": {
"dependsOn": [
"^build"
]
},
"circular-deps": {
"command": "pnpx dpdm -T {projectRoot}/src/**/*.ts --tree=false --warning=false --skip-dynamic-imports=circular"
}
}
}
}

View File

@@ -7,9 +7,6 @@
"display": "standalone",
"scope": "/",
"start_url": "/",
"display_override": [
"window-controls-overlay"
],
"icons": [
{
"src": "icon.png",

View File

@@ -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";
@@ -30,15 +30,16 @@ import type CodeMirror from "@triliumnext/codemirror";
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 +84,7 @@ export type CommandMappings = {
focusTree: CommandData;
focusOnTitle: CommandData;
focusOnDetail: CommandData;
focusOnSearchDefinition: Required<CommandData>;
searchNotes: CommandData & {
searchString?: string;
ancestorNoteId?: string | null;
@@ -90,14 +92,7 @@ export type CommandMappings = {
closeTocCommand: CommandData;
closeHlt: CommandData;
showLaunchBarSubtree: CommandData;
showHiddenSubtree: CommandData;
showSQLConsoleHistory: CommandData;
logout: CommandData;
switchToMobileVersion: CommandData;
switchToDesktopVersion: CommandData;
showRevisions: CommandData & {
noteId?: string | null;
};
showRevisions: CommandData;
showLlmChat: CommandData;
createAiChat: CommandData;
showOptions: CommandData & {
@@ -116,7 +111,7 @@ export type CommandMappings = {
openedFileUpdated: CommandData & {
entityType: string;
entityId: string;
lastModifiedMs?: number;
lastModifiedMs: number;
filePath: string;
};
focusAndSelectTitle: CommandData & {
@@ -140,7 +135,6 @@ export type CommandMappings = {
showLeftPane: CommandData;
showAttachments: CommandData;
showSearchHistory: CommandData;
showShareSubtree: CommandData;
hoistNote: CommandData & { noteId: string };
leaveProtectedSession: CommandData;
enterProtectedSession: CommandData;
@@ -272,73 +266,6 @@ export type CommandMappings = {
jumpToNote: CommandData;
commandPalette: CommandData;
// Keyboard shortcuts
backInNoteHistory: CommandData;
forwardInNoteHistory: CommandData;
forceSaveRevision: CommandData;
scrollToActiveNote: CommandData;
quickSearch: CommandData;
collapseTree: CommandData;
createNoteAfter: CommandData;
createNoteInto: CommandData;
addNoteAboveToSelection: CommandData;
addNoteBelowToSelection: CommandData;
openNewTab: CommandData;
activateNextTab: CommandData;
activatePreviousTab: CommandData;
openNewWindow: CommandData;
toggleTray: CommandData;
firstTab: CommandData;
secondTab: CommandData;
thirdTab: CommandData;
fourthTab: CommandData;
fifthTab: CommandData;
sixthTab: CommandData;
seventhTab: CommandData;
eigthTab: CommandData;
ninthTab: CommandData;
lastTab: CommandData;
showNoteSource: CommandData;
showSQLConsole: CommandData;
showBackendLog: CommandData;
showCheatsheet: CommandData;
showHelp: CommandData;
addLinkToText: CommandData;
followLinkUnderCursor: CommandData;
insertDateTimeToText: CommandData;
pasteMarkdownIntoText: CommandData;
cutIntoNote: CommandData;
addIncludeNoteToText: CommandData;
editReadOnlyNote: CommandData;
toggleRibbonTabClassicEditor: CommandData;
toggleRibbonTabBasicProperties: CommandData;
toggleRibbonTabBookProperties: CommandData;
toggleRibbonTabFileProperties: CommandData;
toggleRibbonTabImageProperties: CommandData;
toggleRibbonTabOwnedAttributes: CommandData;
toggleRibbonTabInheritedAttributes: CommandData;
toggleRibbonTabPromotedAttributes: CommandData;
toggleRibbonTabNoteMap: CommandData;
toggleRibbonTabNoteInfo: CommandData;
toggleRibbonTabNotePaths: CommandData;
toggleRibbonTabSimilarNotes: CommandData;
toggleRightPane: CommandData;
printActiveNote: CommandData;
exportAsPdf: CommandData;
openNoteExternally: CommandData;
openNoteCustom: CommandData;
renderActiveNote: CommandData;
unhoist: CommandData;
reloadFrontendApp: CommandData;
openDevTools: CommandData;
findInText: CommandData;
toggleLeftPane: CommandData;
toggleFullscreen: CommandData;
zoomOut: CommandData;
zoomIn: CommandData;
zoomReset: CommandData;
copyWithoutFormatting: CommandData;
// Geomap
deleteFromMap: { noteId: string };
@@ -375,9 +302,6 @@ export type CommandMappings = {
};
refreshTouchBar: CommandData;
reloadTextEditor: CommandData;
chooseNoteType: CommandData & {
callback: ChooseNoteTypeCallback
}
};
type EventMappings = {
@@ -530,7 +454,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 +547,7 @@ export class AppContext extends Component {
component.triggerCommand(commandName, { $el: $(this) });
});
this.child(rootWidget as Component);
this.child(rootWidget);
this.triggerEvent("initialRenderComplete", {});
}
@@ -650,20 +574,16 @@ export class AppContext extends Component {
}
getComponentByEl(el: HTMLElement) {
return $(el).closest("[data-component-id]").prop("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 +593,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";
}
});

View File

@@ -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> {}

View File

@@ -10,16 +10,38 @@ 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() {
super();
if (jQuery.hotkeys) {
// hot keys are active also inside inputs and content editables
jQuery.hotkeys.options.filterInputAcceptingElements = false;
jQuery.hotkeys.options.filterContentEditable = false;
jQuery.hotkeys.options.filterTextInputs = false;
}
}
openDevToolsCommand() {
if (utils.isElectron()) {
utils.dynamicRequire("@electron/remote").getCurrentWindow().webContents.toggleDevTools();
utils.dynamicRequire("@electron/remote").getCurrentWindow().toggleDevTools();
}
}
@@ -109,7 +131,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 +143,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 {

View File

@@ -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">) {
@@ -144,6 +146,19 @@ export default class RootCommandExecutor extends Component {
}
}
async showNoteOCRTextCommand() {
const notePath = appContext.tabManager.getActiveContextNotePath();
if (notePath) {
await appContext.tabManager.openTabWithNoteWithHoisting(notePath, {
activate: true,
viewScope: {
viewMode: "ocr"
}
});
}
}
async showAttachmentsCommand() {
const notePath = appContext.tabManager.getActiveContextNotePath();

View File

@@ -433,9 +433,6 @@ export default class TabManager extends Component {
$autocompleteEl.autocomplete("close");
}
// close dangling tooltips
$("body > div.tooltip").remove();
const noteContextsToRemove = noteContextToRemove.getSubContexts();
const ntxIdsToRemove = noteContextsToRemove.map((nc) => nc.ntxId);
@@ -603,18 +600,18 @@ export default class TabManager extends Component {
}
async moveTabToNewWindowCommand({ ntxId }: { ntxId: string }) {
const { notePath, hoistedNoteId, viewScope } = this.getNoteContextById(ntxId);
const { notePath, hoistedNoteId } = this.getNoteContextById(ntxId);
const removed = await this.removeNoteContext(ntxId);
if (removed) {
this.triggerCommand("openInWindow", { notePath, hoistedNoteId, viewScope });
this.triggerCommand("openInWindow", { notePath, hoistedNoteId });
}
}
async copyTabToNewWindowCommand({ ntxId }: { ntxId: string }) {
const { notePath, hoistedNoteId, viewScope } = this.getNoteContextById(ntxId);
this.triggerCommand("openInWindow", { notePath, hoistedNoteId, viewScope });
const { notePath, hoistedNoteId } = this.getNoteContextById(ntxId);
this.triggerCommand("openInWindow", { notePath, hoistedNoteId });
}
async reopenLastTabCommand() {

View File

@@ -23,11 +23,11 @@ export default class TouchBarComponent extends Component {
this.$widget = $("<div>");
$(window).on("focusin", async (e) => {
const focusedEl = e.target as unknown as HTMLElement;
const $target = $(focusedEl);
const $target = $(e.target);
this.$activeModal = $target.closest(".modal-dialog");
this.lastFocusedComponent = appContext.getComponentByEl(focusedEl);
const parentComponentEl = $target.closest(".component");
this.lastFocusedComponent = appContext.getComponentByEl(parentComponentEl[0]);
this.#refreshTouchBar();
});
}

View File

@@ -8,10 +8,12 @@ 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 "bootstrap/dist/css/bootstrap.min.css";
import "./stylesheets/bootstrap.scss";
import "boxicons/css/boxicons.min.css";
import "jquery-hotkeys";
import "autocomplete.js/index_jquery.js";
await appContext.earlyInit();
@@ -45,10 +47,6 @@ if (utils.isElectron()) {
electronContextMenu.setupContextMenu();
}
if (utils.isPWA()) {
initPWATopbarColor();
}
function initOnElectron() {
const electron: typeof Electron = utils.dynamicRequire("electron");
electron.ipcRenderer.on("globalShortcut", async (event, actionName) => appContext.triggerCommand(actionName));
@@ -117,20 +115,3 @@ function initDarkOrLightMode(style: CSSStyleDeclaration) {
const { nativeTheme } = utils.dynamicRequire("@electron/remote") as typeof ElectronRemote;
nativeTheme.themeSource = themeSource;
}
function initPWATopbarColor() {
const tracker = $("#background-color-tracker");
if (tracker.length) {
const applyThemeColor = () => {
let meta = $("meta[name='theme-color']");
if (!meta.length) {
meta = $(`<meta name="theme-color">`).appendTo($("head"));
}
meta.attr("content", tracker.css("color"));
};
tracker.on("transitionend", applyThemeColor);
applyThemeColor();
}
}

View File

@@ -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;
@@ -256,20 +256,18 @@ export default class FNote {
return this.children;
}
async getSubtreeNoteIds(includeArchived = false) {
async getSubtreeNoteIds() {
let noteIds: (string | string[])[] = [];
for (const child of await this.getChildNotes()) {
if (child.isArchived && !includeArchived) continue;
noteIds.push(child.noteId);
noteIds.push(await child.getSubtreeNoteIds(includeArchived));
noteIds.push(await child.getSubtreeNoteIds());
}
return noteIds.flat();
}
async getSubtreeNotes() {
const noteIds = await this.getSubtreeNoteIds();
return (await this.froca.getNotes(noteIds));
return this.froca.getNotes(noteIds);
}
async getChildNotes() {
@@ -907,8 +905,8 @@ export default class FNote {
return this.getBlob();
}
getBlob() {
return this.froca.getBlob("notes", this.noteId);
async getBlob() {
return await this.froca.getBlob("notes", this.noteId);
}
toString() {
@@ -1022,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.
*/
@@ -1037,3 +1027,5 @@ export default class FNote {
return await server.get<NoteMetaData>(`notes/${this.noteId}/metadata`);
}
}
export default FNote;

View File

@@ -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 NoteIconWidget from "../widgets/note_icon.jsx";
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 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";
import NoteList from "../widgets/collections/NoteList.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(<MovePaneButton direction="left" />)
.child(<MovePaneButton direction="right" />)
.child(<ClosePaneButton />)
.child(<CreatePaneButton />)
.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(<NoteList />)
.child(<SearchResult />)
.child(<SqlResults />)
.child(<ScrollPadding />)
.child(new NoteListWidget(false))
.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");

View File

@@ -24,48 +24,46 @@ 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 CallToActionDialog from "../widgets/dialogs/call_to_action.jsx";
import NoteTitleWidget from "../widgets/note_title.jsx";
import { PopupEditorFormattingToolbar } from "../widgets/ribbon/FormattingToolbar.js";
import NoteList from "../widgets/collections/NoteList.jsx";
import NoteListWidget from "../widgets/note_list.js";
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(<NoteList displayOnlyCollections />))
.child(<CallToActionDialog />);
.child(new NoteListWidget(true)))
}

View File

@@ -3,27 +3,29 @@ 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 NoteWrapperWidget from "../widgets/note_wrapper.js";
import MobileDetailMenu from "../widgets/mobile_widgets/mobile_detail_menu.js";
import NoteList from "../widgets/collections/NoteList.jsx";
const MOBILE_CSS = `
<style>
@@ -132,33 +134,38 @@ export default class MobileLayout {
.child(new FlexContainer("column").filling().id("mobile-sidebar-wrapper").child(new QuickSearchWidget()).child(new NoteTreeWidget().cssBlock(FANCYTREE_CSS)))
)
.child(
new ScreenContainer("detail", "row")
new ScreenContainer("detail", "column")
.id("detail-container")
.class("d-sm-flex d-md-flex d-lg-flex d-xl-flex col-12 col-sm-7 col-md-8 col-lg-9")
.child(
new NoteWrapperWidget()
.child(
new FlexContainer("row")
.contentSized()
.css("font-size", "larger")
.css("align-items", "center")
.child(<ToggleSidebarButton />)
.child(<NoteTitleWidget />)
.child(<MobileDetailMenu />)
)
.child(<SharedInfoWidget />)
.child(<FloatingButtons items={MOBILE_FLOATING_BUTTONS} />)
.child(new PromotedAttributesWidget())
.child(
new ScrollingContainer()
.filling()
.contentSized()
.child(new NoteDetailWidget())
.child(<NoteList />)
.child(<FilePropertiesWrapper />)
)
.child(<MobileEditorToolbar />)
new FlexContainer("row")
.contentSized()
.css("font-size", "larger")
.css("align-items", "center")
.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(new PromotedAttributesWidget())
.child(
new ScrollingContainer()
.filling()
.contentSized()
.child(new NoteDetailWidget())
.child(new NoteListWidget(false))
.child(new FilePropertiesWidget().css("font-size", "smaller"))
)
.child(new MobileEditorToolbar())
)
)
.child(
@@ -166,25 +173,9 @@ 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(<CloseZenModeButton />);
.child(new FlexContainer("row").class("horizontal").css("height", "53px").child(new LauncherContainer(true)).child(new GlobalMenuWidget(true)).id("launcher-pane"))
);
applyModals(rootContainer);
return rootContainer;
}
}
function FilePropertiesWrapper() {
const { note } = useNoteContext();
return (
<div>
{note?.type === "file" && <FilePropertiesTab note={note} />}
</div>
);
}

View File

@@ -1,4 +1,4 @@
import "bootstrap/dist/css/bootstrap.min.css";
import "./stylesheets/bootstrap.scss";
// @ts-ignore - module = undefined
// Required for correct loading of scripts in Electron

View File

@@ -1,8 +1,6 @@
import { KeyboardActionNames } from "@triliumnext/commons";
import keyboardActionService, { getActionSync } from "../services/keyboard_actions.js";
import keyboardActionService from "../services/keyboard_actions.js";
import note_tooltip from "../services/note_tooltip.js";
import utils from "../services/utils.js";
import { should } from "vitest";
export interface ContextMenuOptions<T> {
x: number;
@@ -15,13 +13,8 @@ export interface ContextMenuOptions<T> {
onHide?: () => void;
}
export interface MenuSeparatorItem {
kind: "separator";
}
export interface MenuHeader {
title: string;
kind: "header";
interface MenuSeparatorItem {
title: "----";
}
export interface MenuItemBadge {
@@ -45,13 +38,12 @@ export interface MenuCommandItem<T> {
handler?: MenuHandler<T>;
items?: MenuItem<T>[] | null;
shortcut?: string;
keyboardShortcut?: KeyboardActionNames;
spellingSuggestion?: string;
checked?: boolean;
columns?: number;
}
export type MenuItem<T> = MenuCommandItem<T> | MenuSeparatorItem | MenuHeader;
export type MenuItem<T> = MenuCommandItem<T> | MenuSeparatorItem;
export type MenuHandler<T> = (item: MenuCommandItem<T>, e: JQuery.MouseDownEvent<HTMLElement, undefined, HTMLElement, HTMLElement>) => void;
export type ContextMenuEvent = PointerEvent | MouseEvent | JQuery.ContextMenuEvent;
@@ -156,51 +148,14 @@ class ContextMenu {
.addClass("show");
}
addItems($parent: JQuery<HTMLElement>, items: MenuItem<any>[], multicolumn = false) {
let $group = $parent; // The current group or parent element to which items are being appended
let shouldStartNewGroup = false; // If true, the next item will start a new group
let shouldResetGroup = false; // If true, the next item will be the last one from the group
for (let index = 0; index < items.length; index++) {
const item = items[index];
addItems($parent: JQuery<HTMLElement>, items: MenuItem<any>[]) {
for (const item of items) {
if (!item) {
continue;
}
// If the current item is a header, start a new group. This group will contain the
// header and the next item that follows the header.
if ("kind" in item && item.kind === "header") {
if (multicolumn && !shouldResetGroup) {
shouldStartNewGroup = true;
}
}
// If the next item is a separator, start a new group. This group will contain the
// current item, the separator, and the next item after the separator.
const nextItem = (index < items.length - 1) ? items[index + 1] : null;
if (multicolumn && nextItem && "kind" in nextItem && nextItem.kind === "separator") {
if (!shouldResetGroup) {
shouldStartNewGroup = true;
} else {
shouldResetGroup = true; // Continue the current group
}
}
// Create a new group to avoid column breaks before and after the seaparator / header.
// This is a workaround for Firefox not supporting break-before / break-after: avoid
// for columns.
if (shouldStartNewGroup) {
$group = $("<div class='dropdown-no-break'>");
$parent.append($group);
shouldStartNewGroup = false;
}
if ("kind" in item && item.kind === "separator") {
$group.append($("<div>").addClass("dropdown-divider"));
shouldResetGroup = true; // End the group after the next item
} else if ("kind" in item && item.kind === "header") {
$group.append($("<h6>").addClass("dropdown-header").text(item.title));
shouldResetGroup = true;
if (item.title === "----") {
$parent.append($("<div>").addClass("dropdown-divider"));
} else {
const $icon = $("<span>");
@@ -230,23 +185,7 @@ class ContextMenu {
}
}
if ("keyboardShortcut" in item && item.keyboardShortcut) {
const shortcuts = getActionSync(item.keyboardShortcut).effectiveShortcuts;
if (shortcuts) {
const allShortcuts: string[] = [];
for (const effectiveShortcut of shortcuts) {
allShortcuts.push(effectiveShortcut.split("+")
.map(key => `<kbd>${key}</kbd>`)
.join("+"));
}
if (allShortcuts.length) {
const container = $("<span>").addClass("keyboard-shortcut");
container.append($(allShortcuts.join(",")));
$link.append(container);
}
}
} else if ("shortcut" in item && item.shortcut) {
if ("shortcut" in item && item.shortcut) {
$link.append($("<kbd>").text(item.shortcut));
}
@@ -302,24 +241,16 @@ class ContextMenu {
$link.addClass("dropdown-toggle");
const $subMenu = $("<ul>").addClass("dropdown-menu");
const hasColumns = !!item.columns && item.columns > 1;
if (!this.isMobile && hasColumns) {
$subMenu.css("column-count", item.columns!);
if (!this.isMobile && item.columns) {
$subMenu.css("column-count", item.columns);
}
this.addItems($subMenu, item.items, hasColumns);
this.addItems($subMenu, item.items);
$item.append($subMenu);
}
$group.append($item);
// After adding a menu item, if the previous item was a separator or header,
// reset the group so that the next item will be appended directly to the parent.
if (shouldResetGroup) {
$group = $parent;
shouldResetGroup = false;
};
$parent.append($item);
}
}
}

View File

@@ -37,7 +37,7 @@ function setupContextMenu() {
handler: () => webContents.session.addWordToSpellCheckerDictionary(params.misspelledWord)
});
items.push({ kind: "separator" });
items.push({ title: `----` });
}
if (params.isEditable) {
@@ -112,7 +112,7 @@ function setupContextMenu() {
// Replace the placeholder with the real search keyword.
let searchUrl = searchEngineUrl.replace("{keyword}", encodeURIComponent(params.selectionText));
items.push({ kind: "separator" });
items.push({ title: "----" });
items.push({
title: t("electron_context_menu.search_online", { term: shortenedSelection, searchEngine: searchEngineName }),

View File

@@ -45,16 +45,16 @@ export default class LauncherContextMenu implements SelectMenuItemEventListener<
isVisibleRoot || isAvailableRoot ? { title: t("launcher_context_menu.add-script-launcher"), command: "addScriptLauncher", uiIcon: "bx bx-code-curly" } : null,
isVisibleRoot || isAvailableRoot ? { title: t("launcher_context_menu.add-custom-widget"), command: "addWidgetLauncher", uiIcon: "bx bx-customize" } : null,
isVisibleRoot || isAvailableRoot ? { title: t("launcher_context_menu.add-spacer"), command: "addSpacerLauncher", uiIcon: "bx bx-dots-horizontal" } : null,
isVisibleRoot || isAvailableRoot ? { kind: "separator" } : null,
isVisibleRoot || isAvailableRoot ? { title: "----" } : null,
isAvailableItem ? { title: t("launcher_context_menu.move-to-visible-launchers"), command: "moveLauncherToVisible", uiIcon: "bx bx-show", enabled: true } : null,
isVisibleItem ? { title: t("launcher_context_menu.move-to-available-launchers"), command: "moveLauncherToAvailable", uiIcon: "bx bx-hide", enabled: true } : null,
isVisibleItem || isAvailableItem ? { kind: "separator" } : null,
isVisibleItem || isAvailableItem ? { title: "----" } : null,
{ title: `${t("launcher_context_menu.duplicate-launcher")}`, command: "duplicateSubtree", uiIcon: "bx bx-outline", enabled: isItem },
{ title: `${t("launcher_context_menu.delete")}`, command: "deleteNotes", uiIcon: "bx bx-trash destructive-action-icon", enabled: canBeDeleted },
{ kind: "separator" },
{ title: "----" },
{ title: t("launcher_context_menu.reset"), command: "resetLauncher", uiIcon: "bx bx-reset destructive-action-icon", enabled: canBeReset }
];

View File

@@ -13,8 +13,6 @@ import type NoteTreeWidget from "../widgets/note_tree.js";
import type FAttachment from "../entities/fattachment.js";
import type { SelectMenuItemEventListener } from "../components/events.js";
import utils from "../services/utils.js";
import attributes from "../services/attributes.js";
import { executeBulkActions } from "../services/bulk_action.js";
// TODO: Deduplicate once client/server is well split.
interface ConvertToAttachmentResponse {
@@ -63,11 +61,6 @@ export default class TreeContextMenu implements SelectMenuItemEventListener<Tree
// the only exception is when the only selected note is the one that was right-clicked, then
// it's clear what the user meant to do.
const selNodes = this.treeWidget.getSelectedNodes();
const selectedNotes = await froca.getNotes(selNodes.map(node => node.data.noteId));
if (note && !selectedNotes.includes(note)) selectedNotes.push(note);
const isArchived = selectedNotes.every(note => note.isArchived);
const canToggleArchived = !selectedNotes.some(note => note.isArchived !== isArchived);
const noSelectedNotes = selNodes.length === 0 || (selNodes.length === 1 && selNodes[0] === this.node);
const notSearch = note?.type !== "search";
@@ -76,29 +69,27 @@ export default class TreeContextMenu implements SelectMenuItemEventListener<Tree
const insertNoteAfterEnabled = isNotRoot && !isHoisted && parentNotSearch;
const items: (MenuItem<TreeCommandNames> | null)[] = [
{ title: t("tree-context-menu.open-in-a-new-tab"), command: "openInTab", shortcut: "Ctrl+Click", uiIcon: "bx bx-link-external", enabled: noSelectedNotes },
{ title: `${t("tree-context-menu.open-in-a-new-tab")}`, command: "openInTab", uiIcon: "bx bx-link-external", enabled: noSelectedNotes },
{ title: t("tree-context-menu.open-in-a-new-split"), command: "openNoteInSplit", uiIcon: "bx bx-dock-right", enabled: noSelectedNotes },
{ title: t("tree-context-menu.open-in-popup"), command: "openNoteInPopup", uiIcon: "bx bx-edit", enabled: noSelectedNotes },
isHoisted
? null
: {
title: `${t("tree-context-menu.hoist-note")}`,
title: `${t("tree-context-menu.hoist-note")} <kbd data-command="toggleNoteHoisting"></kbd>`,
command: "toggleNoteHoisting",
keyboardShortcut: "toggleNoteHoisting",
uiIcon: "bx bxs-chevrons-up",
enabled: noSelectedNotes && notSearch
},
!isHoisted || !isNotRoot
? null
: { title: t("tree-context-menu.unhoist-note"), command: "toggleNoteHoisting", keyboardShortcut: "toggleNoteHoisting", uiIcon: "bx bx-door-open" },
: { title: `${t("tree-context-menu.unhoist-note")} <kbd data-command="toggleNoteHoisting"></kbd>`, command: "toggleNoteHoisting", uiIcon: "bx bx-door-open" },
{ kind: "separator" },
{ title: "----" },
{
title: t("tree-context-menu.insert-note-after"),
title: `${t("tree-context-menu.insert-note-after")}<kbd data-command="createNoteAfter"></kbd>`,
command: "insertNoteAfter",
keyboardShortcut: "createNoteAfter",
uiIcon: "bx bx-plus",
items: insertNoteAfterEnabled ? await noteTypesService.getNoteTypeItems("insertNoteAfter") : null,
enabled: insertNoteAfterEnabled && noSelectedNotes && notOptionsOrHelp,
@@ -106,22 +97,21 @@ export default class TreeContextMenu implements SelectMenuItemEventListener<Tree
},
{
title: t("tree-context-menu.insert-child-note"),
title: `${t("tree-context-menu.insert-child-note")}<kbd data-command="createNoteInto"></kbd>`,
command: "insertChildNote",
keyboardShortcut: "createNoteInto",
uiIcon: "bx bx-plus",
items: notSearch ? await noteTypesService.getNoteTypeItems("insertChildNote") : null,
enabled: notSearch && noSelectedNotes && notOptionsOrHelp,
columns: 2
},
{ kind: "separator" },
{ title: "----" },
{ title: t("tree-context-menu.protect-subtree"), command: "protectSubtree", uiIcon: "bx bx-check-shield", enabled: noSelectedNotes },
{ title: t("tree-context-menu.unprotect-subtree"), command: "unprotectSubtree", uiIcon: "bx bx-shield", enabled: noSelectedNotes },
{ kind: "separator" },
{ title: "----" },
{
title: t("tree-context-menu.advanced"),
@@ -130,52 +120,48 @@ export default class TreeContextMenu implements SelectMenuItemEventListener<Tree
items: [
{ title: t("tree-context-menu.apply-bulk-actions"), command: "openBulkActionsDialog", uiIcon: "bx bx-list-plus", enabled: true },
{ kind: "separator" },
{ title: "----" },
{
title: t("tree-context-menu.edit-branch-prefix"),
title: `${t("tree-context-menu.edit-branch-prefix")} <kbd data-command="editBranchPrefix"></kbd>`,
command: "editBranchPrefix",
keyboardShortcut: "editBranchPrefix",
uiIcon: "bx bx-rename",
enabled: isNotRoot && parentNotSearch && noSelectedNotes && notOptionsOrHelp
},
{ title: t("tree-context-menu.convert-to-attachment"), command: "convertNoteToAttachment", uiIcon: "bx bx-paperclip", enabled: isNotRoot && !isHoisted && notOptionsOrHelp },
{ kind: "separator" },
{ title: "----" },
{ title: t("tree-context-menu.expand-subtree"), command: "expandSubtree", keyboardShortcut: "expandSubtree", uiIcon: "bx bx-expand", enabled: noSelectedNotes },
{ title: t("tree-context-menu.collapse-subtree"), command: "collapseSubtree", keyboardShortcut: "collapseSubtree", uiIcon: "bx bx-collapse", enabled: noSelectedNotes },
{ title: `${t("tree-context-menu.expand-subtree")} <kbd data-command="expandSubtree"></kbd>`, command: "expandSubtree", uiIcon: "bx bx-expand", enabled: noSelectedNotes },
{ title: `${t("tree-context-menu.collapse-subtree")} <kbd data-command="collapseSubtree"></kbd>`, command: "collapseSubtree", uiIcon: "bx bx-collapse", enabled: noSelectedNotes },
{
title: t("tree-context-menu.sort-by"),
title: `${t("tree-context-menu.sort-by")} <kbd data-command="sortChildNotes"></kbd>`,
command: "sortChildNotes",
keyboardShortcut: "sortChildNotes",
uiIcon: "bx bx-sort-down",
enabled: noSelectedNotes && notSearch
},
{ kind: "separator" },
{ title: "----" },
{ title: t("tree-context-menu.copy-note-path-to-clipboard"), command: "copyNotePathToClipboard", uiIcon: "bx bx-directions", enabled: true },
{ title: t("tree-context-menu.recent-changes-in-subtree"), command: "recentChangesInSubtree", uiIcon: "bx bx-history", enabled: noSelectedNotes && notOptionsOrHelp }
]
},
{ kind: "separator" },
{ title: "----" },
{
title: t("tree-context-menu.cut"),
title: `${t("tree-context-menu.cut")} <kbd data-command="cutNotesToClipboard"></kbd>`,
command: "cutNotesToClipboard",
keyboardShortcut: "cutNotesToClipboard",
uiIcon: "bx bx-cut",
enabled: isNotRoot && !isHoisted && parentNotSearch
},
{ title: t("tree-context-menu.copy-clone"), command: "copyNotesToClipboard", keyboardShortcut: "copyNotesToClipboard", uiIcon: "bx bx-copy", enabled: isNotRoot && !isHoisted },
{ title: `${t("tree-context-menu.copy-clone")} <kbd data-command="copyNotesToClipboard"></kbd>`, command: "copyNotesToClipboard", uiIcon: "bx bx-copy", enabled: isNotRoot && !isHoisted },
{
title: t("tree-context-menu.paste-into"),
title: `${t("tree-context-menu.paste-into")} <kbd data-command="pasteNotesFromClipboard"></kbd>`,
command: "pasteNotesFromClipboard",
keyboardShortcut: "pasteNotesFromClipboard",
uiIcon: "bx bx-paste",
enabled: !clipboard.isClipboardEmpty() && notSearch && noSelectedNotes
},
@@ -188,71 +174,39 @@ export default class TreeContextMenu implements SelectMenuItemEventListener<Tree
},
{
title: t("tree-context-menu.move-to"),
title: `${t("tree-context-menu.move-to")} <kbd data-command="moveNotesTo"></kbd>`,
command: "moveNotesTo",
keyboardShortcut: "moveNotesTo",
uiIcon: "bx bx-transfer",
enabled: isNotRoot && !isHoisted && parentNotSearch
},
{ title: t("tree-context-menu.clone-to"), command: "cloneNotesTo", keyboardShortcut: "cloneNotesTo", uiIcon: "bx bx-duplicate", enabled: isNotRoot && !isHoisted },
{ title: `${t("tree-context-menu.clone-to")} <kbd data-command="cloneNotesTo"></kbd>`, command: "cloneNotesTo", uiIcon: "bx bx-duplicate", enabled: isNotRoot && !isHoisted },
{
title: t("tree-context-menu.duplicate"),
title: `${t("tree-context-menu.duplicate")} <kbd data-command="duplicateSubtree">`,
command: "duplicateSubtree",
keyboardShortcut: "duplicateSubtree",
uiIcon: "bx bx-outline",
enabled: parentNotSearch && isNotRoot && !isHoisted && notOptionsOrHelp
},
{
title: !isArchived ? t("tree-context-menu.archive") : t("tree-context-menu.unarchive"),
uiIcon: !isArchived ? "bx bx-archive" : "bx bx-archive-out",
enabled: canToggleArchived,
handler: () => {
if (!selectedNotes.length) return;
if (selectedNotes.length == 1) {
const note = selectedNotes[0];
if (!isArchived) {
attributes.addLabel(note.noteId, "archived");
} else {
attributes.removeOwnedLabelByName(note, "archived");
}
} else {
const noteIds = selectedNotes.map(note => note.noteId);
if (!isArchived) {
executeBulkActions(noteIds, [{
name: "addLabel", labelName: "archived"
}]);
} else {
executeBulkActions(noteIds, [{
name: "deleteLabel", labelName: "archived"
}]);
}
}
}
},
{
title: t("tree-context-menu.delete"),
title: `${t("tree-context-menu.delete")} <kbd data-command="deleteNotes"></kbd>`,
command: "deleteNotes",
keyboardShortcut: "deleteNotes",
uiIcon: "bx bx-trash destructive-action-icon",
enabled: isNotRoot && !isHoisted && parentNotSearch && notOptionsOrHelp
},
{ kind: "separator" },
{ title: "----" },
{ title: t("tree-context-menu.import-into-note"), command: "importIntoNote", uiIcon: "bx bx-import", enabled: notSearch && noSelectedNotes && notOptionsOrHelp },
{ title: t("tree-context-menu.export"), command: "exportNote", uiIcon: "bx bx-export", enabled: notSearch && noSelectedNotes && notOptionsOrHelp },
{ kind: "separator" },
{ title: "----" },
{
title: t("tree-context-menu.search-in-subtree"),
title: `${t("tree-context-menu.search-in-subtree")} <kbd data-command="searchInSubtree"></kbd>`,
command: "searchInSubtree",
keyboardShortcut: "searchInSubtree",
uiIcon: "bx bx-search",
enabled: notSearch && noSelectedNotes
}

View File

@@ -1,7 +1,7 @@
import appContext from "./components/app_context.js";
import noteAutocompleteService from "./services/note_autocomplete.js";
import glob from "./services/glob.js";
import "bootstrap/dist/css/bootstrap.min.css";
import "./stylesheets/bootstrap.scss";
import "boxicons/css/boxicons.min.css";
import "autocomplete.js/index_jquery.js";

View File

@@ -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 {

View File

@@ -210,7 +210,7 @@ function makeToast(id: string, message: string): ToastOptions {
}
ws.subscribeToMessages(async (message) => {
if (!("taskType" in message) || message.taskType !== "deleteNotes") {
if (message.taskType !== "deleteNotes") {
return;
}
@@ -228,7 +228,7 @@ ws.subscribeToMessages(async (message) => {
});
ws.subscribeToMessages(async (message) => {
if (!("taskType" in message) || message.taskType !== "undeleteNotes") {
if (message.taskType !== "undeleteNotes") {
return;
}

View File

@@ -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]

View File

@@ -23,6 +23,7 @@ interface Options {
tooltip?: boolean;
trim?: boolean;
imageHasZoom?: boolean;
showOcrText?: boolean;
}
const CODE_MIME_TYPES = new Set(["application/json"]);
@@ -46,9 +47,9 @@ async function getRenderedContent(this: {} | { ctx: string }, entity: FNote | FA
} else if (type === "code") {
await renderCode(entity, $renderedContent);
} else if (["image", "canvas", "mindMap"].includes(type)) {
renderImage(entity, $renderedContent, options);
await renderImage(entity, $renderedContent, options);
} else if (!options.tooltip && ["file", "pdf", "audio", "video"].includes(type)) {
renderFile(entity, type, $renderedContent);
await renderFile(entity, type, $renderedContent, options);
} else if (type === "mermaid") {
await renderMermaid(entity, $renderedContent);
} else if (type === "render" && entity instanceof FNote) {
@@ -161,7 +162,7 @@ async function renderCode(note: FNote | FAttachment, $renderedContent: JQuery<HT
await applySingleBlockSyntaxHighlight($codeBlock, normalizeMimeTypeForCKEditor(note.mime));
}
function renderImage(entity: FNote | FAttachment, $renderedContent: JQuery<HTMLElement>, options: Options = {}) {
async function renderImage(entity: FNote | FAttachment, $renderedContent: JQuery<HTMLElement>, options: Options = {}) {
const encodedTitle = encodeURIComponent(entity.title);
let url;
@@ -201,9 +202,39 @@ function renderImage(entity: FNote | FAttachment, $renderedContent: JQuery<HTMLE
}
imageContextMenuService.setupContextMenu($img);
// Add OCR text display for image notes
if (entity instanceof FNote && options.showOcrText) {
await addOCRTextIfAvailable(entity, $renderedContent);
}
}
function renderFile(entity: FNote | FAttachment, type: string, $renderedContent: JQuery<HTMLElement>) {
async function addOCRTextIfAvailable(note: FNote, $content: JQuery<HTMLElement>) {
try {
const response = await fetch(`api/ocr/notes/${note.noteId}/text`);
if (response.ok) {
const data = await response.json();
if (data.success && data.hasOcr && data.text) {
const $ocrSection = $(`
<div class="ocr-text-section">
<div class="ocr-header">
<span class="bx bx-text"></span> ${t("ocr.extracted_text")}
</div>
<div class="ocr-content"></div>
</div>
`);
$ocrSection.find('.ocr-content').text(data.text);
$content.append($ocrSection);
}
}
} catch (error) {
// Silently fail if OCR API is not available
console.debug('Failed to fetch OCR text:', error);
}
}
async function renderFile(entity: FNote | FAttachment, type: string, $renderedContent: JQuery<HTMLElement>, options: Options = {}) {
let entityType, entityId;
if (entity instanceof FNote) {
@@ -239,6 +270,11 @@ function renderFile(entity: FNote | FAttachment, type: string, $renderedContent:
$content.append($videoPreview);
}
// Add OCR text display for file notes
if (entity instanceof FNote && options.showOcrText) {
await addOCRTextIfAvailable(entity, $content);
}
if (entityType === "notes" && "noteId" in entity) {
// TODO: we should make this available also for attachments, but there's a problem with "Open externally" support
// in attachment list
@@ -256,19 +292,8 @@ function renderFile(entity: FNote | FAttachment, type: string, $renderedContent:
</button>
`);
$downloadButton.on("click", (e) => {
e.stopPropagation();
openService.downloadFileNote(entity.noteId)
});
$openButton.on("click", async (e) => {
const iconEl = $openButton.find("> .bx");
iconEl.removeClass("bx bx-link-external");
iconEl.addClass("bx bx-loader spin");
e.stopPropagation();
await openService.openNoteExternally(entity.noteId, entity.mime)
iconEl.removeClass("bx bx-loader spin");
iconEl.addClass("bx bx-link-external");
});
$downloadButton.on("click", () => openService.downloadFileNote(entity.noteId));
$openButton.on("click", () => openService.openNoteExternally(entity.noteId, entity.mime));
// open doesn't work for protected notes since it works through a browser which isn't in protected session
$openButton.toggle(!entity.isProtected);

View File

@@ -60,7 +60,7 @@ async function confirmDeleteNoteBoxWithNote(title: string) {
return new Promise<ConfirmDialogResult | undefined>((res) => appContext.triggerCommand("showConfirmDeleteNoteBoxWithNoteDialog", { title, callback: res }));
}
export async function prompt(props: PromptDialogOptions) {
async function prompt(props: PromptDialogOptions) {
return new Promise<string | null>((res) => appContext.triggerCommand("showPromptDialog", { ...props, callback: res }));
}

View File

@@ -48,6 +48,6 @@ function getUrl(docNameValue: string, language: string) {
// Cannot have spaces in the URL due to how JQuery.load works.
docNameValue = docNameValue.replaceAll(" ", "%20");
const basePath = window.glob.isDev ? window.glob.assetPath + "/.." : window.glob.assetPath;
const basePath = window.glob.isDev ? new URL(window.glob.assetPath).pathname : window.glob.assetPath;
return `${basePath}/doc_notes/${language}/${docNameValue}.html`;
}

View File

@@ -1,8 +1,16 @@
import ws from "./ws.js";
import appContext from "../components/app_context.js";
import { OpenedFileUpdateStatus } from "@triliumnext/commons";
const fileModificationStatus: Record<string, Record<string, OpenedFileUpdateStatus>> = {
// TODO: Deduplicate
interface Message {
type: string;
entityType: string;
entityId: string;
lastModifiedMs: number;
filePath: string;
}
const fileModificationStatus: Record<string, Record<string, Message>> = {
notes: {},
attachments: {}
};
@@ -31,7 +39,7 @@ function ignoreModification(entityType: string, entityId: string) {
delete fileModificationStatus[entityType][entityId];
}
ws.subscribeToMessages(async message => {
ws.subscribeToMessages(async (message: Message) => {
if (message.type !== "openedFileUpdated") {
return;
}

View File

@@ -8,7 +8,6 @@ import FAttribute, { type FAttributeRow } from "../entities/fattribute.js";
import FAttachment, { type FAttachmentRow } from "../entities/fattachment.js";
import type { default as FNote, FNoteRow } from "../entities/fnote.js";
import type { EntityChange } from "../server_types.js";
import type { OptionNames } from "@triliumnext/commons";
async function processEntityChanges(entityChanges: EntityChange[]) {
const loadResults = new LoadResults(entityChanges);
@@ -31,14 +30,13 @@ async function processEntityChanges(entityChanges: EntityChange[]) {
continue; // only noise
}
options.set(attributeEntity.name as OptionNames, attributeEntity.value);
loadResults.addOption(attributeEntity.name as OptionNames);
options.set(attributeEntity.name, attributeEntity.value);
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 });
}
}

View File

@@ -21,7 +21,6 @@ import dayjs from "dayjs";
import type NoteContext from "../components/note_context.js";
import type NoteDetailWidget from "../widgets/note_detail.js";
import type Component from "../components/component.js";
import { formatLogMessage } from "@triliumnext/commons";
/**
* A whole number
@@ -456,7 +455,7 @@ export interface Api {
/**
* Log given message to the log pane in UI
*/
log(message: string | object): void;
log(message: string): void;
}
/**
@@ -697,7 +696,7 @@ function FrontendScriptApi(this: Api, startNote: FNote, currentNote: FNote, orig
this.log = (message) => {
const { noteId } = this.startNote;
message = `${utils.now()}: ${formatLogMessage(message)}`;
message = `${utils.now()}: ${message}`;
console.log(`Script ${noteId}: ${message}`);

View File

@@ -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",

View File

@@ -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));

View File

@@ -4,7 +4,6 @@ import ws from "./ws.js";
import utils from "./utils.js";
import appContext from "../components/app_context.js";
import { t } from "./i18n.js";
import { WebSocketMessage } from "@triliumnext/commons";
type BooleanLike = boolean | "true" | "false";
@@ -67,7 +66,7 @@ function makeToast(id: string, message: string): ToastOptions {
}
ws.subscribeToMessages(async (message) => {
if (!("taskType" in message) || message.taskType !== "importNotes") {
if (message.taskType !== "importNotes") {
return;
}
@@ -88,8 +87,8 @@ ws.subscribeToMessages(async (message) => {
}
});
ws.subscribeToMessages(async (message: WebSocketMessage) => {
if (!("taskType" in message) || message.taskType !== "importAttachments") {
ws.subscribeToMessages(async (message) => {
if (message.taskType !== "importAttachments") {
return;
}

View File

@@ -1,43 +0,0 @@
import { NoteType } from "@triliumnext/commons";
import FNote from "../entities/fnote";
import { ViewTypeOptions } from "../widgets/collections/interface";
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 ?? ""]
}
}

View File

@@ -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.

View File

@@ -35,7 +35,8 @@ async function getLinkIcon(noteId: string, viewMode: ViewMode | undefined) {
return icon;
}
export type ViewMode = "default" | "source" | "attachments" | "contextual-help";
// TODO: Remove `string` once all the view modes have been mapped.
type ViewMode = "default" | "source" | "attachments" | "contextual-help" | string;
export interface ViewScope {
/**

View File

@@ -1,4 +1,4 @@
import type { AttachmentRow, EtapiTokenRow, OptionNames } 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;
@@ -67,9 +66,8 @@ export default class LoadResults {
private revisionRows: RevisionRow[];
private noteReorderings: string[];
private contentNoteIdToComponentId: ContentNoteIdToComponentIdRow[];
private optionNames: OptionNames[];
private optionNames: string[];
private attachmentRows: AttachmentRow[];
public hasEtapiTokenChanges: boolean = false;
constructor(entityChanges: EntityChange[]) {
const entities: Record<string, Record<string, any>> = {};
@@ -180,11 +178,11 @@ export default class LoadResults {
return this.contentNoteIdToComponentId.find((l) => l.noteId === noteId && l.componentId !== componentId);
}
addOption(name: OptionNames) {
addOption(name: string) {
this.optionNames.push(name);
}
isOptionReloaded(name: OptionNames) {
isOptionReloaded(name: string) {
return this.optionNames.includes(name);
}
@@ -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
);
}

View File

@@ -36,12 +36,10 @@ export interface Suggestion {
commandId?: string;
commandDescription?: string;
commandShortcut?: string;
attributeSnippet?: string;
highlightedAttributeSnippet?: string;
}
export interface Options {
container?: HTMLElement | null;
interface Options {
container?: HTMLElement;
fastSearch?: boolean;
allowCreatingNotes?: boolean;
allowJumpToSearchNotes?: boolean;
@@ -84,12 +82,12 @@ async function autocompleteSource(term: string, cb: (rows: Suggestion[]) => void
// Check if we're in command mode
if (options.isCommandPalette && term.startsWith(">")) {
const commandQuery = term.substring(1).trim();
// Get commands (all if no query, filtered if query provided)
const commands = commandQuery.length === 0
const commands = commandQuery.length === 0
? commandRegistry.getAllCommands()
: commandRegistry.searchCommands(commandQuery);
// Convert commands to suggestions
const commandSuggestions: Suggestion[] = commands.map(cmd => ({
action: "command",
@@ -101,7 +99,7 @@ async function autocompleteSource(term: string, cb: (rows: Suggestion[]) => void
commandShortcut: cmd.shortcut,
icon: cmd.icon
}));
cb(commandSuggestions);
return;
}
@@ -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
@@ -480,21 +452,6 @@ function init() {
};
}
/**
* Convenience function which triggers the display of recent notes in the autocomplete input and focuses it.
*
* @param inputElement - The input element to trigger recent notes on.
*/
export function triggerRecentNotes(inputElement: HTMLInputElement | null | undefined) {
if (!inputElement) {
return;
}
const $el = $(inputElement);
showRecentNotes($el);
$el.trigger("focus").trigger("select");
}
export default {
autocompleteSourceForCKEditor,
initNoteAutocomplete,

View File

@@ -109,6 +109,8 @@ async function createNote(parentNotePath: string | undefined, options: CreateNot
async function chooseNoteType() {
return new Promise<ChooseNoteTypeResponse>((res) => {
// TODO: Remove ignore after callback for chooseNoteType is defined in app_context.ts
//@ts-ignore
appContext.triggerCommand("chooseNoteType", { callback: res });
});
}

View File

@@ -0,0 +1,71 @@
import type FNote from "../entities/fnote.js";
import BoardView from "../widgets/view_widgets/board_view/index.js";
import CalendarView from "../widgets/view_widgets/calendar_view.js";
import GeoView from "../widgets/view_widgets/geo_view/index.js";
import ListOrGridView from "../widgets/view_widgets/list_or_grid_view.js";
import TableView from "../widgets/view_widgets/table_view/index.js";
import type { ViewModeArgs } from "../widgets/view_widgets/view_mode.js";
import type ViewMode from "../widgets/view_widgets/view_mode.js";
const allViewTypes = ["list", "grid", "calendar", "table", "geoMap", "board"] as const;
export type ArgsWithoutNoteId = Omit<ViewModeArgs, "noteIds">;
export type ViewTypeOptions = typeof allViewTypes[number];
export default class NoteListRenderer {
private viewType: ViewTypeOptions;
private args: ArgsWithoutNoteId;
public viewMode?: ViewMode<any>;
constructor(args: ArgsWithoutNoteId) {
this.args = args;
this.viewType = this.#getViewType(args.parentNote);
}
#getViewType(parentNote: FNote): ViewTypeOptions {
const viewType = parentNote.getLabelValue("viewType");
if (!(allViewTypes as readonly string[]).includes(viewType || "")) {
// when not explicitly set, decide based on the note type
return parentNote.type === "search" ? "list" : "grid";
} else {
return viewType as ViewTypeOptions;
}
}
get isFullHeight() {
switch (this.viewType) {
case "list":
case "grid":
return false;
default:
return true;
}
}
async renderList() {
const args = this.args;
const viewMode = this.#buildViewMode(args);
this.viewMode = viewMode;
await viewMode.beforeRender();
return await viewMode.renderList();
}
#buildViewMode(args: ViewModeArgs) {
switch (this.viewType) {
case "calendar":
return new CalendarView(args);
case "table":
return new TableView(args);
case "geoMap":
return new GeoView(args);
case "board":
return new BoardView(args);
case "list":
case "grid":
default:
return new ListOrGridView(this.viewType, args);
}
}
}

View File

@@ -13,8 +13,8 @@ let openTooltipElements: JQuery<HTMLElement>[] = [];
let dismissTimer: ReturnType<typeof setTimeout>;
function setupGlobalTooltip() {
$(document).on("mouseenter", "a:not(.no-tooltip-preview)", mouseEnterHandler);
$(document).on("mouseenter", "[data-href]:not(.no-tooltip-preview)", mouseEnterHandler);
$(document).on("mouseenter", "a", mouseEnterHandler);
$(document).on("mouseenter", "[data-href]", mouseEnterHandler);
// close any note tooltip after click, this fixes the problem that sometimes tooltips remained on the screen
$(document).on("click", (e) => {

View File

@@ -1,7 +1,7 @@
import { t } from "./i18n.js";
import froca from "./froca.js";
import server from "./server.js";
import type { MenuCommandItem, MenuItem, MenuItemBadge, MenuSeparatorItem } from "../menus/context_menu.js";
import type { MenuCommandItem, MenuItem, MenuItemBadge } from "../menus/context_menu.js";
import type { NoteType } from "../entities/fnote.js";
import type { TreeCommandNames } from "../menus/tree_context_menu.js";
@@ -73,7 +73,7 @@ const BETA_BADGE = {
title: t("note_types.beta-feature")
};
const SEPARATOR: MenuSeparatorItem = { kind: "separator" };
const SEPARATOR = { title: "----" };
const creationDateCache = new Map<string, Date>();
let rootCreationDate: Date | undefined;
@@ -81,8 +81,8 @@ let rootCreationDate: Date | undefined;
async function getNoteTypeItems(command?: TreeCommandNames) {
const items: MenuItem<TreeCommandNames>[] = [
...getBlankNoteTypes(command),
...await getBuiltInTemplates(null, command, false),
...await getBuiltInTemplates(t("note_types.collections"), command, true),
...await getBuiltInTemplates(null, command, false),
...await getUserTemplates(command)
];
@@ -121,10 +121,7 @@ async function getUserTemplates(command?: TreeCommandNames) {
}
const items: MenuItem<TreeCommandNames>[] = [
{
title: t("note_type_chooser.templates"),
kind: "header"
}
SEPARATOR
];
for (const templateNote of templateNotes) {
@@ -161,7 +158,8 @@ async function getBuiltInTemplates(title: string | null, command: TreeCommandNam
if (title) {
items.push({
title: title,
kind: "header"
enabled: false,
uiIcon: "bx bx-empty"
});
} else {
items.push(SEPARATOR);

View File

@@ -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() {

View File

@@ -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>;
@@ -20,7 +19,7 @@ class Options {
this.arr = arr;
}
get(key: OptionNames) {
get(key: string) {
return this.arr?.[key] as string;
}
@@ -40,7 +39,7 @@ class Options {
}
}
getInt(key: OptionNames) {
getInt(key: string) {
const value = this.arr?.[key];
if (typeof value === "number") {
return value;
@@ -52,7 +51,7 @@ class Options {
return null;
}
getFloat(key: OptionNames) {
getFloat(key: string) {
const value = this.arr?.[key];
if (typeof value !== "string") {
return null;
@@ -60,15 +59,15 @@ class Options {
return parseFloat(value);
}
is(key: OptionNames) {
is(key: string) {
return this.arr[key] === "true";
}
set(key: OptionNames, value: OptionValue) {
set(key: string, value: OptionValue) {
this.arr[key] = value;
}
async save(key: OptionNames, value: OptionValue) {
async save(key: string, value: OptionValue) {
this.set(key, value);
const payload: Record<string, OptionValue> = {};
@@ -77,15 +76,7 @@ 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: OptionNames) {
async toggle(key: string) {
await this.save(key, (!this.is(key)).toString());
}
}

View File

@@ -107,11 +107,11 @@ function makeToast(message: Message, title: string, text: string): ToastOptions
}
ws.subscribeToMessages(async (message) => {
if (!("taskType" in message) || message.taskType !== "protectNotes") {
if (message.taskType !== "protectNotes") {
return;
}
const isProtecting = message.data?.protect;
const isProtecting = message.data.protect;
const title = isProtecting ? t("protected_session.protecting-title") : t("protected_session.unprotecting-title");
if (message.type === "taskError") {

View File

@@ -10,10 +10,6 @@ let leftInstance: ReturnType<typeof Split> | null;
let rightPaneWidth: number;
let rightInstance: ReturnType<typeof Split> | null;
const noteSplitMap = new Map<string[], ReturnType<typeof Split> | undefined>(); // key: a group of ntxIds, value: the corresponding Split instance
const noteSplitRafMap = new Map<string[], number>();
let splitNoteContainer: HTMLElement | undefined;
function setupLeftPaneResizer(leftPaneVisible: boolean) {
if (leftInstance) {
leftInstance.destroy();
@@ -87,86 +83,7 @@ function setupRightPaneResizer() {
}
}
function findKeyByNtxId(ntxId: string): string[] | undefined {
// Find the corresponding key in noteSplitMap based on ntxId
for (const key of noteSplitMap.keys()) {
if (key.includes(ntxId)) return key;
}
return undefined;
}
function setupNoteSplitResizer(ntxIds: string[]) {
let targetNtxIds: string[] | undefined;
for (const ntxId of ntxIds) {
targetNtxIds = findKeyByNtxId(ntxId);
if (targetNtxIds) break;
}
if (targetNtxIds) {
noteSplitMap.get(targetNtxIds)?.destroy();
for (const id of ntxIds) {
if (!targetNtxIds.includes(id)) {
targetNtxIds.push(id)
};
}
} else {
targetNtxIds = [...ntxIds];
}
noteSplitMap.set(targetNtxIds, undefined);
createSplitInstance(targetNtxIds);
}
function delNoteSplitResizer(ntxIds: string[]) {
let targetNtxIds = findKeyByNtxId(ntxIds[0]);
if (!targetNtxIds) {
return;
}
noteSplitMap.get(targetNtxIds)?.destroy();
noteSplitMap.delete(targetNtxIds);
targetNtxIds = targetNtxIds.filter(id => !ntxIds.includes(id));
if (targetNtxIds.length >= 2) {
noteSplitMap.set(targetNtxIds, undefined);
createSplitInstance(targetNtxIds);
}
}
function moveNoteSplitResizer(ntxId: string) {
const targetNtxIds = findKeyByNtxId(ntxId);
if (!targetNtxIds) {
return;
}
noteSplitMap.get(targetNtxIds)?.destroy();
noteSplitMap.set(targetNtxIds, undefined);
createSplitInstance(targetNtxIds);
}
function createSplitInstance(targetNtxIds: string[]) {
const prevRafId = noteSplitRafMap.get(targetNtxIds);
if (prevRafId) {
cancelAnimationFrame(prevRafId);
}
const rafId = requestAnimationFrame(() => {
splitNoteContainer = splitNoteContainer ?? $("#center-pane").find(".split-note-container-widget")[0];
const splitPanels = [...splitNoteContainer.querySelectorAll<HTMLElement>(':scope > .note-split')]
.filter(el => targetNtxIds.includes(el.getAttribute('data-ntx-id') ?? ""));
const splitInstance = Split(splitPanels, {
gutterSize: DEFAULT_GUTTER_SIZE,
minSize: 150,
});
noteSplitMap.set(targetNtxIds, splitInstance);
noteSplitRafMap.delete(targetNtxIds);
});
noteSplitRafMap.set(targetNtxIds, rafId);
}
export default {
setupLeftPaneResizer,
setupRightPaneResizer,
setupNoteSplitResizer,
delNoteSplitResizer,
moveNoteSplitResizer
setupRightPaneResizer
};

View File

@@ -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 {

View File

@@ -1,372 +0,0 @@
import { describe, expect, it, vi, beforeEach, afterEach } from "vitest";
import shortcuts, { keyMatches, matchesShortcut, isIMEComposing } from "./shortcuts.js";
// Mock utils module
vi.mock("./utils.js", () => ({
default: {
isDesktop: () => true
}
}));
// Mock jQuery globally since it's used in the shortcuts module
const mockElement = {
addEventListener: vi.fn(),
removeEventListener: vi.fn()
};
const mockJQuery = vi.fn(() => [mockElement]);
(mockJQuery as any).length = 1;
mockJQuery[0] = mockElement;
(global as any).$ = mockJQuery as any;
global.document = mockElement as any;
describe("shortcuts", () => {
beforeEach(() => {
vi.clearAllMocks();
});
afterEach(() => {
// Clean up any active bindings after each test
shortcuts.removeGlobalShortcut("test-namespace");
});
describe("normalizeShortcut", () => {
it("should normalize shortcut to lowercase and remove whitespace", () => {
expect(shortcuts.normalizeShortcut("Ctrl + A")).toBe("ctrl+a");
expect(shortcuts.normalizeShortcut(" SHIFT + F1 ")).toBe("shift+f1");
expect(shortcuts.normalizeShortcut("Alt+Space")).toBe("alt+space");
});
it("should handle empty or null shortcuts", () => {
expect(shortcuts.normalizeShortcut("")).toBe("");
expect(shortcuts.normalizeShortcut(null as any)).toBe(null);
expect(shortcuts.normalizeShortcut(undefined as any)).toBe(undefined);
});
it("should handle shortcuts with multiple spaces", () => {
expect(shortcuts.normalizeShortcut("Ctrl + Shift + A")).toBe("ctrl+shift+a");
});
it("should warn about malformed shortcuts", () => {
const consoleSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
shortcuts.normalizeShortcut("ctrl+");
shortcuts.normalizeShortcut("+a");
shortcuts.normalizeShortcut("ctrl++a");
expect(consoleSpy).toHaveBeenCalledTimes(3);
consoleSpy.mockRestore();
});
});
describe("keyMatches", () => {
const createKeyboardEvent = (key: string, code?: string) => ({
key,
code: code || `Key${key.toUpperCase()}`
} as KeyboardEvent);
it("should match regular letter keys using key code", () => {
const event = createKeyboardEvent("a", "KeyA");
expect(keyMatches(event, "a")).toBe(true);
expect(keyMatches(event, "A")).toBe(true);
});
it("should match number keys using digit codes", () => {
const event = createKeyboardEvent("1", "Digit1");
expect(keyMatches(event, "1")).toBe(true);
});
it("should match special keys using key mapping", () => {
expect(keyMatches({ key: "Enter" } as KeyboardEvent, "return")).toBe(true);
expect(keyMatches({ key: "Enter" } as KeyboardEvent, "enter")).toBe(true);
expect(keyMatches({ key: "Delete" } as KeyboardEvent, "del")).toBe(true);
expect(keyMatches({ key: "Escape" } as KeyboardEvent, "esc")).toBe(true);
expect(keyMatches({ key: " " } as KeyboardEvent, "space")).toBe(true);
expect(keyMatches({ key: "ArrowUp" } as KeyboardEvent, "up")).toBe(true);
});
it("should match function keys", () => {
expect(keyMatches({ key: "F1" } as KeyboardEvent, "f1")).toBe(true);
expect(keyMatches({ key: "F12" } as KeyboardEvent, "f12")).toBe(true);
});
it("should handle undefined or null keys", () => {
const consoleSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
expect(keyMatches({} as KeyboardEvent, null as any)).toBe(false);
expect(keyMatches({} as KeyboardEvent, undefined as any)).toBe(false);
expect(consoleSpy).toHaveBeenCalled();
consoleSpy.mockRestore();
});
});
describe("matchesShortcut", () => {
const createKeyboardEvent = (options: {
key: string;
code?: string;
ctrlKey?: boolean;
altKey?: boolean;
shiftKey?: boolean;
metaKey?: boolean;
}) => ({
key: options.key,
code: options.code || `Key${options.key.toUpperCase()}`,
ctrlKey: options.ctrlKey || false,
altKey: options.altKey || false,
shiftKey: options.shiftKey || false,
metaKey: options.metaKey || false
} as KeyboardEvent);
it("should match shortcuts with modifiers", () => {
const event = createKeyboardEvent({ key: "a", code: "KeyA", ctrlKey: true });
expect(matchesShortcut(event, "ctrl+a")).toBe(true);
const shiftEvent = createKeyboardEvent({ key: "a", code: "KeyA", shiftKey: true });
expect(matchesShortcut(shiftEvent, "shift+a")).toBe(true);
});
it("should match complex modifier combinations", () => {
const event = createKeyboardEvent({
key: "a",
code: "KeyA",
ctrlKey: true,
shiftKey: true
});
expect(matchesShortcut(event, "ctrl+shift+a")).toBe(true);
});
it("should not match when modifiers don't match", () => {
const event = createKeyboardEvent({ key: "a", code: "KeyA", ctrlKey: true });
expect(matchesShortcut(event, "alt+a")).toBe(false);
expect(matchesShortcut(event, "a")).toBe(false);
});
it("should not match when no modifiers are used", () => {
const event = createKeyboardEvent({ key: "a", code: "KeyA" });
expect(matchesShortcut(event, "a")).toBe(false);
});
it("should match some keys even with no modifiers", () => {
// Bare function keys
let event = createKeyboardEvent({ key: "F1", code: "F1" });
expect(matchesShortcut(event, "F1")).toBeTruthy();
expect(matchesShortcut(event, "f1")).toBeTruthy();
// Function keys with shift
event = createKeyboardEvent({ key: "F1", code: "F1", shiftKey: true });
expect(matchesShortcut(event, "Shift+F1")).toBeTruthy();
// Special keys
for (const keyCode of [ "Delete", "Enter" ]) {
event = createKeyboardEvent({ key: keyCode, code: keyCode });
expect(matchesShortcut(event, keyCode), `Key ${keyCode}`).toBeTruthy();
}
});
it("should handle alternative modifier names", () => {
const ctrlEvent = createKeyboardEvent({ key: "a", code: "KeyA", ctrlKey: true });
expect(matchesShortcut(ctrlEvent, "control+a")).toBe(true);
const metaEvent = createKeyboardEvent({ key: "a", code: "KeyA", metaKey: true });
expect(matchesShortcut(metaEvent, "cmd+a")).toBe(true);
expect(matchesShortcut(metaEvent, "command+a")).toBe(true);
});
it("should handle empty or invalid shortcuts", () => {
const event = createKeyboardEvent({ key: "a", code: "KeyA" });
expect(matchesShortcut(event, "")).toBe(false);
expect(matchesShortcut(event, null as any)).toBe(false);
});
it("should handle invalid events", () => {
const consoleSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
expect(matchesShortcut(null as any, "a")).toBe(false);
expect(matchesShortcut({} as KeyboardEvent, "a")).toBe(false);
expect(consoleSpy).toHaveBeenCalled();
consoleSpy.mockRestore();
});
it("should warn about invalid shortcut formats", () => {
const consoleSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
const event = createKeyboardEvent({ key: "a", code: "KeyA" });
matchesShortcut(event, "ctrl+");
matchesShortcut(event, "+");
expect(consoleSpy).toHaveBeenCalled();
consoleSpy.mockRestore();
});
});
describe("bindGlobalShortcut", () => {
it("should bind a global shortcut", () => {
const handler = vi.fn();
shortcuts.bindGlobalShortcut("ctrl+a", handler, "test-namespace");
expect(mockElement.addEventListener).toHaveBeenCalledWith("keydown", expect.any(Function));
});
it("should not bind shortcuts when handler is null", () => {
shortcuts.bindGlobalShortcut("ctrl+a", null, "test-namespace");
expect(mockElement.addEventListener).not.toHaveBeenCalled();
});
it("should remove previous bindings when namespace is reused", () => {
const handler1 = vi.fn();
const handler2 = vi.fn();
shortcuts.bindGlobalShortcut("ctrl+a", handler1, "test-namespace");
expect(mockElement.addEventListener).toHaveBeenCalledTimes(1);
shortcuts.bindGlobalShortcut("ctrl+b", handler2, "test-namespace");
expect(mockElement.removeEventListener).toHaveBeenCalledTimes(1);
expect(mockElement.addEventListener).toHaveBeenCalledTimes(2);
});
});
describe("bindElShortcut", () => {
it("should bind shortcut to specific element", () => {
const mockEl = { addEventListener: vi.fn(), removeEventListener: vi.fn() };
const mockJQueryEl = [mockEl] as any;
mockJQueryEl.length = 1;
const handler = vi.fn();
shortcuts.bindElShortcut(mockJQueryEl, "ctrl+a", handler, "test-namespace");
expect(mockEl.addEventListener).toHaveBeenCalledWith("keydown", expect.any(Function));
});
it("should fall back to document when element is empty", () => {
const emptyJQuery = [] as any;
emptyJQuery.length = 0;
const handler = vi.fn();
shortcuts.bindElShortcut(emptyJQuery, "ctrl+a", handler, "test-namespace");
expect(mockElement.addEventListener).toHaveBeenCalledWith("keydown", expect.any(Function));
});
});
describe("removeGlobalShortcut", () => {
it("should remove shortcuts for a specific namespace", () => {
const handler = vi.fn();
shortcuts.bindGlobalShortcut("ctrl+a", handler, "test-namespace");
shortcuts.removeGlobalShortcut("test-namespace");
expect(mockElement.removeEventListener).toHaveBeenCalledWith("keydown", expect.any(Function));
});
});
describe("event handling", () => {
it.skip("should call handler when shortcut matches", () => {
const handler = vi.fn();
shortcuts.bindGlobalShortcut("ctrl+a", handler, "test-namespace");
// Get the listener that was registered
expect(mockElement.addEventListener.mock.calls).toHaveLength(1);
const [, listener] = mockElement.addEventListener.mock.calls[0];
// First verify that matchesShortcut works directly
const testEvent = {
type: "keydown",
key: "a",
code: "KeyA",
ctrlKey: true,
altKey: false,
shiftKey: false,
metaKey: false,
preventDefault: vi.fn(),
stopPropagation: vi.fn()
} as any;
// Test matchesShortcut directly first
expect(matchesShortcut(testEvent, "ctrl+a")).toBe(true);
// Now test the actual listener
listener(testEvent);
expect(handler).toHaveBeenCalled();
expect(testEvent.preventDefault).toHaveBeenCalled();
expect(testEvent.stopPropagation).toHaveBeenCalled();
});
it("should not call handler for non-keyboard events", () => {
const handler = vi.fn();
shortcuts.bindGlobalShortcut("ctrl+a", handler, "test-namespace");
const [, listener] = mockElement.addEventListener.mock.calls[0];
// Simulate a non-keyboard event
const event = {
type: "click"
} as any;
listener(event);
expect(handler).not.toHaveBeenCalled();
});
it("should not call handler when shortcut doesn't match", () => {
const handler = vi.fn();
shortcuts.bindGlobalShortcut("ctrl+a", handler, "test-namespace");
const [, listener] = mockElement.addEventListener.mock.calls[0];
// Simulate a non-matching keydown event
const event = {
type: "keydown",
key: "b",
code: "KeyB",
ctrlKey: true,
altKey: false,
shiftKey: false,
metaKey: false,
preventDefault: vi.fn(),
stopPropagation: vi.fn()
} as any;
listener(event);
expect(handler).not.toHaveBeenCalled();
expect(event.preventDefault).not.toHaveBeenCalled();
});
});
describe('isIMEComposing', () => {
it('should return true when event.isComposing is true', () => {
const event = { isComposing: true, keyCode: 65 } as KeyboardEvent;
expect(isIMEComposing(event)).toBe(true);
});
it('should return true when keyCode is 229', () => {
const event = { isComposing: false, keyCode: 229 } as KeyboardEvent;
expect(isIMEComposing(event)).toBe(true);
});
it('should return true when both isComposing is true and keyCode is 229', () => {
const event = { isComposing: true, keyCode: 229 } as KeyboardEvent;
expect(isIMEComposing(event)).toBe(true);
});
it('should return false for normal keys', () => {
const event = { isComposing: false, keyCode: 65 } as KeyboardEvent;
expect(isIMEComposing(event)).toBe(false);
});
it('should return false when isComposing is undefined and keyCode is not 229', () => {
const event = { keyCode: 13 } as KeyboardEvent;
expect(isIMEComposing(event)).toBe(false);
});
it('should handle null/undefined events gracefully', () => {
expect(isIMEComposing(null as any)).toBe(false);
expect(isIMEComposing(undefined as any)).toBe(false);
});
});
});

View File

@@ -1,71 +1,7 @@
import utils from "./utils.js";
type ElementType = HTMLElement | Document;
type Handler = (e: KeyboardEvent) => void;
interface ShortcutBinding {
element: HTMLElement | Document;
shortcut: string;
handler: Handler;
namespace: string | null;
listener: (evt: Event) => void;
}
// 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
const functionKeyCodes: string[] = [];
for (let i = 1; i <= 19; i++) {
const keyCode = `F${i}`;
functionKeyCodes.push(keyCode);
keyMap[`f${i}`] = [ keyCode ];
}
const KEYCODES_WITH_NO_MODIFIER = new Set([
"Delete",
"Enter",
...functionKeyCodes
]);
/**
* Check if IME (Input Method Editor) is composing
* This is used to prevent keyboard shortcuts from firing during IME composition
* @param e - The keyboard event to check
* @returns true if IME is currently composing, false otherwise
*/
export function isIMEComposing(e: KeyboardEvent): boolean {
// Handle null/undefined events gracefully
if (!e) {
return false;
}
// Standard check for composition state
// e.isComposing is true when IME is actively composing
// e.keyCode === 229 is a fallback for older browsers where 229 indicates IME processing
return e.isComposing || e.keyCode === 229;
}
type Handler = (e: JQuery.TriggeredEvent<ElementType | Element, string, ElementType | Element, ElementType | Element>) => void;
function removeGlobalShortcut(namespace: string) {
bindGlobalShortcut("", null, namespace);
@@ -79,154 +15,38 @@ function bindElShortcut($el: JQuery<ElementType | Element>, keyboardShortcut: st
if (utils.isDesktop()) {
keyboardShortcut = normalizeShortcut(keyboardShortcut);
// If namespace is provided, remove all previous bindings for this namespace
let eventName = "keydown";
if (namespace) {
removeNamespaceBindings(namespace);
eventName += `.${namespace}`;
// if there's a namespace, then we replace the existing event handler with the new one
$el.off(eventName);
}
// Method can be called to remove the shortcut (e.g. when keyboardShortcut label is deleted)
if (keyboardShortcut && handler) {
const element = $el.length > 0 ? $el[0] as (HTMLElement | Document) : document;
const listener = (evt: Event) => {
// Only handle keyboard events
if (evt.type !== 'keydown' || !(evt instanceof KeyboardEvent)) {
return;
}
const e = evt as KeyboardEvent;
// Skip processing if IME is composing to prevent shortcuts from
// interfering with text input in CJK languages
if (isIMEComposing(e)) {
return;
}
if (matchesShortcut(e, keyboardShortcut)) {
e.preventDefault();
e.stopPropagation();
// method can be called to remove the shortcut (e.g. when keyboardShortcut label is deleted)
if (keyboardShortcut) {
$el.bind(eventName, keyboardShortcut, (e) => {
if (handler) {
handler(e);
}
};
// Add the event listener
element.addEventListener('keydown', listener);
// Store the binding for later cleanup
const binding: ShortcutBinding = {
element,
shortcut: keyboardShortcut,
handler,
namespace,
listener
};
const key = namespace || 'global';
if (!activeBindings.has(key)) {
activeBindings.set(key, []);
}
activeBindings.get(key)!.push(binding);
e.preventDefault();
e.stopPropagation();
});
}
}
}
function removeNamespaceBindings(namespace: string) {
const bindings = activeBindings.get(namespace);
if (bindings) {
// Remove all event listeners for this namespace
bindings.forEach(binding => {
binding.element.removeEventListener('keydown', binding.listener);
});
activeBindings.delete(namespace);
}
}
export function matchesShortcut(e: KeyboardEvent, shortcut: string): boolean {
if (!shortcut) return false;
// Ensure we have a proper KeyboardEvent with key property
if (!e || typeof e.key !== 'string') {
console.warn('matchesShortcut called with invalid event:', e);
return false;
}
const parts = shortcut.toLowerCase().split('+');
const key = parts[parts.length - 1]; // Last part is the actual key
const modifiers = parts.slice(0, -1); // Everything before is modifiers
// Defensive check - ensure we have a valid key
if (!key || key.trim() === '') {
console.warn('Invalid shortcut format:', shortcut);
return false;
}
// Check if the main key matches
if (!keyMatches(e, key)) {
return false;
}
// Check modifiers
const expectedCtrl = modifiers.includes('ctrl') || modifiers.includes('control');
const expectedAlt = modifiers.includes('alt');
const expectedShift = modifiers.includes('shift');
const expectedMeta = modifiers.includes('meta') || modifiers.includes('cmd') || modifiers.includes('command');
// Refuse key combinations that don't include modifiers because they interfere with the normal usage of the application.
// Some keys such as function keys are an exception.
if (!(expectedCtrl || expectedAlt || expectedShift || expectedMeta) && !KEYCODES_WITH_NO_MODIFIER.has(e.code)) {
return false;
}
return e.ctrlKey === expectedCtrl &&
e.altKey === expectedAlt &&
e.shiftKey === expectedShift &&
e.metaKey === expectedMeta;
}
export function keyMatches(e: KeyboardEvent, key: string): boolean {
// Defensive check for undefined/null key
if (!key) {
console.warn('keyMatches called with undefined/null key');
return false;
}
const mappedKeys = keyMap[key.toLowerCase()];
if (mappedKeys) {
return mappedKeys.includes(e.key) || mappedKeys.includes(e.code);
}
// For number keys, use the physical key code regardless of modifiers
// This works across all keyboard layouts
if (key >= '0' && key <= '9') {
return e.code === `Digit${key}`;
}
// For letter keys, use the physical key code for consistency
if (key.length === 1 && key >= 'a' && key <= 'z') {
return e.key.toLowerCase() === key.toLowerCase();
}
// For regular keys, check both key and code as fallback
return e.key.toLowerCase() === key.toLowerCase() ||
e.code.toLowerCase() === key.toLowerCase();
}
/**
* Simple normalization - just lowercase and trim whitespace
* Normalize to the form expected by the jquery.hotkeys.js
*/
function normalizeShortcut(shortcut: string): string {
if (!shortcut) {
return shortcut;
}
const normalized = shortcut.toLowerCase().trim().replace(/\s+/g, '');
// Warn about potentially problematic shortcuts
if (normalized.endsWith('+') || normalized.startsWith('+') || normalized.includes('++')) {
console.warn('Potentially malformed shortcut:', shortcut, '-> normalized to:', normalized);
}
return normalized;
return shortcut.toLowerCase().replace("enter", "return").replace("delete", "del").replace("ctrl+alt", "alt+ctrl").replace("meta+alt", "alt+meta"); // alt needs to be first;
}
export default {

View File

@@ -36,9 +36,7 @@ export function applyCopyToClipboardButton($codeBlock: JQuery<HTMLElement>) {
const $copyButton = $("<button>")
.addClass("bx component icon-action tn-tool-button bx-copy copy-button")
.attr("title", t("code_block.copy_title"))
.on("click", (e) => {
e.stopPropagation();
.on("click", () => {
if (!isShare) {
copyTextWithToast($codeBlock.text());
} else {

View File

@@ -1,9 +1,10 @@
import ws from "./ws.js";
import utils from "./utils.js";
export interface ToastOptions {
id?: string;
icon: string;
title?: string;
title: string;
message: string;
delay?: number;
autohide?: boolean;
@@ -11,32 +12,20 @@ export interface ToastOptions {
}
function toast(options: ToastOptions) {
const $toast = $(options.title
? `\
<div class="toast" role="alert" aria-live="assertive" aria-atomic="true">
<div class="toast-header">
<strong class="me-auto">
<span class="bx bx-${options.icon}"></span>
<span class="toast-title"></span>
</strong>
<button type="button" class="btn-close" data-bs-dismiss="toast" aria-label="Close"></button>
</div>
<div class="toast-body"></div>
</div>`
: `
<div class="toast" role="alert" aria-live="assertive" aria-atomic="true">
<div class="toast-icon">
const $toast = $(
`<div class="toast" role="alert" aria-live="assertive" aria-atomic="true">
<div class="toast-header">
<strong class="me-auto">
<span class="bx bx-${options.icon}"></span>
</div>
<div class="toast-body"></div>
<div class="toast-header">
<button type="button" class="btn-close" data-bs-dismiss="toast" aria-label="Close"></button>
</div>
</div>`
<span class="toast-title"></span>
</strong>
<button type="button" class="btn-close" data-bs-dismiss="toast" aria-label="Close"></button>
</div>
<div class="toast-body"></div>
</div>`
);
$toast.toggleClass("no-title", !options.title);
$toast.find(".toast-title").text(options.title ?? "");
$toast.find(".toast-title").text(options.title);
$toast.find(".toast-body").html(options.message);
if (options.id) {
@@ -81,6 +70,7 @@ function showMessage(message: string, delay = 2000) {
console.debug(utils.now(), "message:", message);
toast({
title: "Info",
icon: "check",
message: message,
autohide: true,
@@ -92,6 +82,7 @@ export function showError(message: string, delay = 10000) {
console.log(utils.now(), "error: ", message);
toast({
title: "Error",
icon: "alert",
message: message,
autohide: true,

View File

@@ -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;
@@ -47,6 +46,27 @@ function parseDate(str: string) {
}
}
// Source: https://stackoverflow.com/a/30465299/4898894
function getMonthsInDateRange(startDate: string, endDate: string) {
const start = startDate.split("-");
const end = endDate.split("-");
const startYear = parseInt(start[0]);
const endYear = parseInt(end[0]);
const dates: string[] = [];
for (let i = startYear; i <= endYear; i++) {
const endMonth = i != endYear ? 11 : parseInt(end[1]) - 1;
const startMon = i === startYear ? parseInt(start[1]) - 1 : 0;
for (let j = startMon; j <= endMonth; j = j > 12 ? j % 12 || 11 : j + 1) {
const month = j + 1;
const displayMonth = month < 10 ? "0" + month : month;
dates.push([i, displayMonth].join("-"));
}
}
return dates;
}
function padNum(num: number) {
return `${num <= 9 ? "0" : ""}${num}`;
}
@@ -105,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 {
@@ -124,23 +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);
}
/**
* Returns `true` if the client is running as a PWA, otherwise `false`.
*/
export function isPWA() {
return (
window.matchMedia('(display-mode: standalone)').matches
|| window.matchMedia('(display-mode: window-controls-overlay)').matches
|| window.navigator.standalone
|| window.navigator.windowControlsOverlay
);
}
export function isMac() {
function isMac() {
return navigator.platform.indexOf("Mac") > -1;
}
@@ -177,11 +185,7 @@ export function escapeQuotes(value: string) {
return value.replaceAll('"', "&quot;");
}
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) {
@@ -214,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
@@ -288,55 +292,7 @@ function isHtmlEmpty(html: string) {
);
}
function formatHtml(html: string) {
let indent = "\n";
const tab = "\t";
let i = 0;
let pre: { indent: string; tag: string }[] = [];
html = html
.replace(new RegExp("<pre>([\\s\\S]+?)?</pre>"), function (x) {
pre.push({ indent: "", tag: x });
return "<--TEMPPRE" + i++ + "/-->";
})
.replace(new RegExp("<[^<>]+>[^<]?", "g"), function (x) {
let ret;
const tagRegEx = /<\/?([^\s/>]+)/.exec(x);
let tag = tagRegEx ? tagRegEx[1] : "";
let p = new RegExp("<--TEMPPRE(\\d+)/-->").exec(x);
if (p) {
const pInd = parseInt(p[1]);
pre[pInd].indent = indent;
}
if (["area", "base", "br", "col", "command", "embed", "hr", "img", "input", "keygen", "link", "menuitem", "meta", "param", "source", "track", "wbr"].indexOf(tag) >= 0) {
// self closing tag
ret = indent + x;
} else {
if (x.indexOf("</") < 0) {
//open tag
if (x.charAt(x.length - 1) !== ">") ret = indent + x.substr(0, x.length - 1) + indent + tab + x.substr(x.length - 1, x.length);
else ret = indent + x;
!p && (indent += tab);
} else {
//close tag
indent = indent.substr(0, indent.length - 1);
if (x.charAt(x.length - 1) !== ">") ret = indent + x.substr(0, x.length - 1) + indent + x.substr(x.length - 1, x.length);
else ret = indent + x;
}
}
return ret;
});
for (i = pre.length; i--;) {
html = html.replace("<--TEMPPRE" + i + "/-->", pre[i].tag.replace("<pre>", "<pre>\n").replace("</pre>", pre[i].indent + "</pre>"));
}
return html.charAt(0) === "\n" ? html.substr(1, html.length - 1) : html;
}
export async function clearBrowserCache() {
async function clearBrowserCache() {
if (isElectron()) {
const win = dynamicRequire("@electron/remote").getCurrentWindow();
await win.webContents.session.clearCache();
@@ -350,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 {
@@ -424,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>) {
@@ -487,7 +428,7 @@ function sleep(time_ms: number) {
});
}
export function escapeRegExp(str: string) {
function escapeRegExp(str: string) {
return str.replace(/([.*+?^=!:${}()|\[\]\/\\])/g, "\\$1");
}
@@ -620,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()}`;
}
@@ -789,91 +731,16 @@ 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,
reloadTray,
parseDate,
getMonthsInDateRange,
formatDateISO,
formatDateTime,
formatTimeInterval,
@@ -881,7 +748,6 @@ export default {
localNowDateTime,
now,
isElectron,
isPWA,
isMac,
isCtrlKey,
assertArguments,
@@ -894,7 +760,6 @@ export default {
getNoteTypeClass,
getMimeTypeClass,
isHtmlEmpty,
formatHtml,
clearBrowserCache,
copySelectionToClipboard,
dynamicRequire,

View File

@@ -6,11 +6,9 @@ import frocaUpdater from "./froca_updater.js";
import appContext from "../components/app_context.js";
import { t } from "./i18n.js";
import type { EntityChange } from "../server_types.js";
import { WebSocketMessage } from "@triliumnext/commons";
import toast from "./toast.js";
type MessageHandler = (message: WebSocketMessage) => void;
let messageHandlers: MessageHandler[] = [];
type MessageHandler = (message: any) => void;
const messageHandlers: MessageHandler[] = [];
let ws: WebSocket;
let lastAcceptedEntityChangeId = window.glob.maxEntityChangeIdAtLoad;
@@ -49,14 +47,10 @@ function logInfo(message: string) {
window.logError = logError;
window.logInfo = logInfo;
export function subscribeToMessages(messageHandler: MessageHandler) {
function subscribeToMessages(messageHandler: MessageHandler) {
messageHandlers.push(messageHandler);
}
export function unsubscribeToMessage(messageHandler: MessageHandler) {
messageHandlers = messageHandlers.filter(handler => handler !== messageHandler);
}
// used to serialize frontend update operations
let consumeQueuePromise: Promise<void> | null = null;
@@ -279,17 +273,13 @@ function connectWebSocket() {
async function sendPing() {
if (Date.now() - lastPingTs > 30000) {
console.warn(utils.now(), "Lost websocket connection to the backend");
toast.showPersistent({
id: "lost-websocket-connection",
title: t("ws.lost-websocket-connection-title"),
message: t("ws.lost-websocket-connection-message"),
icon: "no-signal"
});
console.log(
utils.now(),
"Lost websocket connection to the backend. If you keep having this issue repeatedly, you might want to check your reverse proxy (nginx, apache) configuration and allow/unblock WebSocket."
);
}
if (ws.readyState === ws.OPEN) {
toast.closePersistent("lost-websocket-connection");
ws.send(
JSON.stringify({
type: "ping",

View File

@@ -1,4 +1,4 @@
import "bootstrap/dist/css/bootstrap.min.css";
import "./stylesheets/bootstrap.scss";
import "./stylesheets/auth.css";
// @TriliumNextTODO: is this even needed anymore?

View File

@@ -1,7 +1,8 @@
import "jquery";
import "jquery-hotkeys";
import utils from "./services/utils.js";
import ko from "knockout";
import "bootstrap/dist/css/bootstrap.min.css";
import "./stylesheets/bootstrap.scss";
// TriliumNextTODO: properly make use of below types
// type SetupModelSetupType = "new-document" | "sync-from-desktop" | "sync-from-server" | "";

View File

@@ -1,6 +1,6 @@
import "normalize.css";
import "boxicons/css/boxicons.min.css";
import "@triliumnext/ckeditor5/src/theme/ck-content.css";
import "@triliumnext/ckeditor5/content.css";
import "@triliumnext/share-theme/styles/index.css";
import "@triliumnext/share-theme/scripts/index.js";

View File

@@ -0,0 +1,2 @@
/* Import all of Bootstrap's CSS */
@use "bootstrap/scss/bootstrap";

View File

@@ -31,7 +31,7 @@
#center-pane > *:not(.split-note-container-widget),
#right-pane,
.title-row .note-icon-widget,
.title-row .icon-action,
.title-row .button-widget,
.ribbon-container,
.promoted-attributes-widget,
.scroll-padding-widget,
@@ -320,3 +320,8 @@ h6 {
page-break-after: avoid;
break-after: avoid;
}
figure.table {
/* Workaround for https://github.com/ckeditor/ckeditor5/issues/18903. Remove once official fix is released */
display: table !important;
}

View File

@@ -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,27 +139,12 @@ textarea,
color: var(--muted-text-color);
}
.form-group.disabled,
.form-checkbox.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 {
@@ -252,6 +215,10 @@ button.close:hover {
color: var(--main-text-color) !important;
}
.note-title[readonly] {
background: inherit;
}
.tdialog {
display: none;
}
@@ -290,11 +257,6 @@ button.close:hover {
pointer-events: none;
}
.icon-action.btn {
padding: 0 8px;
min-width: unset !important;
}
.ui-widget-content a:not(.ui-tabs-anchor) {
color: #337ab7 !important;
}
@@ -363,24 +325,28 @@ button kbd {
.tabulator-popup-container {
color: var(--menu-text-color) !important;
font-size: inherit;
background: var(--menu-background-color) !important;
background-color: var(--menu-background-color) !important;
user-select: none;
-webkit-user-select: none;
--bs-dropdown-zindex: 999;
--bs-dropdown-link-active-bg: var(--active-item-background-color) !important;
}
.dropdown-menu .dropdown-divider {
break-before: avoid;
break-after: avoid;
}
body.desktop .dropdown-menu,
body.desktop .tabulator-popup-container {
border: 1px solid var(--dropdown-border-color);
column-rule: 1px solid var(--dropdown-border-color);
box-shadow: 0px 10px 20px rgba(0, 0, 0, var(--dropdown-shadow-opacity));
animation: dropdown-menu-opening 100ms ease-in;
}
@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);
@@ -415,7 +381,7 @@ body.desktop .tabulator-popup-container {
}
.dropdown-menu a:hover:not(.disabled),
.dropdown-item:hover:not(.disabled, .dropdown-container-item),
.dropdown-item:hover:not(.disabled, .dropdown-item-container),
.tabulator-menu-item:hover {
color: var(--hover-item-text-color) !important;
background-color: var(--hover-item-background-color) !important;
@@ -423,9 +389,9 @@ body.desktop .tabulator-popup-container {
cursor: pointer;
}
.dropdown-container-item,
.dropdown-item.dropdown-container-item:hover,
.dropdown-container-item:active {
.dropdown-item-container,
.dropdown-item-container:hover,
.dropdown-item-container:active {
background: transparent;
cursor: default;
}
@@ -440,20 +406,14 @@ body #context-menu-container .dropdown-item > span {
align-items: center;
}
.dropdown-item span.keyboard-shortcut,
.dropdown-item *:not(.keyboard-shortcut) > kbd {
.dropdown-menu kbd {
flex-grow: 1;
text-align: right;
padding-inline-start: 12px;
}
.dropdown-menu kbd {
color: var(--muted-text-color);
border: none;
background-color: transparent;
box-shadow: none;
padding-bottom: 0;
padding: 0;
}
.dropdown-item,
@@ -462,12 +422,6 @@ body #context-menu-container .dropdown-item > span {
border: 1px solid transparent !important;
}
/* This is a workaround for Firefox not supporting break-before / break-after: avoid on columns.
* It usually wraps a menu item followed by a separator / header and another menu item. */
.dropdown-no-break {
break-inside: avoid;
}
.dropdown-item.disabled,
.dropdown-item.disabled kbd {
color: #aaa !important;
@@ -475,9 +429,9 @@ body #context-menu-container .dropdown-item > span {
.dropdown-item.active,
.dropdown-item:focus {
color: var(--active-item-text-color);
background-color: var(--active-item-background-color);
border-color: var(--active-item-border-color);
color: var(--active-item-text-color) !important;
background-color: var(--active-item-background-color) !important;
border-color: var(--active-item-border-color) !important;
outline: none;
}
@@ -688,10 +642,6 @@ table.promoted-attributes-in-tooltip th {
z-index: calc(var(--ck-z-panel) - 1) !important;
}
.tooltip.tooltip-top {
z-index: 32767 !important;
}
.tooltip-trigger {
background: transparent;
pointer-events: none;
@@ -877,34 +827,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;
@@ -992,11 +918,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;
@@ -1138,27 +1059,6 @@ a.external:not(.no-arrow):after, a[href^="http://"]:not(.no-arrow):after, a[href
.toast-body {
white-space: preserve-breaks;
overflow: hidden;
}
.toast.no-title {
display: flex;
flex-direction: row;
}
.toast.no-title .toast-icon {
display: flex;
align-items: center;
padding: var(--bs-toast-padding-y) var(--bs-toast-padding-x);
}
.toast.no-title .toast-body {
padding-left: 0;
padding-right: 0;
}
.toast.no-title .toast-header {
background-color: unset !important;
}
.ck-mentions .ck-button {
@@ -1267,10 +1167,6 @@ a.external:not(.no-arrow):after, a[href^="http://"]:not(.no-arrow):after, a[href
cursor: row-resize;
}
.hidden-ext.note-split + .gutter {
display: none;
}
#context-menu-cover.show {
position: fixed;
top: 0;
@@ -1492,7 +1388,7 @@ body:not(.mobile) #launcher-pane.horizontal .dropdown-submenu > .dropdown-menu {
cursor: pointer;
border: none;
color: var(--launcher-pane-text-color);
background-color: transparent;
background-color: var(--launcher-pane-background-color);
flex-shrink: 0;
}
@@ -1800,6 +1696,7 @@ body:not(.mobile) #launcher-pane.horizontal .dropdown-submenu > .dropdown-menu {
}
.note-split {
flex-basis: 0; /* so that each split has same width */
margin-left: auto;
margin-right: auto;
}
@@ -1837,12 +1734,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;
}
@@ -1863,37 +1764,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%;
padding: 1rem;
}
/* Command palette styling */
@@ -1911,24 +1795,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 {
@@ -2018,23 +1886,17 @@ body.zen #launcher-container,
body.zen #launcher-pane,
body.zen #left-pane,
body.zen #right-pane,
body.zen #mobile-sidebar-wrapper,
body.zen .tab-row-container,
body.zen .tab-row-widget,
body.zen .ribbon-container:not(:has(.classic-toolbar-widget.visible)),
body.zen .ribbon-container:has(.classic-toolbar-widget.visible) .ribbon-top-row,
body.zen .ribbon-container .ribbon-body:not(:has(.classic-toolbar-widget.visible)),
body.zen .note-icon-widget,
body.zen .title-row .icon-action,
body.zen .floating-buttons-children > *:not(.bx-edit-alt),
body.zen .action-button {
body.zen .title-row .button-widget,
body.zen .floating-buttons-children > *:not(.bx-edit-alt) {
display: none !important;
}
body.zen .split-note-container-widget > .gutter {
display: unset !important;
}
body.zen #launcher-pane {
position: absolute !important;
top: 0 !important;
@@ -2073,10 +1935,6 @@ body.zen .note-title-widget input {
background: transparent !important;
}
body.zen #detail-container {
width: 100%;
}
/* Content renderer */
footer.file-footer,
@@ -2394,20 +2252,25 @@ footer.webview-footer button {
transform: rotate(180deg);
}
/* CK Edito */
/* Insert text snippet: limit the width of the listed items to avoid overly long names */
:root body.desktop div.ck-template-form li.ck-list__item .ck-template-form__text-part > span {
max-width: 25vw;
overflow: hidden;
text-overflow: ellipsis;
.ocr-text-section {
margin: 10px 0;
padding: 10px;
background: var(--accented-background-color);
border-left: 3px solid var(--main-border-color);
text-align: left;
}
.revision-diff-added {
background: rgba(100, 200, 100, 0.5);
.ocr-header {
font-weight: bold;
margin-bottom: 8px;
font-size: 0.9em;
color: var(--muted-text-color);
}
.revision-diff-removed {
background: rgba(255, 100, 100, 0.5);
text-decoration: line-through;
.ocr-content {
max-height: 150px;
overflow-y: auto;
font-size: 0.9em;
line-height: 1.4;
white-space: pre-wrap;
}

View File

@@ -1,16 +1,16 @@
:root {
--theme-style: dark;
--main-font-family: Montserrat, sans-serif;
--main-font-family: Montserrat;
--main-font-size: normal;
--tree-font-family: Montserrat, sans-serif;
--tree-font-family: Montserrat;
--tree-font-size: normal;
--detail-font-family: Montserrat, sans-serif;
--detail-font-family: Montserrat;
--detail-font-size: normal;
--monospace-font-family: JetBrainsLight, monospace;
--monospace-font-family: JetBrainsLight;
--monospace-font-size: normal;
--main-background-color: #333;

View File

@@ -5,16 +5,16 @@ html {
/* either light or dark, colored theme with darker tones are also dark, used e.g. for note map node colors */
--theme-style: light;
--main-font-family: Montserrat, sans-serif;
--main-font-family: Montserrat;
--main-font-size: normal;
--tree-font-family: Montserrat, sans-serif;
--tree-font-family: Montserrat;
--tree-font-size: normal;
--detail-font-family: Montserrat, sans-serif;
--detail-font-family: Montserrat;
--detail-font-size: normal;
--monospace-font-family: JetBrainsLight, monospace;
--monospace-font-family: JetBrainsLight;
--monospace-font-size: normal;
--main-background-color: white;

Some files were not shown because too many files have changed in this diff Show More