mirror of
				https://github.com/zadam/trilium.git
				synced 2025-10-31 10:26:08 +01:00 
			
		
		
		
	Compare commits
	
		
			8 Commits
		
	
	
		
			feat/redo-
			...
			fix/resolv
		
	
	| Author | SHA1 | Date | |
|---|---|---|---|
|  | ac73d4c47a | ||
|  | e4006a83f2 | ||
|  | 5782f93824 | ||
|  | c76e265b67 | ||
|  | fefcd9457f | ||
|  | 72a779e695 | ||
|  | f6c27f458a | ||
|  | adfaa8b12c | 
| @@ -1,6 +1,6 @@ | ||||
| root = true | ||||
|  | ||||
| [*.{js,ts,tsx}] | ||||
| [*.{js,ts}] | ||||
| charset = utf-8 | ||||
| end_of_line = lf | ||||
| indent_size = 4 | ||||
|   | ||||
							
								
								
									
										2
									
								
								.github/FUNDING.yml
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										2
									
								
								.github/FUNDING.yml
									
									
									
									
										vendored
									
									
								
							| @@ -2,5 +2,3 @@ | ||||
|  | ||||
| github: [eliandoran] | ||||
| custom: ["https://paypal.me/eliandoran"] | ||||
| liberapay: ElianDoran | ||||
| buy_me_a_coffee: eliandoran | ||||
|   | ||||
							
								
								
									
										27
									
								
								.github/actions/build-electron/action.yml
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										27
									
								
								.github/actions/build-electron/action.yml
									
									
									
									
										vendored
									
									
								
							| @@ -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 | ||||
|   | ||||
							
								
								
									
										4
									
								
								.github/actions/build-server/action.yml
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										4
									
								
								.github/actions/build-server/action.yml
									
									
									
									
										vendored
									
									
								
							| @@ -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: | | ||||
|   | ||||
| @@ -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 | ||||
|             }); | ||||
|           } | ||||
							
								
								
									
										2
									
								
								.github/actions/report-size/action.yml
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										2
									
								
								.github/actions/report-size/action.yml
									
									
									
									
										vendored
									
									
								
							| @@ -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 | ||||
|   | ||||
							
								
								
									
										18
									
								
								.github/workflows/checks.yml
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										18
									
								
								.github/workflows/checks.yml
									
									
									
									
										vendored
									
									
								
							| @@ -1,18 +0,0 @@ | ||||
| name: Checks | ||||
| on: | ||||
|   push: | ||||
|   pull_request_target: | ||||
|     types: [synchronize] | ||||
|  | ||||
| jobs: | ||||
|   main: | ||||
|     runs-on: ubuntu-latest | ||||
|     permissions: | ||||
|       contents: write | ||||
|     steps: | ||||
|       - name: Check if PRs have conflicts | ||||
|         uses: eps1lon/actions-label-merge-conflict@v3 | ||||
|         if: github.repository == ${{ vars.REPO_MAIN }} && ${{secrets.MERGE_CONFLICT_LABEL_PAT}} | ||||
|         with: | ||||
|           dirtyLabel: "merge-conflicts" | ||||
|           repoToken: "${{ secrets.MERGE_CONFLICT_LABEL_PAT }}" | ||||
							
								
								
									
										6
									
								
								.github/workflows/codeql.yml
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										6
									
								
								.github/workflows/codeql.yml
									
									
									
									
										vendored
									
									
								
							| @@ -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` | ||||
| @@ -67,7 +67,7 @@ jobs: | ||||
|  | ||||
|     # Initializes the CodeQL tools for scanning. | ||||
|     - name: Initialize CodeQL | ||||
|       uses: github/codeql-action/init@v4 | ||||
|       uses: github/codeql-action/init@v3 | ||||
|       with: | ||||
|         languages: ${{ matrix.language }} | ||||
|         build-mode: ${{ matrix.build-mode }} | ||||
| @@ -95,6 +95,6 @@ jobs: | ||||
|         exit 1 | ||||
|  | ||||
|     - name: Perform CodeQL Analysis | ||||
|       uses: github/codeql-action/analyze@v4 | ||||
|       uses: github/codeql-action/analyze@v3 | ||||
|       with: | ||||
|         category: "/language:${{matrix.language}}" | ||||
|   | ||||
							
								
								
									
										129
									
								
								.github/workflows/deploy-docs.yml
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										129
									
								
								.github/workflows/deploy-docs.yml
									
									
									
									
										vendored
									
									
								
							| @@ -1,129 +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.14' | ||||
|           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 | ||||
|         if: github.repository == ${{ vars.REPO_MAIN }} && ${{secrets.CLOUDFLARE_API_TOKEN}} | ||||
|         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 }} | ||||
							
								
								
									
										47
									
								
								.github/workflows/dev.yml
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										47
									
								
								.github/workflows/dev.yml
									
									
									
									
										vendored
									
									
								
							| @@ -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 | ||||
|   | ||||
							
								
								
									
										20
									
								
								.github/workflows/main-docker.yml
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										20
									
								
								.github/workflows/main-docker.yml
									
									
									
									
										vendored
									
									
								
							| @@ -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-* | ||||
|   | ||||
							
								
								
									
										15
									
								
								.github/workflows/nightly.yml
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										15
									
								
								.github/workflows/nightly.yml
									
									
									
									
										vendored
									
									
								
							| @@ -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.4.0 | ||||
|         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.4.0 | ||||
|         uses: softprops/action-gh-release@v2.3.2 | ||||
|         if: ${{ github.event_name != 'pull_request' }} | ||||
|         with: | ||||
|           make_latest: false | ||||
|   | ||||
							
								
								
									
										26
									
								
								.github/workflows/playwright.yml
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										26
									
								
								.github/workflows/playwright.yml
									
									
									
									
										vendored
									
									
								
							| @@ -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,10 @@ jobs: | ||||
|       - name: Install dependencies | ||||
|         run: pnpm install --frozen-lockfile | ||||
|       - run: pnpm exec playwright install --with-deps | ||||
|       - uses: nrwl/nx-set-shas@v4 | ||||
|  | ||||
|       - 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 | ||||
|       # 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 | ||||
|   | ||||
							
								
								
									
										27
									
								
								.github/workflows/release.yml
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										27
									
								
								.github/workflows/release.yml
									
									
									
									
										vendored
									
									
								
							| @@ -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.4.0 | ||||
|         uses: softprops/action-gh-release@v2.3.2 | ||||
|         with: | ||||
|           draft: false | ||||
|           body_path: docs/Release Notes/Release Notes/${{ github.ref_name }}.md | ||||
|   | ||||
							
								
								
									
										11
									
								
								.github/workflows/unblock_signing.yml
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										11
									
								
								.github/workflows/unblock_signing.yml
									
									
									
									
										vendored
									
									
								
							| @@ -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 }} | ||||
							
								
								
									
										51
									
								
								.github/workflows/website.yml
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										51
									
								
								.github/workflows/website.yml
									
									
									
									
										vendored
									
									
								
							| @@ -1,51 +0,0 @@ | ||||
| name: Deploy website | ||||
|  | ||||
| on: | ||||
|   push: | ||||
|     branches: | ||||
|       - main | ||||
|     paths: | ||||
|       - "apps/website/**" | ||||
|  | ||||
|   pull_request: | ||||
|     paths: | ||||
|       - "apps/website/**" | ||||
|  | ||||
|   release: | ||||
|     types: [ released ] | ||||
|  | ||||
| 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 }} | ||||
							
								
								
									
										10
									
								
								.gitignore
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										10
									
								
								.gitignore
									
									
									
									
										vendored
									
									
								
							| @@ -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 | ||||
|  | ||||
| @@ -46,6 +47,3 @@ upload | ||||
|  | ||||
| /result | ||||
| .svelte-kit | ||||
|  | ||||
| # docs | ||||
| site/ | ||||
|   | ||||
							
								
								
									
										6
									
								
								.idea/.gitignore
									
									
									
										generated
									
									
										vendored
									
									
										Normal file
									
								
							
							
						
						
									
										6
									
								
								.idea/.gitignore
									
									
									
										generated
									
									
										vendored
									
									
										Normal 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
									
								
							
							
						
						
									
										15
									
								
								.idea/codeStyles/Project.xml
									
									
									
										generated
									
									
									
										Normal 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
									
								
							
							
						
						
									
										5
									
								
								.idea/codeStyles/codeStyleConfig.xml
									
									
									
										generated
									
									
									
										Normal 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
									
								
							
							
						
						
									
										12
									
								
								.idea/dataSources.xml
									
									
									
										generated
									
									
									
										Normal 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
									
								
							
							
						
						
									
										4
									
								
								.idea/encodings.xml
									
									
									
										generated
									
									
									
										Normal 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
									
								
							
							
						
						
									
										15
									
								
								.idea/git_toolbox_prj.xml
									
									
									
										generated
									
									
									
										Normal 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> | ||||
							
								
								
									
										11
									
								
								.idea/inspectionProfiles/Project_Default.xml
									
									
									
										generated
									
									
									
										Normal file
									
								
							
							
						
						
									
										11
									
								
								.idea/inspectionProfiles/Project_Default.xml
									
									
									
										generated
									
									
									
										Normal 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
									
								
							
							
						
						
									
										6
									
								
								.idea/jsLibraryMappings.xml
									
									
									
										generated
									
									
									
										Normal 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
									
								
							
							
						
						
									
										9
									
								
								.idea/jsLinters/jslint.xml
									
									
									
										generated
									
									
									
										Normal 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
									
								
							
							
						
						
									
										8
									
								
								.idea/misc.xml
									
									
									
										generated
									
									
									
										Normal 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
									
								
							
							
						
						
									
										8
									
								
								.idea/modules.xml
									
									
									
										generated
									
									
									
										Normal 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
									
								
							
							
						
						
									
										7
									
								
								.idea/sqldialects.xml
									
									
									
										generated
									
									
									
										Normal 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
									
								
							
							
						
						
									
										6
									
								
								.idea/vcs.xml
									
									
									
										generated
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,6 @@ | ||||
| <?xml version="1.0" encoding="UTF-8"?> | ||||
| <project version="4"> | ||||
|   <component name="VcsDirectoryMappings"> | ||||
|     <mapping directory="" vcs="Git" /> | ||||
|   </component> | ||||
| </project> | ||||
							
								
								
									
										4
									
								
								.mailmap
									
									
									
									
									
								
							
							
						
						
									
										4
									
								
								.mailmap
									
									
									
									
									
								
							| @@ -1,2 +1,2 @@ | ||||
| zadam <adam.zivner@gmail.com> | ||||
| zadam <zadam.apps@gmail.com> | ||||
| Adam Zivner <adam.zivner@gmail.com> | ||||
| Adam Zivner <zadam.apps@gmail.com> | ||||
							
								
								
									
										1
									
								
								.vscode/extensions.json
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										1
									
								
								.vscode/extensions.json
									
									
									
									
										vendored
									
									
								
							| @@ -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", | ||||
|   | ||||
							
								
								
									
										2
									
								
								.vscode/i18n-ally-custom-framework.yml
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										2
									
								
								.vscode/i18n-ally-custom-framework.yml
									
									
									
									
										vendored
									
									
								
							| @@ -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,7 +25,6 @@ scopeRangeRegex: "useTranslation\\(\\s*\\[?\\s*['\"`](.*?)['\"`]" | ||||
| # The "$1" will be replaced by the keypath specified. | ||||
| refactorTemplates: | ||||
|   - t("$1") | ||||
|   - {t("$1")} | ||||
|   - ${t("$1")} | ||||
|   - <%= t("$1") %> | ||||
|  | ||||
|   | ||||
							
								
								
									
										9
									
								
								.vscode/settings.json
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										9
									
								
								.vscode/settings.json
									
									
									
									
										vendored
									
									
								
							| @@ -28,12 +28,5 @@ | ||||
|     "typescript.validate.enable": true, | ||||
|     "typescript.tsserver.experimental.enableProjectDiagnostics": true, | ||||
|     "typescript.tsdk": "node_modules/typescript/lib", | ||||
|     "typescript.enablePromptUseWorkspaceTsdk": true, | ||||
|     "search.exclude": { | ||||
|         "**/node_modules": true, | ||||
|         "docs/**/*.html": true, | ||||
|         "docs/**/*.png": true, | ||||
|         "apps/server/src/assets/doc_notes/**": true, | ||||
|         "apps/edit-docs/demo/**": true | ||||
|     } | ||||
|     "typescript.enablePromptUseWorkspaceTsdk": true | ||||
| } | ||||
							
								
								
									
										5
									
								
								.vscode/snippets.code-snippets
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										5
									
								
								.vscode/snippets.code-snippets
									
									
									
									
										vendored
									
									
								
							| @@ -20,10 +20,5 @@ | ||||
|         "scope": "typescript", | ||||
|         "prefix": "jqf", | ||||
|         "body": ["private $${1:name}!: JQuery<HTMLElement>;"] | ||||
|     }, | ||||
|     "region": { | ||||
|         "scope": "css", | ||||
|         "prefix": "region", | ||||
|         "body": ["/* #region ${1:name} */\n$0\n/* #endregion */"] | ||||
|     } | ||||
| } | ||||
|   | ||||
							
								
								
									
										152
									
								
								CLAUDE.md
									
									
									
									
									
								
							
							
						
						
									
										152
									
								
								CLAUDE.md
									
									
									
									
									
								
							| @@ -1,152 +0,0 @@ | ||||
| # CLAUDE.md | ||||
|  | ||||
| This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. | ||||
|  | ||||
| ## 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. | ||||
|  | ||||
| ## Development Commands | ||||
|  | ||||
| ### Setup | ||||
| - `pnpm install` - Install all dependencies | ||||
| - `corepack enable` - Enable pnpm if not available | ||||
|  | ||||
| ### Running Applications | ||||
| - `pnpm run server:start` - Start development server (http://localhost:8080) | ||||
| - `pnpm run server:start-prod` - Run server in production mode | ||||
|  | ||||
| ### Building | ||||
| - `pnpm run client:build` - Build client application | ||||
| - `pnpm run server:build` - Build server application | ||||
| - `pnpm run electron:build` - Build desktop application | ||||
|  | ||||
| ### Testing | ||||
| - `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 coverage` - Generate coverage reports | ||||
|  | ||||
| ## Architecture Overview | ||||
|  | ||||
| ### Monorepo Structure | ||||
| - **apps/**: Runnable applications | ||||
|   - `client/` - Frontend application (shared by server and desktop) | ||||
|   - `server/` - Node.js server with web interface | ||||
|   - `desktop/` - Electron desktop application | ||||
|   - `web-clipper/` - Browser extension for saving web content | ||||
|   - Additional tools: `db-compare`, `dump-db`, `edit-docs` | ||||
|  | ||||
| - **packages/**: Shared libraries | ||||
|   - `commons/` - Shared interfaces and utilities | ||||
|   - `ckeditor5/` - Custom rich text editor with Trilium-specific plugins | ||||
|   - `codemirror/` - Code editor customizations | ||||
|   - `highlightjs/` - Syntax highlighting | ||||
|   - Custom CKEditor plugins: `ckeditor5-admonition`, `ckeditor5-footnotes`, `ckeditor5-math`, `ckeditor5-mermaid` | ||||
|  | ||||
| ### Core Architecture Patterns | ||||
|  | ||||
| #### Three-Layer Cache System | ||||
| - **Becca** (Backend Cache): Server-side entity cache (`apps/server/src/becca/`) | ||||
| - **Froca** (Frontend Cache): Client-side mirror of backend data (`apps/client/src/services/froca.ts`) | ||||
| - **Shaca** (Share Cache): Optimized cache for shared/published notes (`apps/server/src/share/`) | ||||
|  | ||||
| #### Entity System | ||||
| Core entities are defined in `apps/server/src/becca/entities/`: | ||||
| - `BNote` - Notes with content and metadata | ||||
| - `BBranch` - Hierarchical relationships between notes (allows multiple parents) | ||||
| - `BAttribute` - Key-value metadata attached to notes | ||||
| - `BRevision` - Note version history | ||||
| - `BOption` - Application configuration | ||||
|  | ||||
| #### Widget-Based UI | ||||
| Frontend uses a widget system (`apps/client/src/widgets/`): | ||||
| - `BasicWidget` - Base class for all UI components | ||||
| - `NoteContextAwareWidget` - Widgets that respond to note changes | ||||
| - `RightPanelWidget` - Widgets displayed in the right panel | ||||
| - Type-specific widgets in `type_widgets/` directory | ||||
|  | ||||
| #### API Architecture | ||||
| - **Internal API**: REST endpoints in `apps/server/src/routes/api/` | ||||
| - **ETAPI**: External API for third-party integrations (`apps/server/src/etapi/`) | ||||
| - **WebSocket**: Real-time synchronization (`apps/server/src/services/ws.ts`) | ||||
|  | ||||
| ### Key Files for Understanding Architecture | ||||
|  | ||||
| 1. **Application Entry Points**: | ||||
|    - `apps/server/src/main.ts` - Server startup | ||||
|    - `apps/client/src/desktop.ts` - Client initialization | ||||
|  | ||||
| 2. **Core Services**: | ||||
|    - `apps/server/src/becca/becca.ts` - Backend data management | ||||
|    - `apps/client/src/services/froca.ts` - Frontend data synchronization | ||||
|    - `apps/server/src/services/backend_script_api.ts` - Scripting API | ||||
|  | ||||
| 3. **Database Schema**: | ||||
|    - `apps/server/src/assets/db/schema.sql` - Core database structure | ||||
|  | ||||
| 4. **Configuration**: | ||||
|    - `package.json` - Project dependencies and scripts | ||||
|  | ||||
| ## Note Types and Features | ||||
|  | ||||
| Trilium supports multiple note types, each with specialized widgets: | ||||
| - **Text**: Rich text with CKEditor5 (markdown import/export) | ||||
| - **Code**: Syntax-highlighted code editing with CodeMirror | ||||
| - **File**: Binary file attachments | ||||
| - **Image**: Image display with editing capabilities | ||||
| - **Canvas**: Drawing/diagramming with Excalidraw | ||||
| - **Mermaid**: Diagram generation | ||||
| - **Relation Map**: Visual note relationship mapping | ||||
| - **Web View**: Embedded web pages | ||||
| - **Doc/Book**: Hierarchical documentation structure | ||||
|  | ||||
| ## Development Guidelines | ||||
|  | ||||
| ### Testing Strategy | ||||
| - Server tests run sequentially due to shared database | ||||
| - Client tests can run in parallel | ||||
| - E2E tests use Playwright for both server and desktop apps | ||||
| - Build validation tests check artifact integrity | ||||
|  | ||||
| ### Scripting System | ||||
| Trilium provides powerful user scripting capabilities: | ||||
| - Frontend scripts run in browser context | ||||
| - Backend scripts run in Node.js context with full API access | ||||
| - Script API documentation available in `docs/Script API/` | ||||
|  | ||||
| ### Internationalization | ||||
| - Translation files in `apps/client/src/translations/` | ||||
| - Supported languages: English, German, Spanish, French, Romanian, Chinese | ||||
|  | ||||
| ### Security Considerations | ||||
| - Per-note encryption with granular protected sessions | ||||
| - CSRF protection for API endpoints | ||||
| - OpenID and TOTP authentication support | ||||
| - Sanitization of user-generated content | ||||
|  | ||||
| ## Common Development Tasks | ||||
|  | ||||
| ### Adding New Note Types | ||||
| 1. Create widget in `apps/client/src/widgets/type_widgets/` | ||||
| 2. Register in `apps/client/src/services/note_types.ts` | ||||
| 3. Add backend handling in `apps/server/src/services/notes.ts` | ||||
|  | ||||
| ### Extending Search | ||||
| - Search expressions handled in `apps/server/src/services/search/` | ||||
| - Add new search operators in search context files | ||||
|  | ||||
| ### Custom CKEditor Plugins | ||||
| - Create new package in `packages/` following existing plugin structure | ||||
| - Register in `packages/ckeditor5/src/plugins.ts` | ||||
|  | ||||
| ### Database Migrations | ||||
| - Add migration scripts in `apps/server/src/migrations/` | ||||
| - Update schema in `apps/server/src/assets/db/schema.sql` | ||||
|  | ||||
| ## Build System Notes | ||||
| - Uses pnpm for monorepo management | ||||
| - Vite for fast development builds | ||||
| - ESBuild for production optimization | ||||
| - pnpm workspaces for dependency management | ||||
| - Docker support with multi-stage builds | ||||
							
								
								
									
										126
									
								
								README.md
									
									
									
									
									
								
							
							
						
						
									
										126
									
								
								README.md
									
									
									
									
									
								
							| @@ -1,11 +1,11 @@ | ||||
| # Trilium Notes | ||||
|  | ||||
|     | ||||
|  | ||||
|    | ||||
| [](https://app.relative-ci.com/projects/Di5q7dz9daNDZ9UXi0Bp) [](https://hosted.weblate.org/engage/trilium/) | ||||
|  | ||||
|  | ||||
|  | ||||
| [](https://app.relative-ci.com/projects/Di5q7dz9daNDZ9UXi0Bp) | ||||
|  | ||||
| [English](./README.md) | [Chinese (Simplified)](./docs/README-ZH_CN.md) | [Chinese (Traditional)](./docs/README-ZH_TW.md) | [Russian](./docs/README-ru.md) | [Japanese](./docs/README-ja.md) | [Italian](./docs/README-it.md) | [Spanish](./docs/README-es.md) | ||||
| [English](./README.md) | [Chinese](./docs/README-ZH_CN.md) | [Russian](./docs/README.ru.md) | [Japanese](./docs/README.ja.md) | [Italian](./docs/README.it.md) | [Spanish](./docs/README.es.md) | ||||
|  | ||||
| Trilium Notes is a free and open-source, cross-platform hierarchical note taking application with focus on building large personal knowledge bases. | ||||
|  | ||||
| @@ -13,23 +13,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 +46,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 +75,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 +90,7 @@ If your distribution is listed in the table below, use your distribution's packa | ||||
|  | ||||
| [](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,33 +104,23 @@ 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: | ||||
|  | ||||
| [](https://hosted.weblate.org/engage/trilium/) | ||||
|  | ||||
| ### Code | ||||
|  | ||||
| Download the repository, install dependencies using `pnpm` and then run the server (available at http://localhost:8080): | ||||
| ```shell | ||||
| git clone https://github.com/TriliumNext/Trilium.git | ||||
| cd Trilium | ||||
| git clone https://github.com/TriliumNext/Notes.git | ||||
| cd Notes | ||||
| pnpm install | ||||
| pnpm run server:start | ||||
| ``` | ||||
| @@ -143,57 +129,39 @@ pnpm run server:start | ||||
|  | ||||
| Download the repository, install dependencies using `pnpm` and then run the environment required to edit the documentation: | ||||
| ```shell | ||||
| git clone https://github.com/TriliumNext/Trilium.git | ||||
| cd Trilium | ||||
| git clone https://github.com/TriliumNext/Notes.git | ||||
| cd Notes | ||||
| pnpm install | ||||
| pnpm edit-docs:edit-docs | ||||
| pnpm nx run edit-docs:edit-docs | ||||
| ``` | ||||
|  | ||||
| ### Building the Executable | ||||
| Download the repository, install dependencies using `pnpm` and then build the desktop app for Windows: | ||||
| ```shell | ||||
| git clone https://github.com/TriliumNext/Trilium.git | ||||
| cd Trilium | ||||
| git clone https://github.com/TriliumNext/Notes.git | ||||
| cd Notes | ||||
| 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. | ||||
| * [Sarah Hussein](https://github.com/Sarah-Hussein) 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 | ||||
|   | ||||
| @@ -35,22 +35,22 @@ | ||||
|     "chore:generate-openapi": "tsx bin/generate-openapi.js" | ||||
|   }, | ||||
|   "devDependencies": {     | ||||
|     "@playwright/test": "1.56.0", | ||||
|     "@stylistic/eslint-plugin": "5.4.0",         | ||||
|     "@playwright/test": "1.53.2", | ||||
|     "@stylistic/eslint-plugin": "5.1.0",         | ||||
|     "@types/express": "5.0.3",     | ||||
|     "@types/node": "22.18.9",     | ||||
|     "@types/node": "22.16.2",     | ||||
|     "@types/yargs": "17.0.33", | ||||
|     "@vitest/coverage-v8": "3.2.4", | ||||
|     "eslint": "9.37.0", | ||||
|     "eslint": "9.30.1", | ||||
|     "eslint-plugin-simple-import-sort": "12.1.1", | ||||
|     "esm": "3.2.25", | ||||
|     "jsdoc": "4.0.5", | ||||
|     "jsdoc": "4.0.4", | ||||
|     "lorem-ipsum": "2.0.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.7", | ||||
|     "typedoc-plugin-missing-exports": "4.0.0" | ||||
|   }, | ||||
|   "optionalDependencies": { | ||||
|     "appdmg": "0.6.6" | ||||
|   | ||||
| @@ -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 | ||||
| @@ -1,30 +1,24 @@ | ||||
| { | ||||
|   "name": "@triliumnext/client", | ||||
|   "version": "0.99.1", | ||||
|   "version": "0.96.0", | ||||
|   "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.30.1", | ||||
|     "@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", | ||||
|     "@maplibre/maplibre-gl-leaflet": "0.1.3", | ||||
|     "@mermaid-js/layout-elk": "0.2.0", | ||||
|     "@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", | ||||
|     "@mermaid-js/layout-elk": "0.1.8", | ||||
|     "@mind-elixir/node-menu": "5.0.0", | ||||
|     "@popperjs/core": "2.11.8", | ||||
|     "@triliumnext/ckeditor5": "workspace:*", | ||||
| @@ -33,48 +27,60 @@ | ||||
|     "@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.4.0", | ||||
|     "mermaid": "11.12.0", | ||||
|     "mind-elixir": "5.3.2", | ||||
|     "marked": "16.0.0", | ||||
|     "mermaid": "11.8.1", | ||||
|     "mind-elixir": "5.0.1", | ||||
|     "normalize.css": "8.0.1", | ||||
|     "panzoom": "9.4.3", | ||||
|     "preact": "10.27.2", | ||||
|     "react-i18next": "16.0.0", | ||||
|     "preact": "10.26.9", | ||||
|     "split.js": "1.6.5", | ||||
|     "svg-pan-zoom": "3.6.2", | ||||
|     "tabulator-tables": "6.3.1", | ||||
|     "vanilla-js-wheel-zoom": "9.0.4" | ||||
|   }, | ||||
|   "devDependencies": { | ||||
|     "@ckeditor/ckeditor5-inspector": "5.0.0", | ||||
|     "@preact/preset-vite": "2.10.2", | ||||
|     "@ckeditor/ckeditor5-inspector": "4.1.0", | ||||
|     "@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": "20.0.0", | ||||
|     "@types/tabulator-tables": "6.2.7", | ||||
|     "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.0" | ||||
|   }, | ||||
|   "nx": { | ||||
|     "name": "client", | ||||
|     "targets": { | ||||
|       "serve": { | ||||
|         "dependsOn": [ | ||||
|           "^build" | ||||
|         ] | ||||
|       }, | ||||
|       "circular-deps": { | ||||
|         "command": "pnpx dpdm -T {projectRoot}/src/**/*.ts --tree=false --warning=false --skip-dynamic-imports=circular" | ||||
|       } | ||||
|     } | ||||
|   } | ||||
| } | ||||
| @@ -7,9 +7,6 @@ | ||||
|     "display": "standalone", | ||||
|     "scope": "/", | ||||
|     "start_url": "/", | ||||
|     "display_override": [ | ||||
|         "window-controls-overlay" | ||||
|     ], | ||||
|     "icons": [ | ||||
|         { | ||||
|             "src": "icon.png", | ||||
|   | ||||
| @@ -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"; | ||||
| @@ -28,17 +28,16 @@ import TouchBarComponent from "./touch_bar.js"; | ||||
| import type { CKTextEditor } from "@triliumnext/ckeditor5"; | ||||
| 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 +82,7 @@ export type CommandMappings = { | ||||
|     focusTree: CommandData; | ||||
|     focusOnTitle: CommandData; | ||||
|     focusOnDetail: CommandData; | ||||
|     focusOnSearchDefinition: Required<CommandData>; | ||||
|     searchNotes: CommandData & { | ||||
|         searchString?: string; | ||||
|         ancestorNoteId?: string | null; | ||||
| @@ -90,14 +90,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 +109,7 @@ export type CommandMappings = { | ||||
|     openedFileUpdated: CommandData & { | ||||
|         entityType: string; | ||||
|         entityId: string; | ||||
|         lastModifiedMs?: number; | ||||
|         lastModifiedMs: number; | ||||
|         filePath: string; | ||||
|     }; | ||||
|     focusAndSelectTitle: CommandData & { | ||||
| @@ -129,7 +122,6 @@ export type CommandMappings = { | ||||
|     showImportDialog: CommandData & { noteId: string }; | ||||
|     openNewNoteSplit: NoteCommandData; | ||||
|     openInWindow: NoteCommandData; | ||||
|     openInPopup: CommandData & { noteIdOrPath: string; }; | ||||
|     openNoteInNewTab: CommandData; | ||||
|     openNoteInNewSplit: CommandData; | ||||
|     openNoteInNewWindow: CommandData; | ||||
| @@ -138,9 +130,6 @@ export type CommandMappings = { | ||||
|     hideLeftPane: CommandData; | ||||
|     showCpuArchWarning: CommandData; | ||||
|     showLeftPane: CommandData; | ||||
|     showAttachments: CommandData; | ||||
|     showSearchHistory: CommandData; | ||||
|     showShareSubtree: CommandData; | ||||
|     hoistNote: CommandData & { noteId: string }; | ||||
|     leaveProtectedSession: CommandData; | ||||
|     enterProtectedSession: CommandData; | ||||
| @@ -151,7 +140,6 @@ export type CommandMappings = { | ||||
|     }; | ||||
|     openInTab: ContextMenuCommandData; | ||||
|     openNoteInSplit: ContextMenuCommandData; | ||||
|     openNoteInPopup: ContextMenuCommandData; | ||||
|     toggleNoteHoisting: ContextMenuCommandData; | ||||
|     insertNoteAfter: ContextMenuCommandData; | ||||
|     insertChildNote: ContextMenuCommandData; | ||||
| @@ -181,7 +169,7 @@ export type CommandMappings = { | ||||
|     deleteNotes: ContextMenuCommandData; | ||||
|     importIntoNote: ContextMenuCommandData; | ||||
|     exportNote: ContextMenuCommandData; | ||||
|     searchInSubtree: CommandData & { notePath: string; }; | ||||
|     searchInSubtree: ContextMenuCommandData; | ||||
|     moveNoteUp: ContextMenuCommandData; | ||||
|     moveNoteDown: ContextMenuCommandData; | ||||
|     moveNoteUpInHierarchy: ContextMenuCommandData; | ||||
| @@ -270,74 +258,6 @@ export type CommandMappings = { | ||||
|     closeThisNoteSplit: CommandData; | ||||
|     moveThisNoteSplit: CommandData & { isMovingLeft: boolean }; | ||||
|     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 }; | ||||
| @@ -354,30 +274,12 @@ export type CommandMappings = { | ||||
|  | ||||
|     geoMapCreateChildNote: CommandData; | ||||
|  | ||||
|     // Table view | ||||
|     addNewRow: CommandData & { | ||||
|         customOpts: CreateNoteOpts; | ||||
|         parentNotePath?: string; | ||||
|     }; | ||||
|     addNewTableColumn: CommandData & { | ||||
|         columnToEdit?: ColumnComponent; | ||||
|         referenceColumn?: ColumnComponent; | ||||
|         direction?: "before" | "after"; | ||||
|         type?: "label" | "relation"; | ||||
|     }; | ||||
|     deleteTableColumn: CommandData & { | ||||
|         columnToDelete?: ColumnComponent; | ||||
|     }; | ||||
|  | ||||
|     buildTouchBar: CommandData & { | ||||
|         TouchBar: typeof TouchBar; | ||||
|         buildIcon(name: string): NativeImage; | ||||
|     }; | ||||
|     refreshTouchBar: CommandData; | ||||
|     reloadTextEditor: CommandData; | ||||
|     chooseNoteType: CommandData & { | ||||
|         callback: ChooseNoteTypeCallback | ||||
|     } | ||||
| }; | ||||
|  | ||||
| type EventMappings = { | ||||
| @@ -530,7 +432,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 +525,7 @@ export class AppContext extends Component { | ||||
|             component.triggerCommand(commandName, { $el: $(this) }); | ||||
|         }); | ||||
|  | ||||
|         this.child(rootWidget as Component); | ||||
|         this.child(rootWidget); | ||||
|  | ||||
|         this.triggerEvent("initialRenderComplete", {}); | ||||
|     } | ||||
| @@ -650,20 +552,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 +571,25 @@ const appContext = new AppContext(window.glob.isMainWindow); | ||||
| $(window).on("beforeunload", () => { | ||||
|     let allSaved = true; | ||||
|  | ||||
|     appContext.beforeUnloadListeners = appContext.beforeUnloadListeners.filter((wr) => typeof wr === "function" || !!wr.deref()); | ||||
|     appContext.beforeUnloadListeners = appContext.beforeUnloadListeners.filter((wr) => !!wr.deref()); | ||||
|  | ||||
|     for (const listener of appContext.beforeUnloadListeners) { | ||||
|         if (typeof listener === "object") { | ||||
|             const component = listener.deref(); | ||||
|     for (const weakRef of appContext.beforeUnloadListeners) { | ||||
|         const component = weakRef.deref(); | ||||
|  | ||||
|             if (!component) { | ||||
|                 continue; | ||||
|             } | ||||
|         if (!component) { | ||||
|             continue; | ||||
|         } | ||||
|  | ||||
|             if (!component.beforeUnloadEvent()) { | ||||
|                 console.log(`Component ${component.componentId} is not finished saving its state.`); | ||||
|                 allSaved = false; | ||||
|             } | ||||
|         } else { | ||||
|             if (!listener()) { | ||||
|                 allSaved = false; | ||||
|             } | ||||
|         if (!component.beforeUnloadEvent()) { | ||||
|             console.log(`Component ${component.componentId} is not finished saving its state.`); | ||||
|  | ||||
|             toast.showMessage(t("app_context.please_wait_for_save"), 10000); | ||||
|  | ||||
|             allSaved = false; | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     if (!allSaved) { | ||||
|         toast.showMessage(t("app_context.please_wait_for_save"), 10000); | ||||
|         return "some string"; | ||||
|     } | ||||
| }); | ||||
|   | ||||
| @@ -1,8 +1,6 @@ | ||||
| import utils from "../services/utils.js"; | ||||
| import type { CommandMappings, CommandNames, EventData, EventNames } from "./app_context.js"; | ||||
|  | ||||
| type EventHandler = ((data: any) => void); | ||||
|  | ||||
| /** | ||||
|  * Abstract class for all components in the Trilium's frontend. | ||||
|  * | ||||
| @@ -21,7 +19,6 @@ export class TypedComponent<ChildT extends TypedComponent<ChildT>> { | ||||
|     initialized: Promise<void> | null; | ||||
|     parent?: TypedComponent<any>; | ||||
|     _position!: number; | ||||
|     private listeners: Record<string, EventHandler[]> | null = {}; | ||||
|  | ||||
|     constructor() { | ||||
|         this.componentId = `${this.sanitizedClassName}-${utils.randomString(8)}`; | ||||
| @@ -79,14 +76,6 @@ export class TypedComponent<ChildT extends TypedComponent<ChildT>> { | ||||
|     handleEventInChildren<T extends EventNames>(name: T, data: EventData<T>): Promise<unknown[] | unknown> | null { | ||||
|         const promises: Promise<unknown>[] = []; | ||||
|  | ||||
|         // Handle React children. | ||||
|         if (this.listeners?.[name]) { | ||||
|             for (const listener of this.listeners[name]) { | ||||
|                 listener(data); | ||||
|             } | ||||
|         } | ||||
|  | ||||
|         // Handle legacy children. | ||||
|         for (const child of this.children) { | ||||
|             const ret = child.handleEvent(name, data) as Promise<void>; | ||||
|  | ||||
| @@ -131,35 +120,6 @@ export class TypedComponent<ChildT extends TypedComponent<ChildT>> { | ||||
|  | ||||
|         return promise; | ||||
|     } | ||||
|  | ||||
|     registerHandler<T extends EventNames>(name: T, handler: EventHandler) { | ||||
|         if (!this.listeners) { | ||||
|             this.listeners = {}; | ||||
|         } | ||||
|  | ||||
|         if (!this.listeners[name]) { | ||||
|             this.listeners[name] = []; | ||||
|         } | ||||
|  | ||||
|         if (this.listeners[name].includes(handler)) { | ||||
|             return; | ||||
|         } | ||||
|  | ||||
|         this.listeners[name].push(handler); | ||||
|     } | ||||
|  | ||||
|     removeHandler<T extends EventNames>(name: T, handler: EventHandler) { | ||||
|         if (!this.listeners?.[name]?.includes(handler)) { | ||||
|             return; | ||||
|         } | ||||
|  | ||||
|         this.listeners[name] = this.listeners[name] | ||||
|             .filter(listener => listener !== handler); | ||||
|  | ||||
|         if (!this.listeners[name].length) { | ||||
|             delete this.listeners[name]; | ||||
|         } | ||||
|     } | ||||
| } | ||||
|  | ||||
| export default class Component extends TypedComponent<Component> {} | ||||
|   | ||||
| @@ -10,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(); | ||||
|         } | ||||
|     } | ||||
|  | ||||
| @@ -91,9 +113,7 @@ export default class Entrypoints extends Component { | ||||
|             if (win.isFullScreenable()) { | ||||
|                 win.setFullScreen(!win.isFullScreen()); | ||||
|             } | ||||
|         } else { | ||||
|             document.documentElement.requestFullscreen(); | ||||
|         } | ||||
|         } // outside of electron this is handled by the browser | ||||
|     } | ||||
|  | ||||
|     reloadFrontendAppCommand() { | ||||
| @@ -109,7 +129,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 +141,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 { | ||||
|   | ||||
| @@ -325,9 +325,8 @@ class NoteContext extends Component implements EventListener<"entitiesReloaded"> | ||||
|             return false; | ||||
|         } | ||||
|  | ||||
|         // Collections must always display a note list, even if no children. | ||||
|         const viewType = note.getLabelValue("viewType") ?? "grid"; | ||||
|         if (!["list", "grid"].includes(viewType)) { | ||||
|         // Some book types must always display a note list, even if no children. | ||||
|         if (["calendar", "table", "geoMap"].includes(note.getLabelValue("viewType") ?? "")) { | ||||
|             return true; | ||||
|         } | ||||
|  | ||||
|   | ||||
| @@ -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">) { | ||||
|   | ||||
| @@ -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() { | ||||
|   | ||||
| @@ -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(); | ||||
|         }); | ||||
|     } | ||||
|   | ||||
| @@ -8,9 +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 "./stylesheets/bootstrap.scss"; | ||||
| import "boxicons/css/boxicons.min.css"; | ||||
| import "jquery-hotkeys"; | ||||
| import "autocomplete.js/index_jquery.js"; | ||||
|  | ||||
| await appContext.earlyInit(); | ||||
| @@ -44,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)); | ||||
| @@ -116,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(); | ||||
|     } | ||||
| } | ||||
|   | ||||
| @@ -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,22 +256,6 @@ export default class FNote { | ||||
|         return this.children; | ||||
|     } | ||||
|  | ||||
|     async getSubtreeNoteIds(includeArchived = false) { | ||||
|         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)); | ||||
|         } | ||||
|         return noteIds.flat(); | ||||
|     } | ||||
|  | ||||
|     async getSubtreeNotes() { | ||||
|         const noteIds = await this.getSubtreeNoteIds(); | ||||
|         return (await this.froca.getNotes(noteIds)); | ||||
|     } | ||||
|  | ||||
|     async getChildNotes() { | ||||
|         return await this.froca.getNotes(this.children); | ||||
|     } | ||||
| @@ -907,8 +891,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 +1006,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 +1013,5 @@ export default class FNote { | ||||
|         return await server.get<NoteMetaData>(`notes/${this.noteId}/metadata`); | ||||
|     } | ||||
| } | ||||
|  | ||||
| export default FNote; | ||||
|   | ||||
							
								
								
									
										283
									
								
								apps/client/src/layouts/desktop_layout.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										283
									
								
								apps/client/src/layouts/desktop_layout.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,283 @@ | ||||
| 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.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 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 BulkActionsDialog from "../widgets/dialogs/bulk_actions.js"; | ||||
| import AboutDialog from "../widgets/dialogs/about.js"; | ||||
| import HelpDialog from "../widgets/dialogs/help.js"; | ||||
| import RecentChangesDialog from "../widgets/dialogs/recent_changes.js"; | ||||
| import BranchPrefixDialog from "../widgets/dialogs/branch_prefix.js"; | ||||
| import SortChildNotesDialog from "../widgets/dialogs/sort_child_notes.js"; | ||||
| import PasswordNoteSetDialog from "../widgets/dialogs/password_not_set.js"; | ||||
| import IncludeNoteDialog from "../widgets/dialogs/include_note.js"; | ||||
| import NoteTypeChooserDialog from "../widgets/dialogs/note_type_chooser.js"; | ||||
| import JumpToNoteDialog from "../widgets/dialogs/jump_to_note.js"; | ||||
| import AddLinkDialog from "../widgets/dialogs/add_link.js"; | ||||
| import CloneToDialog from "../widgets/dialogs/clone_to.js"; | ||||
| import MoveToDialog from "../widgets/dialogs/move_to.js"; | ||||
| import ImportDialog from "../widgets/dialogs/import.js"; | ||||
| import ExportDialog from "../widgets/dialogs/export.js"; | ||||
| import MarkdownImportDialog from "../widgets/dialogs/markdown_import.js"; | ||||
| import ProtectedSessionPasswordDialog from "../widgets/dialogs/protected_session_password.js"; | ||||
| import RevisionsDialog from "../widgets/dialogs/revisions.js"; | ||||
| import DeleteNotesDialog from "../widgets/dialogs/delete_notes.js"; | ||||
| import InfoDialog from "../widgets/dialogs/info.js"; | ||||
| import ConfirmDialog from "../widgets/dialogs/confirm.js"; | ||||
| import PromptDialog from "../widgets/dialogs/prompt.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 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, { hasTouchBar } 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"; | ||||
|  | ||||
| export default class DesktopLayout { | ||||
|  | ||||
|     private customWidgets: WidgetsByParent; | ||||
|  | ||||
|     constructor(customWidgets: WidgetsByParent) { | ||||
|         this.customWidgets = customWidgets; | ||||
|     } | ||||
|  | ||||
|     getRootWidget(appContext: AppContext) { | ||||
|         appContext.noteTreeWidget = new NoteTreeWidget(); | ||||
|  | ||||
|         const launcherPaneIsHorizontal = options.get("layoutOrientation") === "horizontal"; | ||||
|         const launcherPane = this.#buildLauncherPane(launcherPaneIsHorizontal); | ||||
|         const isElectron = utils.isElectron(); | ||||
|         const isMac = window.glob.platform === "darwin"; | ||||
|         const isWindows = window.glob.platform === "win32"; | ||||
|         const hasNativeTitleBar = window.glob.hasNativeTitleBar; | ||||
|  | ||||
|         /** | ||||
|          * If true, the tab bar is displayed above the launcher pane with full width; if false (default), the tab bar is displayed in the rest pane. | ||||
|          * On macOS we need to force the full-width tab bar on Electron in order to allow the semaphore (window controls) enough space. | ||||
|          */ | ||||
|         const fullWidthTabBar = launcherPaneIsHorizontal || (isElectron && !hasNativeTitleBar && isMac); | ||||
|         const customTitleBarButtons = !hasNativeTitleBar && !isMac && !isWindows; | ||||
|  | ||||
|         const rootContainer = new RootContainer(true) | ||||
|             .setParent(appContext) | ||||
|             .class((launcherPaneIsHorizontal ? "horizontal" : "vertical") + "-layout") | ||||
|             .optChild( | ||||
|                 fullWidthTabBar, | ||||
|                 new FlexContainer("row") | ||||
|                     .class("tab-row-container") | ||||
|                     .child(new FlexContainer("row").id("tab-row-left-spacer")) | ||||
|                     .optChild(launcherPaneIsHorizontal, new LeftPaneToggleWidget(true)) | ||||
|                     .child(new TabRowWidget().class("full-width")) | ||||
|                     .optChild(customTitleBarButtons, new TitleBarButtonsWidget()) | ||||
|                     .css("height", "40px") | ||||
|                     .css("background-color", "var(--launcher-pane-background-color)") | ||||
|                     .setParent(appContext) | ||||
|             ) | ||||
|             .optChild(launcherPaneIsHorizontal, launcherPane) | ||||
|             .child( | ||||
|                 new FlexContainer("row") | ||||
|                     .css("flex-grow", "1") | ||||
|                     .id("horizontal-main-container") | ||||
|                     .optChild(!launcherPaneIsHorizontal, launcherPane) | ||||
|                     .child( | ||||
|                         new LeftPaneContainer() | ||||
|                             .optChild(!launcherPaneIsHorizontal, new QuickSearchWidget()) | ||||
|                             .child(appContext.noteTreeWidget) | ||||
|                             .child(...this.customWidgets.get("left-pane")) | ||||
|                     ) | ||||
|                     .child( | ||||
|                         new FlexContainer("column") | ||||
|                             .id("rest-pane") | ||||
|                             .css("flex-grow", "1") | ||||
|                             .optChild(!fullWidthTabBar, new FlexContainer("row").child(new TabRowWidget()).optChild(customTitleBarButtons, new TitleBarButtonsWidget()).css("height", "40px")) | ||||
|                             .child( | ||||
|                                 new FlexContainer("row") | ||||
|                                     .filling() | ||||
|                                     .collapsible() | ||||
|                                     .id("vertical-main-container") | ||||
|                                     .child( | ||||
|                                         new FlexContainer("column") | ||||
|                                             .filling() | ||||
|                                             .collapsible() | ||||
|                                             .id("center-pane") | ||||
|                                             .child( | ||||
|                                                 new SplitNoteContainer(() => | ||||
|                                                     new NoteWrapperWidget() | ||||
|                                                         .child( | ||||
|                                                             new FlexContainer("row") | ||||
|                                                                 .class("title-row") | ||||
|                                                                 .css("height", "50px") | ||||
|                                                                 .css("min-height", "50px") | ||||
|                                                                 .css("align-items", "center") | ||||
|                                                                 .cssBlock(".title-row > * { margin: 5px; }") | ||||
|                                                                 .child(new NoteIconWidget()) | ||||
|                                                                 .child(new NoteTitleWidget()) | ||||
|                                                                 .child(new SpacerWidget(0, 1)) | ||||
|                                                                 .child(new MovePaneButton(true)) | ||||
|                                                                 .child(new MovePaneButton(false)) | ||||
|                                                                 .child(new ClosePaneButton()) | ||||
|                                                                 .child(new CreatePaneButton()) | ||||
|                                                         ) | ||||
|                                                         .child( | ||||
|                                                             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( | ||||
|                                                             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(new SqlTableSchemasWidget()) | ||||
|                                                                 .child(new NoteDetailWidget()) | ||||
|                                                                 .child(new NoteListWidget()) | ||||
|                                                                 .child(new SearchResultWidget()) | ||||
|                                                                 .child(new SqlResultWidget()) | ||||
|                                                                 .child(new ScrollPaddingWidget()) | ||||
|                                                         ) | ||||
|                                                         .child(new ApiLogWidget()) | ||||
|                                                         .child(new FindWidget()) | ||||
|                                                         .child( | ||||
|                                                             ...this.customWidgets.get("node-detail-pane"), // typo, let's keep it for a while as BC | ||||
|                                                             ...this.customWidgets.get("note-detail-pane") | ||||
|                                                         ) | ||||
|                                                 ) | ||||
|                                             ) | ||||
|                                             .child(...this.customWidgets.get("center-pane")) | ||||
|                                     ) | ||||
|                                     .child( | ||||
|                                         new RightPaneContainer() | ||||
|                                             .child(new TocWidget()) | ||||
|                                             .child(new HighlightsListWidget()) | ||||
|                                             .child(...this.customWidgets.get("right-pane")) | ||||
|                                     ) | ||||
|                             ) | ||||
|                     ) | ||||
|             ) | ||||
|             .child(new CloseZenButton()) | ||||
|  | ||||
|             // Desktop-specific dialogs. | ||||
|             .child(new PasswordNoteSetDialog()) | ||||
|             .child(new UploadAttachmentsDialog()); | ||||
|  | ||||
|         applyModals(rootContainer); | ||||
|         return rootContainer; | ||||
|     } | ||||
|  | ||||
|     #buildLauncherPane(isHorizontal: boolean) { | ||||
|         let launcherPane; | ||||
|  | ||||
|         if (isHorizontal) { | ||||
|             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(new GlobalMenuWidget(false)) | ||||
|                 .child(new LauncherContainer(false)) | ||||
|                 .child(new LeftPaneToggleWidget(false)); | ||||
|         } | ||||
|  | ||||
|         launcherPane.id("launcher-pane"); | ||||
|         return launcherPane; | ||||
|     } | ||||
| } | ||||
| @@ -1,196 +0,0 @@ | ||||
| import FlexContainer from "../widgets/containers/flex_container.js"; | ||||
| import TabRowWidget from "../widgets/tab_row.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 NoteDetailWidget from "../widgets/note_detail.js"; | ||||
| import PromotedAttributesWidget from "../widgets/promoted_attributes.js"; | ||||
| import NoteIconWidget from "../widgets/note_icon.jsx"; | ||||
| 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 CreatePaneButton from "../widgets/buttons/create_pane_button.js"; | ||||
| import ClosePaneButton from "../widgets/buttons/close_pane_button.js"; | ||||
| import RightPaneContainer from "../widgets/containers/right_pane_container.js"; | ||||
| import NoteWrapperWidget from "../widgets/note_wrapper.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 LauncherContainer from "../widgets/containers/launcher_container.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 options from "../services/options.js"; | ||||
| import utils from "../services/utils.js"; | ||||
| import type { AppContext } from "../components/app_context.js"; | ||||
| import type { WidgetsByParent } from "../services/bundle.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 { | ||||
|  | ||||
|     private customWidgets: WidgetsByParent; | ||||
|  | ||||
|     constructor(customWidgets: WidgetsByParent) { | ||||
|         this.customWidgets = customWidgets; | ||||
|     } | ||||
|  | ||||
|     getRootWidget(appContext: AppContext) { | ||||
|         appContext.noteTreeWidget = new NoteTreeWidget(); | ||||
|  | ||||
|         const launcherPaneIsHorizontal = options.get("layoutOrientation") === "horizontal"; | ||||
|         const launcherPane = this.#buildLauncherPane(launcherPaneIsHorizontal); | ||||
|         const isElectron = utils.isElectron(); | ||||
|         const isMac = window.glob.platform === "darwin"; | ||||
|         const isWindows = window.glob.platform === "win32"; | ||||
|         const hasNativeTitleBar = window.glob.hasNativeTitleBar; | ||||
|  | ||||
|         /** | ||||
|          * If true, the tab bar is displayed above the launcher pane with full width; if false (default), the tab bar is displayed in the rest pane. | ||||
|          * On macOS we need to force the full-width tab bar on Electron in order to allow the semaphore (window controls) enough space. | ||||
|          */ | ||||
|         const fullWidthTabBar = launcherPaneIsHorizontal || (isElectron && !hasNativeTitleBar && isMac); | ||||
|         const customTitleBarButtons = !hasNativeTitleBar && !isMac && !isWindows; | ||||
|  | ||||
|         const rootContainer = new RootContainer(true) | ||||
|             .setParent(appContext) | ||||
|             .class((launcherPaneIsHorizontal ? "horizontal" : "vertical") + "-layout") | ||||
|             .optChild( | ||||
|                 fullWidthTabBar, | ||||
|                 new FlexContainer("row") | ||||
|                     .class("tab-row-container") | ||||
|                     .child(new FlexContainer("row").id("tab-row-left-spacer")) | ||||
|                     .optChild(launcherPaneIsHorizontal, <LeftPaneToggle isHorizontalLayout={true} />) | ||||
|                     .child(new TabRowWidget().class("full-width")) | ||||
|                     .optChild(customTitleBarButtons, <TitleBarButtons />) | ||||
|                     .css("height", "40px") | ||||
|                     .css("background-color", "var(--launcher-pane-background-color)") | ||||
|                     .setParent(appContext) | ||||
|             ) | ||||
|             .optChild(launcherPaneIsHorizontal, launcherPane) | ||||
|             .child( | ||||
|                 new FlexContainer("row") | ||||
|                     .css("flex-grow", "1") | ||||
|                     .id("horizontal-main-container") | ||||
|                     .optChild(!launcherPaneIsHorizontal, launcherPane) | ||||
|                     .child( | ||||
|                         new LeftPaneContainer() | ||||
|                             .optChild(!launcherPaneIsHorizontal, new QuickSearchWidget()) | ||||
|                             .child(appContext.noteTreeWidget) | ||||
|                             .child(...this.customWidgets.get("left-pane")) | ||||
|                     ) | ||||
|                     .child( | ||||
|                         new FlexContainer("column") | ||||
|                             .id("rest-pane") | ||||
|                             .css("flex-grow", "1") | ||||
|                             .optChild(!fullWidthTabBar, new FlexContainer("row").child(new TabRowWidget()).optChild(customTitleBarButtons, <TitleBarButtons />).css("height", "40px")) | ||||
|                             .child( | ||||
|                                 new FlexContainer("row") | ||||
|                                     .filling() | ||||
|                                     .collapsible() | ||||
|                                     .id("vertical-main-container") | ||||
|                                     .child( | ||||
|                                         new FlexContainer("column") | ||||
|                                             .filling() | ||||
|                                             .collapsible() | ||||
|                                             .id("center-pane") | ||||
|                                             .child( | ||||
|                                                 new SplitNoteContainer(() => | ||||
|                                                     new NoteWrapperWidget() | ||||
|                                                         .child( | ||||
|                                                             new FlexContainer("row") | ||||
|                                                                 .class("title-row") | ||||
|                                                                 .css("height", "50px") | ||||
|                                                                 .css("min-height", "50px") | ||||
|                                                                 .css("align-items", "center") | ||||
|                                                                 .cssBlock(".title-row > * { margin: 5px; }") | ||||
|                                                                 .child(<NoteIconWidget />) | ||||
|                                                                 .child(<NoteTitleWidget />) | ||||
|                                                                 .child(new SpacerWidget(0, 1)) | ||||
|                                                                 .child(<MovePaneButton direction="left" />) | ||||
|                                                                 .child(<MovePaneButton direction="right" />) | ||||
|                                                                 .child(<ClosePaneButton />) | ||||
|                                                                 .child(<CreatePaneButton />) | ||||
|                                                         ) | ||||
|                                                         .child(<Ribbon />) | ||||
|                                                         .child(<SharedInfo />) | ||||
|                                                         .child(new WatchedFileUpdateStatusWidget()) | ||||
|                                                         .child(<FloatingButtons items={DESKTOP_FLOATING_BUTTONS} />) | ||||
|                                                         .child( | ||||
|                                                             new ScrollingContainer() | ||||
|                                                                 .filling() | ||||
|                                                                 .child(new PromotedAttributesWidget()) | ||||
|                                                                 .child(<SqlTableSchemas />) | ||||
|                                                                 .child(new NoteDetailWidget()) | ||||
|                                                                 .child(<NoteList />) | ||||
|                                                                 .child(<SearchResult />) | ||||
|                                                                 .child(<SqlResults />) | ||||
|                                                                 .child(<ScrollPadding />) | ||||
|                                                         ) | ||||
|                                                         .child(<ApiLog />) | ||||
|                                                         .child(new FindWidget()) | ||||
|                                                         .child( | ||||
|                                                             ...this.customWidgets.get("node-detail-pane"), // typo, let's keep it for a while as BC | ||||
|                                                             ...this.customWidgets.get("note-detail-pane") | ||||
|                                                         ) | ||||
|                                                 ) | ||||
|                                             ) | ||||
|                                             .child(...this.customWidgets.get("center-pane")) | ||||
|                                     ) | ||||
|                                     .child( | ||||
|                                         new RightPaneContainer() | ||||
|                                             .child(new TocWidget()) | ||||
|                                             .child(new HighlightsListWidget()) | ||||
|                                             .child(...this.customWidgets.get("right-pane")) | ||||
|                                     ) | ||||
|                             ) | ||||
|                     ) | ||||
|             ) | ||||
|             .child(<CloseZenModeButton />) | ||||
|  | ||||
|             // Desktop-specific dialogs. | ||||
|             .child(<PasswordNoteSetDialog />) | ||||
|             .child(<UploadAttachmentsDialog />); | ||||
|  | ||||
|         applyModals(rootContainer); | ||||
|         return rootContainer; | ||||
|     } | ||||
|  | ||||
|     #buildLauncherPane(isHorizontal: boolean) { | ||||
|         let launcherPane; | ||||
|  | ||||
|         if (isHorizontal) { | ||||
|             launcherPane = new FlexContainer("row") | ||||
|                 .css("height", "53px") | ||||
|                 .class("horizontal") | ||||
|                 .child(new LauncherContainer(true)) | ||||
|                 .child(<GlobalMenu isHorizontalLayout={true} />); | ||||
|         } else { | ||||
|             launcherPane = new FlexContainer("column") | ||||
|                 .css("width", "53px") | ||||
|                 .class("vertical") | ||||
|                 .child(<GlobalMenu isHorizontalLayout={false} />) | ||||
|                 .child(new LauncherContainer(false)) | ||||
|                 .child(<LeftPaneToggle isHorizontalLayout={false} />); | ||||
|         } | ||||
|  | ||||
|         launcherPane.id("launcher-pane"); | ||||
|         return launcherPane; | ||||
|     } | ||||
| } | ||||
							
								
								
									
										50
									
								
								apps/client/src/layouts/layout_commons.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										50
									
								
								apps/client/src/layouts/layout_commons.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,50 @@ | ||||
| import type RootContainer from "../widgets/containers/root_container.js"; | ||||
|  | ||||
| import AboutDialog from "../widgets/dialogs/about.js"; | ||||
| import HelpDialog from "../widgets/dialogs/help.js"; | ||||
| import JumpToNoteDialog from "../widgets/dialogs/jump_to_note.js"; | ||||
| import RecentChangesDialog from "../widgets/dialogs/recent_changes.js"; | ||||
| import PromptDialog from "../widgets/dialogs/prompt.js"; | ||||
| import AddLinkDialog from "../widgets/dialogs/add_link.js"; | ||||
| import IncludeNoteDialog from "../widgets/dialogs/include_note.js"; | ||||
| import BulkActionsDialog from "../widgets/dialogs/bulk_actions.js"; | ||||
| import BranchPrefixDialog from "../widgets/dialogs/branch_prefix.js"; | ||||
| import SortChildNotesDialog from "../widgets/dialogs/sort_child_notes.js"; | ||||
| import NoteTypeChooserDialog from "../widgets/dialogs/note_type_chooser.js"; | ||||
| import MoveToDialog from "../widgets/dialogs/move_to.js"; | ||||
| import CloneToDialog from "../widgets/dialogs/clone_to.js"; | ||||
| import ImportDialog from "../widgets/dialogs/import.js"; | ||||
| import ExportDialog from "../widgets/dialogs/export.js"; | ||||
| import MarkdownImportDialog from "../widgets/dialogs/markdown_import.js"; | ||||
| import ProtectedSessionPasswordDialog from "../widgets/dialogs/protected_session_password.js"; | ||||
| import ConfirmDialog from "../widgets/dialogs/confirm.js"; | ||||
| import RevisionsDialog from "../widgets/dialogs/revisions.js"; | ||||
| import DeleteNotesDialog from "../widgets/dialogs/delete_notes.js"; | ||||
| import InfoDialog from "../widgets/dialogs/info.js"; | ||||
| import IncorrectCpuArchDialog from "../widgets/dialogs/incorrect_cpu_arch.js"; | ||||
|  | ||||
| export function applyModals(rootContainer: RootContainer) { | ||||
|     rootContainer | ||||
|         .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()) | ||||
| } | ||||
| @@ -1,71 +0,0 @@ | ||||
| import type RootContainer from "../widgets/containers/root_container.js"; | ||||
|  | ||||
| import AboutDialog from "../widgets/dialogs/about.js"; | ||||
| import HelpDialog from "../widgets/dialogs/help.js"; | ||||
| import JumpToNoteDialog from "../widgets/dialogs/jump_to_note.js"; | ||||
| import RecentChangesDialog from "../widgets/dialogs/recent_changes.js"; | ||||
| import PromptDialog from "../widgets/dialogs/prompt.js"; | ||||
| import AddLinkDialog from "../widgets/dialogs/add_link.js"; | ||||
| import IncludeNoteDialog from "../widgets/dialogs/include_note.js"; | ||||
| import BulkActionsDialog from "../widgets/dialogs/bulk_actions.js"; | ||||
| import BranchPrefixDialog from "../widgets/dialogs/branch_prefix.js"; | ||||
| import SortChildNotesDialog from "../widgets/dialogs/sort_child_notes.js"; | ||||
| import NoteTypeChooserDialog from "../widgets/dialogs/note_type_chooser.js"; | ||||
| import MoveToDialog from "../widgets/dialogs/move_to.js"; | ||||
| import CloneToDialog from "../widgets/dialogs/clone_to.js"; | ||||
| import ImportDialog from "../widgets/dialogs/import.js"; | ||||
| import ExportDialog from "../widgets/dialogs/export.js"; | ||||
| import MarkdownImportDialog from "../widgets/dialogs/markdown_import.js"; | ||||
| import ProtectedSessionPasswordDialog from "../widgets/dialogs/protected_session_password.js"; | ||||
| import ConfirmDialog from "../widgets/dialogs/confirm.js"; | ||||
| import RevisionsDialog from "../widgets/dialogs/revisions.js"; | ||||
| import DeleteNotesDialog from "../widgets/dialogs/delete_notes.js"; | ||||
| 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 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"; | ||||
|  | ||||
| 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 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 PromotedAttributesWidget()) | ||||
|                 .child(new NoteDetailWidget()) | ||||
|                 .child(<NoteList displayOnlyCollections />)) | ||||
|         .child(<CallToActionDialog />); | ||||
| } | ||||
| @@ -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> | ||||
| @@ -40,8 +42,8 @@ kbd { | ||||
|     border: none; | ||||
|     cursor: pointer; | ||||
|     font-size: 1.25em; | ||||
|     padding-inline-start: 0.5em; | ||||
|     padding-inline-end: 0.5em; | ||||
|     padding-left: 0.5em; | ||||
|     padding-right: 0.5em; | ||||
|     color: var(--main-text-color); | ||||
| } | ||||
| .quick-search { | ||||
| @@ -59,7 +61,7 @@ const FANCYTREE_CSS = ` | ||||
|     margin-top: 0px; | ||||
|     overflow-y: auto; | ||||
|     contain: content; | ||||
|     padding-inline-start: 10px; | ||||
|     padding-left: 10px; | ||||
| } | ||||
| 
 | ||||
| .fancytree-custom-icon { | ||||
| @@ -68,7 +70,7 @@ const FANCYTREE_CSS = ` | ||||
| 
 | ||||
| .fancytree-title { | ||||
|     font-size: 1.5em; | ||||
|     margin-inline-start: 0.6em !important; | ||||
|     margin-left: 0.6em !important; | ||||
| } | ||||
| 
 | ||||
| .fancytree-node { | ||||
| @@ -81,7 +83,7 @@ const FANCYTREE_CSS = ` | ||||
| 
 | ||||
| span.fancytree-expander { | ||||
|     width: 24px !important; | ||||
|     margin-inline-end: 5px; | ||||
|     margin-right: 5px; | ||||
| } | ||||
| 
 | ||||
| .fancytree-loading span.fancytree-expander { | ||||
| @@ -101,7 +103,7 @@ span.fancytree-expander { | ||||
| .tree-wrapper .scroll-to-active-note-button, | ||||
| .tree-wrapper .tree-settings-button { | ||||
|     position: fixed; | ||||
|     margin-inline-end: 16px; | ||||
|     margin-right: 16px; | ||||
|     display: none; | ||||
| } | ||||
| 
 | ||||
| @@ -126,39 +128,44 @@ export default class MobileLayout { | ||||
|                             .class("d-md-flex d-lg-flex d-xl-flex col-12 col-sm-5 col-md-4 col-lg-3 col-xl-3") | ||||
|                             .id("mobile-sidebar-wrapper") | ||||
|                             .css("max-height", "100%") | ||||
|                             .css("padding-inline-start", "0") | ||||
|                             .css("padding-inline-end", "0") | ||||
|                             .css("padding-left", "0") | ||||
|                             .css("padding-right", "0") | ||||
|                             .css("contain", "content") | ||||
|                             .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()) | ||||
|                                     .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> | ||||
|     ); | ||||
| } | ||||
| @@ -1,3 +1,5 @@ | ||||
| import "./stylesheets/bootstrap.scss"; | ||||
|  | ||||
| // @ts-ignore - module = undefined | ||||
| // Required for correct loading of scripts in Electron | ||||
| if (typeof module === 'object') {window.module = module; module = undefined;} | ||||
|   | ||||
| @@ -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 { | ||||
| @@ -33,11 +26,6 @@ export interface MenuCommandItem<T> { | ||||
|     title: string; | ||||
|     command?: T; | ||||
|     type?: string; | ||||
|     /** | ||||
|      * The icon to display in the menu item. | ||||
|      * | ||||
|      * If not set, no icon is displayed and the item will appear shifted slightly to the left if there are other items with icons. To avoid this, use `bx bx-empty`. | ||||
|      */ | ||||
|     uiIcon?: string; | ||||
|     badges?: MenuItemBadge[]; | ||||
|     templateNoteId?: string; | ||||
| @@ -45,13 +33,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; | ||||
|  | ||||
| @@ -150,57 +137,20 @@ class ContextMenu { | ||||
|         this.$widget | ||||
|             .css({ | ||||
|                 display: "block", | ||||
|                 top, | ||||
|                 left | ||||
|                 top: top, | ||||
|                 left: left | ||||
|             }) | ||||
|             .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 +180,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 +236,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); | ||||
|             } | ||||
|         } | ||||
|     } | ||||
|   | ||||
| @@ -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 }), | ||||
|   | ||||
| @@ -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 } | ||||
|         ]; | ||||
|   | ||||
| @@ -16,8 +16,7 @@ function getItems(): MenuItem<CommandNames>[] { | ||||
|     return [ | ||||
|         { title: t("link_context_menu.open_note_in_new_tab"), command: "openNoteInNewTab", uiIcon: "bx bx-link-external" }, | ||||
|         { title: t("link_context_menu.open_note_in_new_split"), command: "openNoteInNewSplit", uiIcon: "bx bx-dock-right" }, | ||||
|         { title: t("link_context_menu.open_note_in_new_window"), command: "openNoteInNewWindow", uiIcon: "bx bx-window-open" }, | ||||
|         { title: t("link_context_menu.open_note_in_popup"), command: "openNoteInPopup", uiIcon: "bx bx-edit" } | ||||
|         { title: t("link_context_menu.open_note_in_new_window"), command: "openNoteInNewWindow", uiIcon: "bx bx-window-open" } | ||||
|     ]; | ||||
| } | ||||
|  | ||||
| @@ -41,8 +40,6 @@ function handleLinkContextMenuItem(command: string | undefined, notePath: string | ||||
|         appContext.triggerCommand("openNewNoteSplit", { ntxId, notePath, hoistedNoteId, viewScope }); | ||||
|     } else if (command === "openNoteInNewWindow") { | ||||
|         appContext.triggerCommand("openInWindow", { notePath, hoistedNoteId, viewScope }); | ||||
|     } else if (command === "openNoteInPopup") { | ||||
|         appContext.triggerCommand("openInPopup", { noteIdOrPath: notePath }) | ||||
|     } | ||||
| } | ||||
|  | ||||
|   | ||||
| @@ -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 { | ||||
| @@ -25,7 +23,7 @@ let lastTargetNode: HTMLElement | null = null; | ||||
|  | ||||
| // This will include all commands that implement ContextMenuCommandData, but it will not work if it additional options are added via the `|` operator, | ||||
| // so they need to be added manually. | ||||
| export type TreeCommandNames = FilteredCommandNames<ContextMenuCommandData> | "openBulkActionsDialog" | "searchInSubtree"; | ||||
| export type TreeCommandNames = FilteredCommandNames<ContextMenuCommandData> | "openBulkActionsDialog"; | ||||
|  | ||||
| export default class TreeContextMenu implements SelectMenuItemEventListener<TreeCommandNames> { | ||||
|     private treeWidget: NoteTreeWidget; | ||||
| @@ -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,54 @@ 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: 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.sort-by"), | ||||
|                         title: `${t("tree-context-menu.duplicate-subtree")} <kbd data-command="duplicateSubtree">`, | ||||
|                         command: "duplicateSubtree", | ||||
|                         uiIcon: "bx bx-outline", | ||||
|                         enabled: parentNotSearch && isNotRoot && !isHoisted && notOptionsOrHelp | ||||
|                     }, | ||||
|  | ||||
|                     { title: "----" }, | ||||
|  | ||||
|                     { 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")} <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 +180,32 @@ 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"), | ||||
|                 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 | ||||
|             } | ||||
| @@ -293,8 +246,6 @@ export default class TreeContextMenu implements SelectMenuItemEventListener<Tree | ||||
|             const { ntxId } = subContexts?.[subContexts.length - 1] ?? {}; | ||||
|  | ||||
|             this.treeWidget.triggerCommand("openNewNoteSplit", { ntxId, notePath }); | ||||
|         } else if (command === "openNoteInPopup") { | ||||
|             appContext.triggerCommand("openInPopup", { noteIdOrPath: notePath }) | ||||
|         } else if (command === "convertNoteToAttachment") { | ||||
|             if (!(await dialogService.confirm(t("tree-context-menu.convert-to-attachment-confirm")))) { | ||||
|                 return; | ||||
|   | ||||
| @@ -1,6 +1,7 @@ | ||||
| import appContext from "./components/app_context.js"; | ||||
| import noteAutocompleteService from "./services/note_autocomplete.js"; | ||||
| import glob from "./services/glob.js"; | ||||
| import "./stylesheets/bootstrap.scss"; | ||||
| import "boxicons/css/boxicons.min.css"; | ||||
| import "autocomplete.js/index_jquery.js"; | ||||
|  | ||||
|   | ||||
| @@ -1,15 +1,5 @@ | ||||
| import $ from "jquery"; | ||||
|  | ||||
| async function loadBootstrap() { | ||||
|     if (document.body.dir === "rtl") { | ||||
|         await import("bootstrap/dist/css/bootstrap.rtl.min.css"); | ||||
|     } else { | ||||
|         await import("bootstrap/dist/css/bootstrap.min.css"); | ||||
|     } | ||||
| } | ||||
|  | ||||
| (window as any).$ = $; | ||||
| (window as any).jQuery = $; | ||||
| await loadBootstrap(); | ||||
|  | ||||
| $("body").show(); | ||||
|   | ||||
| @@ -79,19 +79,7 @@ async function renderAttributes(attributes: FAttribute[], renderIsInheritable: b | ||||
|     return $container; | ||||
| } | ||||
|  | ||||
| const HIDDEN_ATTRIBUTES = [ | ||||
|     "originalFileName", | ||||
|     "fileSize", | ||||
|     "template", | ||||
|     "inherit", | ||||
|     "cssClass", | ||||
|     "iconClass", | ||||
|     "pageSize", | ||||
|     "viewType", | ||||
|     "geolocation", | ||||
|     "docName", | ||||
|     "webViewSrc" | ||||
| ]; | ||||
| const HIDDEN_ATTRIBUTES = ["originalFileName", "fileSize", "template", "inherit", "cssClass", "iconClass", "pageSize", "viewType", "geolocation", "docName"]; | ||||
|  | ||||
| async function renderNormalAttributes(note: FNote) { | ||||
|     const promotedDefinitionAttributes = note.getPromotedDefinitionAttributes(); | ||||
|   | ||||
| @@ -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`, { | ||||
| @@ -13,12 +12,11 @@ async function addLabel(noteId: string, name: string, value: string = "", isInhe | ||||
|     }); | ||||
| } | ||||
|  | ||||
| export async function setLabel(noteId: string, name: string, value: string = "", isInheritable = false) { | ||||
| export async function setLabel(noteId: string, name: string, value: string = "") { | ||||
|     await server.put(`notes/${noteId}/set-attribute`, { | ||||
|         type: "label", | ||||
|         name: name, | ||||
|         value: value, | ||||
|         isInheritable | ||||
|         value: value | ||||
|     }); | ||||
| } | ||||
|  | ||||
| @@ -26,14 +24,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 +51,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 { | ||||
|   | ||||
| @@ -95,15 +95,7 @@ async function moveToParentNote(branchIdsToMove: string[], newParentBranchId: st | ||||
|     } | ||||
| } | ||||
|  | ||||
| /** | ||||
|  * Shows the delete confirmation screen | ||||
|  * | ||||
|  * @param branchIdsToDelete the list of branch IDs to delete. | ||||
|  * @param forceDeleteAllClones whether to check by default the "Delete also all clones" checkbox. | ||||
|  * @param moveToParent whether to automatically go to the parent note path after a succesful delete. Usually makes sense if deleting the active note(s). | ||||
|  * @returns promise that returns false if the operation was cancelled or there was nothing to delete, true if the operation succeeded. | ||||
|  */ | ||||
| async function deleteNotes(branchIdsToDelete: string[], forceDeleteAllClones = false, moveToParent = true) { | ||||
| async function deleteNotes(branchIdsToDelete: string[], forceDeleteAllClones = false) { | ||||
|     branchIdsToDelete = filterRootNote(branchIdsToDelete); | ||||
|  | ||||
|     if (branchIdsToDelete.length === 0) { | ||||
| @@ -118,12 +110,10 @@ async function deleteNotes(branchIdsToDelete: string[], forceDeleteAllClones = f | ||||
|         return false; | ||||
|     } | ||||
|  | ||||
|     if (moveToParent) { | ||||
|         try { | ||||
|             await activateParentNotePath(); | ||||
|         } catch (e) { | ||||
|             console.error(e); | ||||
|         } | ||||
|     try { | ||||
|         await activateParentNotePath(); | ||||
|     } catch (e) { | ||||
|         console.error(e); | ||||
|     } | ||||
|  | ||||
|     const taskId = utils.randomString(10); | ||||
| @@ -210,7 +200,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 +218,7 @@ ws.subscribeToMessages(async (message) => { | ||||
| }); | ||||
|  | ||||
| ws.subscribeToMessages(async (message) => { | ||||
|     if (!("taskType" in message) || message.taskType !== "undeleteNotes") { | ||||
|     if (message.taskType !== "undeleteNotes") { | ||||
|         return; | ||||
|     } | ||||
|  | ||||
|   | ||||
| @@ -15,10 +15,8 @@ import AddRelationBulkAction from "../widgets/bulk_actions/relation/add_relation | ||||
| import RenameNoteBulkAction from "../widgets/bulk_actions/note/rename_note.js"; | ||||
| import { t } from "./i18n.js"; | ||||
| 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] | ||||
| @@ -91,17 +89,6 @@ function parseActions(note: FNote) { | ||||
|         .filter((action) => !!action); | ||||
| } | ||||
|  | ||||
| export async function executeBulkActions(targetNoteIds: string[], actions: BulkAction[], includeDescendants = false) { | ||||
|     await server.post("bulk-action/execute", { | ||||
|         noteIds: targetNoteIds, | ||||
|         includeDescendants, | ||||
|         actions | ||||
|     }); | ||||
|  | ||||
|     await ws.waitForMaxKnownEntityChangeId(); | ||||
|     toast.showMessage(t("bulk_actions.bulk_actions_executed"), 3000); | ||||
| } | ||||
|  | ||||
| export default { | ||||
|     addAction, | ||||
|     parseActions, | ||||
|   | ||||
| @@ -1,295 +0,0 @@ | ||||
| import { ActionKeyboardShortcut } from "@triliumnext/commons"; | ||||
| import appContext, { type CommandNames } from "../components/app_context.js"; | ||||
| import type NoteTreeWidget from "../widgets/note_tree.js"; | ||||
| import { t, translationsInitializedPromise } from "./i18n.js"; | ||||
| import keyboardActions from "./keyboard_actions.js"; | ||||
| import utils from "./utils.js"; | ||||
|  | ||||
| export interface CommandDefinition { | ||||
|     id: string; | ||||
|     name: string; | ||||
|     description?: string; | ||||
|     icon?: string; | ||||
|     shortcut?: string; | ||||
|     commandName?: CommandNames; | ||||
|     handler?: () => Promise<unknown> | null | undefined | void; | ||||
|     aliases?: string[]; | ||||
|     source?: "manual" | "keyboard-action"; | ||||
|     /** Reference to the original keyboard action for scope checking. */ | ||||
|     keyboardAction?: ActionKeyboardShortcut; | ||||
| } | ||||
|  | ||||
| class CommandRegistry { | ||||
|     private commands: Map<string, CommandDefinition> = new Map(); | ||||
|     private aliases: Map<string, string> = new Map(); | ||||
|  | ||||
|     constructor() { | ||||
|         this.loadCommands(); | ||||
|     } | ||||
|  | ||||
|     private async loadCommands() { | ||||
|         await translationsInitializedPromise; | ||||
|         this.registerDefaultCommands(); | ||||
|         await this.loadKeyboardActionsAsync(); | ||||
|     } | ||||
|  | ||||
|     private registerDefaultCommands() { | ||||
|         this.register({ | ||||
|             id: "export-note", | ||||
|             name: t("command_palette.export_note_title"), | ||||
|             description: t("command_palette.export_note_description"), | ||||
|             icon: "bx bx-export", | ||||
|             handler: () => { | ||||
|                 const notePath = appContext.tabManager.getActiveContextNotePath(); | ||||
|                 if (notePath) { | ||||
|                     appContext.triggerCommand("showExportDialog", { | ||||
|                         notePath, | ||||
|                         defaultType: "single" | ||||
|                     }); | ||||
|                 } | ||||
|             } | ||||
|         }); | ||||
|  | ||||
|         this.register({ | ||||
|             id: "show-attachments", | ||||
|             name: t("command_palette.show_attachments_title"), | ||||
|             description: t("command_palette.show_attachments_description"), | ||||
|             icon: "bx bx-paperclip", | ||||
|             handler: () => appContext.triggerCommand("showAttachments") | ||||
|         }); | ||||
|  | ||||
|         // Special search commands with custom logic | ||||
|         this.register({ | ||||
|             id: "search-notes", | ||||
|             name: t("command_palette.search_notes_title"), | ||||
|             description: t("command_palette.search_notes_description"), | ||||
|             icon: "bx bx-search", | ||||
|             handler: () => appContext.triggerCommand("searchNotes", {}) | ||||
|         }); | ||||
|  | ||||
|         this.register({ | ||||
|             id: "search-in-subtree", | ||||
|             name: t("command_palette.search_subtree_title"), | ||||
|             description: t("command_palette.search_subtree_description"), | ||||
|             icon: "bx bx-search-alt", | ||||
|             handler: () => { | ||||
|                 const notePath = appContext.tabManager.getActiveContextNotePath(); | ||||
|                 if (notePath) { | ||||
|                     appContext.triggerCommand("searchInSubtree", { notePath }); | ||||
|                 } | ||||
|             } | ||||
|         }); | ||||
|  | ||||
|         this.register({ | ||||
|             id: "show-search-history", | ||||
|             name: t("command_palette.search_history_title"), | ||||
|             description: t("command_palette.search_history_description"), | ||||
|             icon: "bx bx-history", | ||||
|             handler: () => appContext.triggerCommand("showSearchHistory") | ||||
|         }); | ||||
|  | ||||
|         this.register({ | ||||
|             id: "show-launch-bar", | ||||
|             name: t("command_palette.configure_launch_bar_title"), | ||||
|             description: t("command_palette.configure_launch_bar_description"), | ||||
|             icon: "bx bx-sidebar", | ||||
|             handler: () => appContext.triggerCommand("showLaunchBarSubtree") | ||||
|         }); | ||||
|     } | ||||
|  | ||||
|     private async loadKeyboardActionsAsync() { | ||||
|         try { | ||||
|             const actions = await keyboardActions.getActions(); | ||||
|             this.registerKeyboardActions(actions); | ||||
|         } catch (error) { | ||||
|             console.error("Failed to load keyboard actions:", error); | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     private registerKeyboardActions(actions: ActionKeyboardShortcut[]) { | ||||
|         for (const action of actions) { | ||||
|             // Skip actions that we've already manually registered | ||||
|             if (this.commands.has(action.actionName)) { | ||||
|                 continue; | ||||
|             } | ||||
|  | ||||
|             // Skip actions that don't have a description (likely separators) | ||||
|             if (!action.description) { | ||||
|                 continue; | ||||
|             } | ||||
|  | ||||
|             // Skip Electron-only actions if not in Electron environment | ||||
|             if (action.isElectronOnly && !utils.isElectron()) { | ||||
|                 continue; | ||||
|             } | ||||
|  | ||||
|             // Skip actions that should not appear in the command palette | ||||
|             if (action.ignoreFromCommandPalette) { | ||||
|                 continue; | ||||
|             } | ||||
|  | ||||
|             // Get the primary shortcut (first one in the list) | ||||
|             const primaryShortcut = action.effectiveShortcuts?.[0]; | ||||
|  | ||||
|             let name = action.friendlyName; | ||||
|             if (action.scope === "note-tree") { | ||||
|                 name = t("command_palette.tree-action-name", { name: action.friendlyName }); | ||||
|             } | ||||
|  | ||||
|             // Create a command definition from the keyboard action | ||||
|             const commandDef: CommandDefinition = { | ||||
|                 id: action.actionName, | ||||
|                 name, | ||||
|                 description: action.description, | ||||
|                 icon: action.iconClass, | ||||
|                 shortcut: primaryShortcut ? this.formatShortcut(primaryShortcut) : undefined, | ||||
|                 commandName: action.actionName as CommandNames, | ||||
|                 source: "keyboard-action", | ||||
|                 keyboardAction: action | ||||
|             }; | ||||
|  | ||||
|             this.register(commandDef); | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     private formatShortcut(shortcut: string): string { | ||||
|         // Convert electron accelerator format to display format | ||||
|         return shortcut | ||||
|             .replace(/CommandOrControl/g, 'Ctrl') | ||||
|             .replace(/\+/g, ' + '); | ||||
|     } | ||||
|  | ||||
|     register(command: CommandDefinition) { | ||||
|         this.commands.set(command.id, command); | ||||
|  | ||||
|         // Register aliases | ||||
|         if (command.aliases) { | ||||
|             for (const alias of command.aliases) { | ||||
|                 this.aliases.set(alias.toLowerCase(), command.id); | ||||
|             } | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     getCommand(id: string): CommandDefinition | undefined { | ||||
|         return this.commands.get(id); | ||||
|     } | ||||
|  | ||||
|     getAllCommands(): CommandDefinition[] { | ||||
|         const commands = Array.from(this.commands.values()); | ||||
|  | ||||
|         // Sort commands by name | ||||
|         commands.sort((a, b) => a.name.localeCompare(b.name)); | ||||
|  | ||||
|         return commands; | ||||
|     } | ||||
|  | ||||
|     searchCommands(query: string): CommandDefinition[] { | ||||
|         const normalizedQuery = query.toLowerCase(); | ||||
|         const results: { command: CommandDefinition; score: number }[] = []; | ||||
|  | ||||
|         for (const command of this.commands.values()) { | ||||
|             let score = 0; | ||||
|  | ||||
|             // Exact match on name | ||||
|             if (command.name.toLowerCase() === normalizedQuery) { | ||||
|                 score = 100; | ||||
|             } | ||||
|             // Name starts with query | ||||
|             else if (command.name.toLowerCase().startsWith(normalizedQuery)) { | ||||
|                 score = 80; | ||||
|             } | ||||
|             // Name contains query | ||||
|             else if (command.name.toLowerCase().includes(normalizedQuery)) { | ||||
|                 score = 60; | ||||
|             } | ||||
|             // Description contains query | ||||
|             else if (command.description?.toLowerCase().includes(normalizedQuery)) { | ||||
|                 score = 40; | ||||
|             } | ||||
|             // Check aliases | ||||
|             else if (command.aliases?.some(alias => alias.toLowerCase().includes(normalizedQuery))) { | ||||
|                 score = 50; | ||||
|             } | ||||
|  | ||||
|             if (score > 0) { | ||||
|                 results.push({ command, score }); | ||||
|             } | ||||
|         } | ||||
|  | ||||
|         // Sort by score (highest first) and then by name | ||||
|         results.sort((a, b) => { | ||||
|             if (a.score !== b.score) { | ||||
|                 return b.score - a.score; | ||||
|             } | ||||
|             return a.command.name.localeCompare(b.command.name); | ||||
|         }); | ||||
|  | ||||
|         return results.map(r => r.command); | ||||
|     } | ||||
|  | ||||
|     async executeCommand(commandId: string) { | ||||
|         const command = this.getCommand(commandId); | ||||
|         if (!command) { | ||||
|             console.error(`Command not found: ${commandId}`); | ||||
|             return; | ||||
|         } | ||||
|  | ||||
|         // Execute custom handler if provided | ||||
|         if (command.handler) { | ||||
|             await command.handler(); | ||||
|             return; | ||||
|         } | ||||
|  | ||||
|         // Handle keyboard action with scope-aware execution | ||||
|         if (command.keyboardAction && command.commandName) { | ||||
|             if (command.keyboardAction.scope === "note-tree") { | ||||
|                 this.executeWithNoteTreeFocus(command.commandName); | ||||
|             } else if (command.keyboardAction.scope === "text-detail") { | ||||
|                 this.executeWithTextDetail(command.commandName); | ||||
|             } else { | ||||
|                 appContext.triggerCommand(command.commandName, { | ||||
|                     ntxId: appContext.tabManager.activeNtxId | ||||
|                 }); | ||||
|             } | ||||
|             return; | ||||
|         } | ||||
|  | ||||
|         // Fallback for commands without keyboard action reference | ||||
|         if (command.commandName) { | ||||
|             appContext.triggerCommand(command.commandName, { | ||||
|                 ntxId: appContext.tabManager.activeNtxId | ||||
|             }); | ||||
|             return; | ||||
|         } | ||||
|  | ||||
|         console.error(`Command ${commandId} has no handler or commandName`); | ||||
|     } | ||||
|  | ||||
|     private executeWithNoteTreeFocus(actionName: CommandNames) { | ||||
|         const tree = document.querySelector(".tree-wrapper") as HTMLElement; | ||||
|         if (!tree) { | ||||
|             return; | ||||
|         } | ||||
|  | ||||
|         const treeComponent = appContext.getComponentByEl(tree) as NoteTreeWidget; | ||||
|         const activeNode = treeComponent.getActiveNode(); | ||||
|         treeComponent.triggerCommand(actionName, { | ||||
|             ntxId: appContext.tabManager.activeNtxId, | ||||
|             node: activeNode | ||||
|         }); | ||||
|     } | ||||
|  | ||||
|     private async executeWithTextDetail(actionName: CommandNames) { | ||||
|         const typeWidget = await appContext.tabManager.getActiveContext()?.getTypeWidget(); | ||||
|         if (!typeWidget) { | ||||
|             return; | ||||
|         } | ||||
|  | ||||
|         typeWidget.triggerCommand(actionName, { | ||||
|             ntxId: appContext.tabManager.activeNtxId | ||||
|         }); | ||||
|     } | ||||
| } | ||||
|  | ||||
| const commandRegistry = new CommandRegistry(); | ||||
| export default commandRegistry; | ||||
| @@ -65,9 +65,6 @@ async function getRenderedContent(this: {} | { ctx: string }, entity: FNote | FA | ||||
|  | ||||
|         $renderedContent.append($("<div>").append("<div>This note is protected and to access it you need to enter password.</div>").append("<br/>").append($button)); | ||||
|     } else if (entity instanceof FNote) { | ||||
|         $renderedContent | ||||
|             .css("display", "flex") | ||||
|             .css("flex-direction", "column"); | ||||
|         $renderedContent.append( | ||||
|             $("<div>") | ||||
|                 .css("display", "flex") | ||||
| @@ -75,33 +72,8 @@ async function getRenderedContent(this: {} | { ctx: string }, entity: FNote | FA | ||||
|                 .css("align-items", "center") | ||||
|                 .css("height", "100%") | ||||
|                 .css("font-size", "500%") | ||||
|                 .css("flex-grow", "1") | ||||
|                 .append($("<span>").addClass(entity.getIcon())) | ||||
|         ); | ||||
|  | ||||
|         if (entity.type === "webView" && entity.hasLabel("webViewSrc")) { | ||||
|             const $footer = $("<footer>") | ||||
|                 .addClass("webview-footer"); | ||||
|             const $openButton = $(` | ||||
|                 <button class="file-open btn btn-primary" type="button"> | ||||
|                     <span class="bx bx-link-external"></span> | ||||
|                     ${t("content_renderer.open_externally")} | ||||
|                 </button> | ||||
|             `) | ||||
|                 .appendTo($footer) | ||||
|                 .on("click", () => { | ||||
|                     const webViewSrc = entity.getLabelValue("webViewSrc"); | ||||
|                     if (webViewSrc) { | ||||
|                         if (utils.isElectron()) { | ||||
|                             const electron = utils.dynamicRequire("electron"); | ||||
|                             electron.shell.openExternal(webViewSrc); | ||||
|                         } else { | ||||
|                             window.open(webViewSrc, '_blank', 'noopener,noreferrer'); | ||||
|                         } | ||||
|                     } | ||||
|                 }); | ||||
|             $footer.appendTo($renderedContent); | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     if (entity instanceof FNote) { | ||||
| @@ -256,19 +228,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); | ||||
|  | ||||
|   | ||||
| @@ -4,14 +4,14 @@ import type { ConfirmDialogOptions, ConfirmDialogResult, ConfirmWithMessageOptio | ||||
| import type { PromptDialogOptions } from "../widgets/dialogs/prompt.js"; | ||||
| import { focusSavedElement, saveFocusedElement } from "./focus.js"; | ||||
|  | ||||
| export async function openDialog($dialog: JQuery<HTMLElement>, closeActDialog = true, config?: Partial<Modal.Options>) { | ||||
| export async function openDialog($dialog: JQuery<HTMLElement>, closeActDialog = true) { | ||||
|     if (closeActDialog) { | ||||
|         closeActiveDialog(); | ||||
|         glob.activeDialog = $dialog; | ||||
|     } | ||||
|  | ||||
|     saveFocusedElement(); | ||||
|     Modal.getOrCreateInstance($dialog[0], config).show(); | ||||
|     Modal.getOrCreateInstance($dialog[0]).show(); | ||||
|  | ||||
|     $dialog.on("hidden.bs.modal", () => { | ||||
|         const $autocompleteEl = $(".aa-input"); | ||||
| @@ -41,14 +41,8 @@ async function info(message: string) { | ||||
|     return new Promise((res) => appContext.triggerCommand("showInfoDialog", { message, callback: res })); | ||||
| } | ||||
|  | ||||
| /** | ||||
|  * Displays a confirmation dialog with the given message. | ||||
|  * | ||||
|  * @param message the message to display in the dialog. | ||||
|  * @returns A promise that resolves to true if the user confirmed, false otherwise. | ||||
|  */ | ||||
| async function confirm(message: string) { | ||||
|     return new Promise<boolean>((res) => | ||||
|     return new Promise((res) => | ||||
|         appContext.triggerCommand("showConfirmDialog", <ConfirmWithMessageOptions>{ | ||||
|             message, | ||||
|             callback: (x: false | ConfirmDialogOptions) => res(x && x.confirmed) | ||||
| @@ -60,7 +54,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 })); | ||||
| } | ||||
|  | ||||
|   | ||||
| @@ -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`; | ||||
| } | ||||
|   | ||||
| @@ -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; | ||||
|     } | ||||
|   | ||||
| @@ -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 }); | ||||
|     } | ||||
| } | ||||
|   | ||||
| @@ -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}`); | ||||
|  | ||||
|   | ||||
| @@ -6,7 +6,7 @@ import { describe, expect, it } from "vitest"; | ||||
| describe("i18n", () => { | ||||
|     it("translations are valid JSON", () => { | ||||
|         for (const locale of LOCALES) { | ||||
|             if (locale.contentOnly || locale.id === "en_rtl") { | ||||
|             if (locale.contentOnly) { | ||||
|                 continue; | ||||
|             } | ||||
|  | ||||
|   | ||||
| @@ -3,21 +3,14 @@ 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; | ||||
|  | ||||
| /** | ||||
|  * A deferred promise that resolves when translations are initialized. | ||||
|  */ | ||||
| export let translationsInitializedPromise = $.Deferred(); | ||||
|  | ||||
| export async function initLocale() { | ||||
|     const locale = (options.get("locale") as string) || "en"; | ||||
|  | ||||
|     locales = await server.get<Locale[]>("options/locales"); | ||||
|  | ||||
|     i18next.use(initReactI18next); | ||||
|     await i18next.use(i18nextHttpBackend).init({ | ||||
|         lng: locale, | ||||
|         fallbackLng: "en", | ||||
| @@ -26,8 +19,6 @@ export async function initLocale() { | ||||
|         }, | ||||
|         returnEmptyString: false | ||||
|     }); | ||||
|  | ||||
|     translationsInitializedPromise.resolve(); | ||||
| } | ||||
|  | ||||
| export function getAvailableLocales() { | ||||
|   | ||||
| @@ -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)); | ||||
|   | ||||
| @@ -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; | ||||
|     } | ||||
|  | ||||
|   | ||||
| @@ -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 ?? ""] | ||||
|     } | ||||
| } | ||||
| @@ -2,15 +2,21 @@ import server from "./server.js"; | ||||
| import appContext, { type CommandNames } from "../components/app_context.js"; | ||||
| import shortcutService from "./shortcuts.js"; | ||||
| import type Component from "../components/component.js"; | ||||
| import type { ActionKeyboardShortcut } from "@triliumnext/commons"; | ||||
|  | ||||
| const keyboardActionRepo: Record<string, ActionKeyboardShortcut> = {}; | ||||
| const keyboardActionRepo: Record<string, Action> = {}; | ||||
|  | ||||
| const keyboardActionsLoaded = server.get<ActionKeyboardShortcut[]>("keyboard-actions").then((actions) => { | ||||
| // TODO: Deduplicate with server. | ||||
| export interface Action { | ||||
|     actionName: CommandNames; | ||||
|     effectiveShortcuts: string[]; | ||||
|     scope: string; | ||||
| } | ||||
|  | ||||
| const keyboardActionsLoaded = server.get<Action[]>("keyboard-actions").then((actions) => { | ||||
|     actions = actions.filter((a) => !!a.actionName); // filter out separators | ||||
|  | ||||
|     for (const action of actions) { | ||||
|         action.effectiveShortcuts = (action.effectiveShortcuts ?? []).filter((shortcut) => !shortcut.startsWith("global:")); | ||||
|         action.effectiveShortcuts = action.effectiveShortcuts.filter((shortcut) => !shortcut.startsWith("global:")); | ||||
|  | ||||
|         keyboardActionRepo[action.actionName] = action; | ||||
|     } | ||||
| @@ -32,7 +38,7 @@ async function setupActionsForElement(scope: string, $el: JQuery<HTMLElement>, c | ||||
|     const actions = await getActionsForScope(scope); | ||||
|  | ||||
|     for (const action of actions) { | ||||
|         for (const shortcut of action.effectiveShortcuts ?? []) { | ||||
|         for (const shortcut of action.effectiveShortcuts) { | ||||
|             shortcutService.bindElShortcut($el, shortcut, () => component.triggerCommand(action.actionName, { ntxId: appContext.tabManager.activeNtxId })); | ||||
|         } | ||||
|     } | ||||
| @@ -40,7 +46,7 @@ async function setupActionsForElement(scope: string, $el: JQuery<HTMLElement>, c | ||||
|  | ||||
| getActionsForScope("window").then((actions) => { | ||||
|     for (const action of actions) { | ||||
|         for (const shortcut of action.effectiveShortcuts ?? []) { | ||||
|         for (const shortcut of action.effectiveShortcuts) { | ||||
|             shortcutService.bindGlobalShortcut(shortcut, () => appContext.triggerCommand(action.actionName, { ntxId: appContext.tabManager.activeNtxId })); | ||||
|         } | ||||
|     } | ||||
| @@ -62,10 +68,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. | ||||
| @@ -78,7 +80,7 @@ function updateDisplayedShortcuts($container: JQuery<HTMLElement>) { | ||||
|         const action = await getAction(actionName, true); | ||||
|  | ||||
|         if (action) { | ||||
|             const keyboardActions = (action.effectiveShortcuts ?? []).join(", "); | ||||
|             const keyboardActions = action.effectiveShortcuts.join(", "); | ||||
|  | ||||
|             if (keyboardActions || $(el).text() !== "not set") { | ||||
|                 $(el).text(keyboardActions); | ||||
| @@ -97,7 +99,7 @@ function updateDisplayedShortcuts($container: JQuery<HTMLElement>) { | ||||
|  | ||||
|         if (action) { | ||||
|             const title = $(el).attr("title"); | ||||
|             const shortcuts = (action.effectiveShortcuts ?? []).join(", "); | ||||
|             const shortcuts = action.effectiveShortcuts.join(", "); | ||||
|  | ||||
|             if (title?.includes(shortcuts)) { | ||||
|                 return; | ||||
|   | ||||
| @@ -3,7 +3,16 @@ import linkContextMenuService from "../menus/link_context_menu.js"; | ||||
| import appContext, { type NoteCommandData } from "../components/app_context.js"; | ||||
| import froca from "./froca.js"; | ||||
| import utils from "./utils.js"; | ||||
| import { ALLOWED_PROTOCOLS } from "@triliumnext/commons"; | ||||
|  | ||||
| // Be consistent with `allowedSchemes` in `src\services\html_sanitizer.ts` | ||||
| // TODO: Deduplicate with server once we can. | ||||
| export const ALLOWED_PROTOCOLS = [ | ||||
|     'http', 'https', 'ftp', 'ftps', 'mailto', 'data', 'evernote', 'file', 'facetime', 'gemini', 'git', | ||||
|     'gopher', 'imap', 'irc', 'irc6', 'jabber', 'jar', 'lastfm', 'ldap', 'ldaps', 'magnet', 'message', | ||||
|     'mumble', 'nfs', 'onenote', 'pop', 'rmi', 's3', 'sftp', 'skype', 'sms', 'spotify', 'steam', 'svn', 'udp', | ||||
|     'view-source', 'vlc', 'vnc', 'ws', 'wss', 'xmpp', 'jdbc', 'slack', 'tel', 'smb', 'zotero', 'geo', | ||||
|     'mid' | ||||
| ]; | ||||
|  | ||||
| function getNotePathFromUrl(url: string) { | ||||
|     const notePathMatch = /#(root[A-Za-z0-9_/]*)$/.exec(url); | ||||
| @@ -26,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 { | ||||
|     /** | ||||
| @@ -221,7 +231,6 @@ export function parseNavigationStateFromUrl(url: string | undefined) { | ||||
|     let ntxId: string | null = null; | ||||
|     let hoistedNoteId: string | null = null; | ||||
|     let searchString: string | null = null; | ||||
|     let openInPopup = false; | ||||
|  | ||||
|     if (paramString) { | ||||
|         for (const pair of paramString.split("&")) { | ||||
| @@ -237,8 +246,6 @@ export function parseNavigationStateFromUrl(url: string | undefined) { | ||||
|                 searchString = value; // supports triggering search from URL, e.g. #?searchString=blabla | ||||
|             } else if (["viewMode", "attachmentId"].includes(name)) { | ||||
|                 (viewScope as any)[name] = value; | ||||
|             } else if (name === "popup") { | ||||
|                 openInPopup = true; | ||||
|             } else { | ||||
|                 console.warn(`Unrecognized hash parameter '${name}'.`); | ||||
|             } | ||||
| @@ -259,8 +266,7 @@ export function parseNavigationStateFromUrl(url: string | undefined) { | ||||
|         ntxId, | ||||
|         hoistedNoteId, | ||||
|         viewScope, | ||||
|         searchString, | ||||
|         openInPopup | ||||
|         searchString | ||||
|     }; | ||||
| } | ||||
|  | ||||
| @@ -293,12 +299,11 @@ function goToLinkExt(evt: MouseEvent | JQuery.ClickEvent | JQuery.MouseDownEvent | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     const { notePath, viewScope, openInPopup } = parseNavigationStateFromUrl(hrefLink); | ||||
|     const { notePath, viewScope } = parseNavigationStateFromUrl(hrefLink); | ||||
|  | ||||
|     const ctrlKey = evt && utils.isCtrlKey(evt); | ||||
|     const shiftKey = evt?.shiftKey; | ||||
|     const isLeftClick = !evt || ("which" in evt && evt.which === 1); | ||||
|     // Right click is handled separately. | ||||
|     const isMiddleClick = evt && "which" in evt && evt.which === 2; | ||||
|     const targetIsBlank = ($link?.attr("target") === "_blank"); | ||||
|     const openInNewTab = (isLeftClick && ctrlKey) || isMiddleClick || targetIsBlank; | ||||
| @@ -306,9 +311,7 @@ function goToLinkExt(evt: MouseEvent | JQuery.ClickEvent | JQuery.MouseDownEvent | ||||
|     const openInNewWindow = isLeftClick && evt?.shiftKey && !ctrlKey; | ||||
|  | ||||
|     if (notePath) { | ||||
|         if (isLeftClick && openInPopup) { | ||||
|             appContext.triggerCommand("openInPopup", { noteIdOrPath: notePath }); | ||||
|         } else if (openInNewWindow) { | ||||
|         if (openInNewWindow) { | ||||
|             appContext.triggerCommand("openInWindow", { notePath, viewScope }); | ||||
|         } else if (openInNewTab) { | ||||
|             appContext.tabManager.openTabWithNoteWithHoisting(notePath, { | ||||
| @@ -384,18 +387,12 @@ function linkContextMenu(e: PointerEvent) { | ||||
|         return; | ||||
|     } | ||||
|  | ||||
|     if (utils.isCtrlKey(e) && e.button === 2) { | ||||
|         appContext.triggerCommand("openInPopup", { noteIdOrPath: notePath }); | ||||
|         e.preventDefault(); | ||||
|         return; | ||||
|     } | ||||
|  | ||||
|     e.preventDefault(); | ||||
|  | ||||
|     linkContextMenuService.openContextMenu(notePath, e, viewScope, null); | ||||
| } | ||||
|  | ||||
| async function loadReferenceLinkTitle($el: JQuery<HTMLElement>, href: string | null | undefined = null) { | ||||
| export async function loadReferenceLinkTitle($el: JQuery<HTMLElement>, href: string | null | undefined = null) { | ||||
|     const $link = $el[0].tagName === "A" ? $el : $el.find("a"); | ||||
|  | ||||
|     href = href || $link.attr("href"); | ||||
|   | ||||
| @@ -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 | ||||
|         ); | ||||
|     } | ||||
|  | ||||
|   | ||||
| @@ -3,7 +3,6 @@ import appContext from "../components/app_context.js"; | ||||
| import noteCreateService from "./note_create.js"; | ||||
| import froca from "./froca.js"; | ||||
| import { t } from "./i18n.js"; | ||||
| import commandRegistry from "./command_registry.js"; | ||||
| import type { MentionFeedObjectItem } from "@triliumnext/ckeditor5"; | ||||
|  | ||||
| // this key needs to have this value, so it's hit by the tooltip | ||||
| @@ -30,28 +29,18 @@ export interface Suggestion { | ||||
|     notePathTitle?: string; | ||||
|     notePath?: string; | ||||
|     highlightedNotePathTitle?: string; | ||||
|     action?: string | "create-note" | "search-notes" | "external-link" | "command"; | ||||
|     action?: string | "create-note" | "search-notes" | "external-link"; | ||||
|     parentNoteId?: string; | ||||
|     icon?: string; | ||||
|     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; | ||||
|     allowExternalLinks?: boolean; | ||||
|     /** If set, hides the right-side button corresponding to go to selected note. */ | ||||
|     hideGoToSelectedNoteButton?: boolean; | ||||
|     /** If set, hides all right-side buttons in the autocomplete dropdown */ | ||||
|     hideAllButtons?: boolean; | ||||
|     /** If set, enables command palette mode */ | ||||
|     isCommandPalette?: boolean; | ||||
| } | ||||
|  | ||||
| async function autocompleteSourceForCKEditor(queryText: string) { | ||||
| @@ -81,31 +70,6 @@ async function autocompleteSourceForCKEditor(queryText: string) { | ||||
| } | ||||
|  | ||||
| async function autocompleteSource(term: string, cb: (rows: Suggestion[]) => void, options: Options = {}) { | ||||
|     // 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 | ||||
|             ? commandRegistry.getAllCommands() | ||||
|             : commandRegistry.searchCommands(commandQuery); | ||||
|  | ||||
|         // Convert commands to suggestions | ||||
|         const commandSuggestions: Suggestion[] = commands.map(cmd => ({ | ||||
|             action: "command", | ||||
|             commandId: cmd.id, | ||||
|             noteTitle: cmd.name, | ||||
|             notePathTitle: `>${cmd.name}`, | ||||
|             highlightedNotePathTitle: cmd.name, | ||||
|             commandDescription: cmd.description, | ||||
|             commandShortcut: cmd.shortcut, | ||||
|             icon: cmd.icon | ||||
|         })); | ||||
|  | ||||
|         cb(commandSuggestions); | ||||
|         return; | ||||
|     } | ||||
|  | ||||
|     const fastSearch = options.fastSearch === false ? false : true; | ||||
|     if (fastSearch === false) { | ||||
|         if (term.trim().length === 0) { | ||||
| @@ -179,12 +143,6 @@ function showRecentNotes($el: JQuery<HTMLElement>) { | ||||
|     $el.trigger("focus"); | ||||
| } | ||||
|  | ||||
| function showAllCommands($el: JQuery<HTMLElement>) { | ||||
|     searchDelay = 0; | ||||
|     $el.setSelectedNotePath(""); | ||||
|     $el.autocomplete("val", ">").autocomplete("open"); | ||||
| } | ||||
|  | ||||
| function fullTextSearch($el: JQuery<HTMLElement>, options: Options) { | ||||
|     const searchString = $el.autocomplete("val") as unknown as string; | ||||
|     if (options.fastSearch === false || searchString?.trim().length === 0) { | ||||
| @@ -232,11 +190,9 @@ function initNoteAutocomplete($el: JQuery<HTMLElement>, options?: Options) { | ||||
|  | ||||
|     const $goToSelectedNoteButton = $("<a>").addClass("input-group-text go-to-selected-note-button bx bx-arrow-to-right"); | ||||
|  | ||||
|     if (!options.hideAllButtons) { | ||||
|         $el.after($clearTextButton).after($showRecentNotesButton).after($fullTextSearchButton); | ||||
|     } | ||||
|     $el.after($clearTextButton).after($showRecentNotesButton).after($fullTextSearchButton); | ||||
|  | ||||
|     if (!options.hideGoToSelectedNoteButton && !options.hideAllButtons) { | ||||
|     if (!options.hideGoToSelectedNoteButton) { | ||||
|         $el.after($goToSelectedNoteButton); | ||||
|     } | ||||
|  | ||||
| @@ -309,50 +265,7 @@ function initNoteAutocomplete($el: JQuery<HTMLElement>, options?: Options) { | ||||
|                 }, | ||||
|                 displayKey: "notePathTitle", | ||||
|                 templates: { | ||||
|                     suggestion: (suggestion) => { | ||||
|                         if (suggestion.action === "command") { | ||||
|                             let html = `<div class="command-suggestion">`; | ||||
|                             html += `<span class="command-icon ${suggestion.icon || "bx bx-terminal"}"></span>`; | ||||
|                             html += `<div class="command-content">`; | ||||
|                             html += `<div class="command-name">${suggestion.highlightedNotePathTitle}</div>`; | ||||
|                             if (suggestion.commandDescription) { | ||||
|                                 html += `<div class="command-description">${suggestion.commandDescription}</div>`; | ||||
|                             } | ||||
|                             html += `</div>`; | ||||
|                             if (suggestion.commandShortcut) { | ||||
|                                 html += `<kbd class="command-shortcut">${suggestion.commandShortcut}</kbd>`; | ||||
|                             } | ||||
|                             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; | ||||
|                     } | ||||
|                     suggestion: (suggestion) => `<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 | ||||
|                 cache: false | ||||
| @@ -362,12 +275,6 @@ function initNoteAutocomplete($el: JQuery<HTMLElement>, options?: Options) { | ||||
|  | ||||
|     // TODO: Types fail due to "autocomplete:selected" not being registered in type definitions. | ||||
|     ($el as any).on("autocomplete:selected", async (event: Event, suggestion: Suggestion) => { | ||||
|         if (suggestion.action === "command") { | ||||
|             $el.autocomplete("close"); | ||||
|             $el.trigger("autocomplete:commandselected", [suggestion]); | ||||
|             return; | ||||
|         } | ||||
|  | ||||
|         if (suggestion.action === "external-link") { | ||||
|             $el.setSelectedNotePath(null); | ||||
|             $el.setSelectedExternalLink(suggestion.externalLink); | ||||
| @@ -480,26 +387,10 @@ 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, | ||||
|     showRecentNotes, | ||||
|     showAllCommands, | ||||
|     setText, | ||||
|     init | ||||
| }; | ||||
|   | ||||
| @@ -11,7 +11,7 @@ import type FBranch from "../entities/fbranch.js"; | ||||
| import type { ChooseNoteTypeResponse } from "../widgets/dialogs/note_type_chooser.js"; | ||||
| import type { CKTextEditor } from "@triliumnext/ckeditor5"; | ||||
|  | ||||
| export interface CreateNoteOpts { | ||||
| interface CreateNoteOpts { | ||||
|     isProtected?: boolean; | ||||
|     saveSelection?: boolean; | ||||
|     title?: string | null; | ||||
| @@ -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 }); | ||||
|     }); | ||||
| } | ||||
|   | ||||
							
								
								
									
										61
									
								
								apps/client/src/services/note_list_renderer.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										61
									
								
								apps/client/src/services/note_list_renderer.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,61 @@ | ||||
| import type FNote from "../entities/fnote.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"; | ||||
|  | ||||
| export type ViewTypeOptions = "list" | "grid" | "calendar" | "table" | "geoMap"; | ||||
|  | ||||
| export default class NoteListRenderer { | ||||
|  | ||||
|     private viewType: ViewTypeOptions; | ||||
|     public viewMode: ViewMode<any> | null; | ||||
|  | ||||
|     constructor(args: ViewModeArgs) { | ||||
|         this.viewType = this.#getViewType(args.parentNote); | ||||
|  | ||||
|         switch (this.viewType) { | ||||
|             case "list": | ||||
|             case "grid": | ||||
|                 this.viewMode = new ListOrGridView(this.viewType, args); | ||||
|                 break; | ||||
|             case "calendar": | ||||
|                 this.viewMode = new CalendarView(args); | ||||
|                 break; | ||||
|             case "table": | ||||
|                 this.viewMode = new TableView(args); | ||||
|                 break; | ||||
|             case "geoMap": | ||||
|                 this.viewMode = new GeoView(args); | ||||
|                 break; | ||||
|             default: | ||||
|                 this.viewMode = null; | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     #getViewType(parentNote: FNote): ViewTypeOptions { | ||||
|         const viewType = parentNote.getLabelValue("viewType"); | ||||
|  | ||||
|         if (!["list", "grid", "calendar", "table", "geoMap"].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() { | ||||
|         return this.viewMode?.isFullHeight; | ||||
|     } | ||||
|  | ||||
|     async renderList() { | ||||
|         if (!this.viewMode) { | ||||
|             return null; | ||||
|         } | ||||
|  | ||||
|         return await this.viewMode.renderList(); | ||||
|     } | ||||
|  | ||||
| } | ||||
| @@ -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) => { | ||||
| @@ -168,10 +168,7 @@ async function renderTooltip(note: FNote | null) { | ||||
|         if (isContentEmpty) { | ||||
|             classes.push("note-no-content"); | ||||
|         } | ||||
|         content = `\ | ||||
|             <h5 class="${classes.join(" ")}"> | ||||
|                 <a href="#${note.noteId}" data-no-context-menu="true">${noteTitleWithPathAsSuffix.prop("outerHTML")}</a> | ||||
|             </h5>`; | ||||
|         content = `<h5 class="${classes.join(" ")}"><a href="#${note.noteId}" data-no-context-menu="true">${noteTitleWithPathAsSuffix.prop("outerHTML")}</a></h5>`; | ||||
|     } | ||||
|  | ||||
|     content = `${content}<div class="note-tooltip-attributes">${$renderedAttributes[0].outerHTML}</div>`; | ||||
| @@ -179,7 +176,6 @@ async function renderTooltip(note: FNote | null) { | ||||
|         content += $renderedContent[0].outerHTML; | ||||
|     } | ||||
|  | ||||
|     content += `<a class="open-popup-button" title="${t("note_tooltip.quick-edit")}" href="#${note.noteId}?popup"><span class="bx bx-edit" /></a>`; | ||||
|     return content; | ||||
| } | ||||
|  | ||||
|   | ||||
| @@ -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); | ||||
|   | ||||
| @@ -35,7 +35,7 @@ function download(url: string) { | ||||
|     } | ||||
| } | ||||
|  | ||||
| export function downloadFileNote(noteId: string) { | ||||
| function downloadFileNote(noteId: string) { | ||||
|     const url = `${getFileUrl("notes", noteId)}?${Date.now()}`; // don't use cache | ||||
|  | ||||
|     download(url); | ||||
| @@ -163,7 +163,7 @@ async function openExternally(type: string, entityId: string, mime: string) { | ||||
|     } | ||||
| } | ||||
|  | ||||
| export const openNoteExternally = async (noteId: string, mime: string) => await openExternally("notes", noteId, mime); | ||||
| const openNoteExternally = async (noteId: string, mime: string) => await openExternally("notes", noteId, mime); | ||||
| const openAttachmentExternally = async (attachmentId: string, mime: string) => await openExternally("attachments", attachmentId, mime); | ||||
|  | ||||
| function getHost() { | ||||
|   | ||||
| @@ -1,8 +1,7 @@ | ||||
| import { OptionNames } from "@triliumnext/commons"; | ||||
| import server from "./server.js"; | ||||
| import { isShare } from "./utils.js"; | ||||
|  | ||||
| export type OptionValue = number | string; | ||||
| type OptionValue = number | string; | ||||
|  | ||||
| class Options { | ||||
|     initializedPromise: Promise<void>; | ||||
| @@ -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()); | ||||
|     } | ||||
| } | ||||
|   | ||||
| @@ -1,4 +1,4 @@ | ||||
| export type LabelType = "text" | "number" | "boolean" | "date" | "datetime" | "time" | "url" | "color"; | ||||
| export type LabelType = "text" | "number" | "boolean" | "date" | "datetime" | "time" | "url"; | ||||
| type Multiplicity = "single" | "multi"; | ||||
|  | ||||
| export interface DefinitionObject { | ||||
| @@ -17,7 +17,7 @@ function parse(value: string) { | ||||
|     for (const token of tokens) { | ||||
|         if (token === "promoted") { | ||||
|             defObj.isPromoted = true; | ||||
|         } else if (["text", "number", "boolean", "date", "datetime", "time", "url", "color"].includes(token)) { | ||||
|         } else if (["text", "number", "boolean", "date", "datetime", "time", "url"].includes(token)) { | ||||
|             defObj.labelType = token as LabelType; | ||||
|         } else if (["single", "multi"].includes(token)) { | ||||
|             defObj.multiplicity = token as Multiplicity; | ||||
|   | ||||
| @@ -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") { | ||||
|   | ||||
| @@ -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 | ||||
| }; | ||||
|   | ||||
| @@ -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 { | ||||
|   | ||||
| @@ -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); | ||||
|         }); | ||||
|     }); | ||||
| }); | ||||
| @@ -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 { | ||||
|   | ||||
| @@ -51,14 +51,6 @@ export default class SpacedUpdate { | ||||
|         this.lastUpdated = Date.now(); | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Sets the update interval for the spaced update. | ||||
|      * @param interval The update interval in milliseconds. | ||||
|      */ | ||||
|     setUpdateInterval(interval: number) { | ||||
|         this.updateInterval = interval; | ||||
|     } | ||||
|  | ||||
|     triggerUpdate() { | ||||
|         if (!this.changed) { | ||||
|             return; | ||||
|   | ||||
| @@ -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 { | ||||
|   | ||||
| @@ -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, | ||||
|   | ||||
Some files were not shown because too many files have changed in this diff Show More
		Reference in New Issue
	
	Block a user