Compare commits

..

1 Commits

Author SHA1 Message Date
Adorian Doran
35e11807e5 client: add support for persistant system tray icon 2025-11-17 15:36:02 +02:00
530 changed files with 13440 additions and 20360 deletions

View File

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

View File

@@ -21,7 +21,7 @@ runs:
# Certificate setup
- name: Import Apple certificates
if: inputs.os == 'macos'
uses: apple-actions/import-codesign-certs@v6
uses: apple-actions/import-codesign-certs@v5
with:
p12-file-base64: ${{ env.APPLE_APP_CERTIFICATE_BASE64 }}
p12-password: ${{ env.APPLE_APP_CERTIFICATE_PASSWORD }}
@@ -30,7 +30,7 @@ runs:
- name: Install Installer certificate
if: inputs.os == 'macos'
uses: apple-actions/import-codesign-certs@v6
uses: apple-actions/import-codesign-certs@v5
with:
p12-file-base64: ${{ env.APPLE_INSTALLER_CERTIFICATE_BASE64 }}
p12-password: ${{ env.APPLE_INSTALLER_CERTIFICATE_PASSWORD }}

View File

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

View File

@@ -1,334 +0,0 @@
# Trilium Notes - AI Coding Agent Instructions
## Project Overview
Trilium Notes is a hierarchical note-taking application with advanced features like synchronization, scripting, and rich text editing. Built as a TypeScript monorepo using pnpm, it implements a three-layer caching architecture (Becca/Froca/Shaca) with a widget-based UI system and supports extensive user scripting capabilities.
## Essential Architecture Patterns
### Three-Layer Cache System (Critical to Understand)
- **Becca** (`apps/server/src/becca/`): Server-side entity cache, primary data source
- **Froca** (`apps/client/src/services/froca.ts`): Client-side mirror synchronized via WebSocket
- **Shaca** (`apps/server/src/share/`): Optimized cache for public/shared notes
**Key insight**: Never bypass these caches with direct DB queries. Always use `becca.notes[noteId]`, `froca.getNote()`, or equivalent cache methods.
### Entity Relationship Model
Notes use a **multi-parent tree** via branches:
- `BNote` - The note content and metadata
- `BBranch` - Tree relationships (one note can have multiple parents via cloning)
- `BAttribute` - Key-value metadata attached to notes (labels and relations)
### Entity Change System & Sync
Every entity modification (notes, branches, attributes) creates an `EntityChange` record that drives synchronization:
```typescript
// Entity changes are automatically tracked
note.title = "New Title";
note.save(); // Creates EntityChange record with changeId
// Sync protocol via WebSocket
ws.sendMessage({ type: 'sync-pull-in-progress', ... });
```
**Critical**: This is why you must use Becca/Froca methods instead of direct DB writes - they create the change tracking records needed for sync.
### Entity Lifecycle & Events
The event system (`apps/server/src/services/events.ts`) broadcasts entity lifecycle events:
```typescript
// Subscribe to events in widgets or services
eventService.subscribe('noteChanged', ({ noteId }) => {
// React to note changes
});
// Common events: noteChanged, branchChanged, attributeChanged, noteDeleted
// Widget method: entitiesReloadedEvent({loadResults}) for handling reloads
```
**Becca loader priorities**: Events are emitted in order (notes → branches → attributes) during initial load to ensure referential integrity.
### TaskContext for Long Operations
Use `TaskContext` for operations with progress reporting (imports, exports, bulk operations):
```typescript
const taskContext = new TaskContext("task-id", "import", "Import Notes");
taskContext.increaseProgressCount();
// WebSocket messages: { type: 'taskProgressCount', taskId, taskType, data, progressCount }
**Pattern**: All long-running operations (delete note trees, export, import) use TaskContext to send WebSocket updates to the frontend.
### Protected Session Handling
Protected notes require an active encryption session:
```typescript
// Always check before accessing protected content
if (note.isContentAvailable()) {
const content = note.getContent(); // Safe
} else {
const title = note.getTitleOrProtected(); // Returns "[protected]"
}
// Protected session management
protectedSessionService.isProtectedSessionAvailable() // Check session
protectedSessionService.startProtectedSession() // After password entry
```
**Session timeout**: Protected sessions expire after inactivity. The encryption key is kept in memory only.
### Attribute Inheritance Patterns
Attributes can be inherited through three mechanisms:
```typescript
// 1. Standard inheritance (#hidePromotedAttributes ~hidePromotedAttributes)
note.getInheritableAttributes() // Walks up parent tree
// 2. Child prefix inheritance (child:label copies to children)
parentNote.setLabel("child:icon", "book") // All children inherit this
// 3. Template relation inheritance (#template=templateNoteId)
note.setRelation("template", templateNoteId)
note.getInheritedAttributes() // Includes template's inheritable attributes
```
**Cycle prevention**: Inheritance tracking prevents infinite loops when notes reference each other.
### Widget-Based UI Architecture
All UI components extend from widget base classes (`apps/client/src/widgets/`):
```typescript
// Right panel widget (sidebar)
class MyWidget extends RightPanelWidget {
get position() { return 100; } // Order in panel
get parentWidget() { return 'right-pane'; }
isEnabled() { return this.note && this.note.hasLabel('myLabel'); }
async refreshWithNote(note) { /* Update UI */ }
}
// Note-aware widget (responds to note changes)
class MyNoteWidget extends NoteContextAwareWidget {
async refreshWithNote(note) { /* Refresh when note changes */ }
async entitiesReloadedEvent({loadResults}) { /* Handle entity updates */ }
}
```
**Important**: Widgets use jQuery (`this.$widget`) for DOM manipulation. Don't mix React patterns here.
## Development Workflow
### Running & Testing
```bash
# From root directory
pnpm install # Install dependencies
corepack enable # Enable pnpm if not available
pnpm server:start # Dev server (http://localhost:8080)
pnpm server:start-prod # Production mode server
pnpm desktop:start # Desktop app development
pnpm server:test spec/etapi/search.spec.ts # Run specific test
pnpm test:parallel # Client tests (can run parallel)
pnpm test:sequential # Server tests (sequential due to shared DB)
pnpm test:all # All tests (parallel + sequential)
pnpm coverage # Generate coverage reports
pnpm typecheck # Type check all projects
```
### Building
```bash
pnpm client:build # Build client application
pnpm server:build # Build server application
pnpm desktop:build # Build desktop application
```
### Test Organization
- **Server tests** (`apps/server/spec/`): Must run sequentially (shared database state)
- **Client tests** (`apps/client/src/`): Can run in parallel
- **E2E tests** (`apps/server-e2e/`): Use Playwright for integration testing
- **ETAPI tests** (`apps/server/spec/etapi/`): External API contract tests
**Pattern**: When adding new API endpoints, add tests in `spec/etapi/` following existing patterns (see `search.spec.ts`).
### Monorepo Navigation
```
apps/
client/ # Frontend (shared by server & desktop)
server/ # Node.js backend with REST API
desktop/ # Electron wrapper
web-clipper/ # Browser extension for saving web content
db-compare/ # Database comparison tool
dump-db/ # Database export utility
edit-docs/ # Documentation editing tools
packages/
commons/ # Shared types and utilities
ckeditor5/ # Custom rich text editor with Trilium-specific plugins
codemirror/ # Code editor integration
highlightjs/ # Syntax highlighting
share-theme/ # Theme for shared/published notes
ckeditor5-admonition/ # Admonition blocks plugin
ckeditor5-footnotes/ # Footnotes plugin
ckeditor5-math/ # Math equations plugin
ckeditor5-mermaid/ # Mermaid diagrams plugin
```
**Filter commands**: Use `pnpm --filter server test` to run commands in specific packages.
## Critical Code Patterns
### ETAPI Backwards Compatibility
When adding query parameters to ETAPI endpoints (`apps/server/src/etapi/`), maintain backwards compatibility by checking if new params exist before changing response format.
**Pattern**: ETAPI consumers expect specific response shapes. Always check for breaking changes.
### Frontend-Backend Communication
- **REST API**: `apps/server/src/routes/api/` - Internal endpoints (no auth required when `noAuthentication=true`)
- **ETAPI**: `apps/server/src/etapi/` - External API with authentication
- **WebSocket**: Real-time sync via `apps/server/src/services/ws.ts`
**Auth note**: ETAPI uses basic auth with tokens. Internal API endpoints trust the frontend.
### Database Migrations
- Add scripts in `apps/server/src/migrations/YYMMDD_HHMM__description.sql`
- Update schema in `apps/server/src/assets/db/schema.sql`
- Never bypass Becca cache after migrations
## Common Pitfalls
1. **Never bypass the cache layers** - Always use `becca.notes[noteId]`, `froca.getNote()`, or equivalent cache methods. Direct database queries will cause sync issues between Becca/Froca/Shaca and won't create EntityChange records needed for synchronization.
2. **Protected notes require session check** - Before accessing `note.title` or `note.getContent()` on protected notes, check `note.isContentAvailable()` or use `note.getTitleOrProtected()` which handles this automatically.
3. **Widget lifecycle matters** - Override `refreshWithNote()` for note changes, `doRenderBody()` for initial render, `entitiesReloadedEvent()` for entity updates. Widgets use jQuery (`this.$widget`) - don't mix React patterns.
4. **Tests run differently** - Server tests must run sequentially (shared database state), client tests can run in parallel. Use `pnpm test:sequential` for backend, `pnpm test:parallel` for frontend.
5. **ETAPI requires authentication** - ETAPI endpoints use basic auth with tokens. Internal API endpoints (`apps/server/src/routes/api/`) trust the frontend when `noAuthentication=true`.
6. **Search expressions are evaluated in memory** - The search service loads all matching notes, scores them in JavaScript, then sorts. You cannot add SQL-level LIMIT/OFFSET without losing scoring functionality.
7. **Documentation edits have rules** - `docs/Script API/` is auto-generated (never edit directly). `docs/User Guide/` should be edited via `pnpm edit-docs:edit-docs`, not manually. Only `docs/Developer Guide/` and `docs/Release Notes/` are safe for direct Markdown editing.
8. **pnpm workspace filtering** - Use `pnpm --filter server <command>` or shorthand `pnpm server:test` defined in root `package.json`. Note the `--filter` syntax, not `-F` or other shortcuts.
9. **Event subscription cleanup** - When subscribing to events in widgets, unsubscribe in `cleanup()` or `doDestroy()` to prevent memory leaks.
10. **Attribute inheritance can be complex** - When checking for labels/relations, use `note.getOwnedAttribute()` for direct attributes or `note.getAttribute()` for inherited ones. Don't assume attributes are directly on the note.
## TypeScript Configuration
- **Project references**: Monorepo uses TypeScript project references (`tsconfig.json`)
- **Path mapping**: Use relative imports, not path aliases
- **Build order**: `pnpm typecheck` builds all projects in dependency order
- **Build system**: Uses Vite for fast development, ESBuild for production optimization
- **Patches**: Custom patches in `patches/` directory for CKEditor and other dependencies
## Key Files for Context
- `apps/server/src/becca/entities/bnote.ts` - Note entity methods
- `apps/client/src/services/froca.ts` - Frontend cache API
- `apps/server/src/services/search/services/search.ts` - Search implementation
- `apps/server/src/routes/routes.ts` - API route registration
- `apps/client/src/widgets/basic_widget.ts` - Widget base class
- `apps/server/src/main.ts` - Server startup entry point
- `apps/client/src/desktop.ts` - Client initialization
- `apps/server/src/services/backend_script_api.ts` - Scripting API
- `apps/server/src/assets/db/schema.sql` - Database schema
## Note Types and Features
Trilium supports multiple note types with specialized widgets in `apps/client/src/widgets/type_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
### Collections
Notes can be marked with the `#collection` label to enable collection view modes. Collections support multiple view types:
- **List**: Standard list view
- **Grid**: Card/grid layout
- **Calendar**: Calendar-based view
- **Table**: Tabular data view
- **GeoMap**: Geographic map view
- **Board**: Kanban-style board
- **Presentation**: Slideshow presentation mode
View types are configured via `#viewType` label (e.g., `#viewType=table`). Each view mode stores its configuration in a separate attachment (e.g., `table.json`). Collections are organized separately from regular note type templates in the note creation menu.
## 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
- Remember: scoring happens in-memory, not at database level
### Custom CKEditor Plugins
- Create new package in `packages/` following existing plugin structure
- Register in `packages/ckeditor5/src/plugins.ts`
- See `ckeditor5-admonition`, `ckeditor5-footnotes`, `ckeditor5-math`, `ckeditor5-mermaid` for examples
### Database Migrations
- Add migration scripts in `apps/server/src/migrations/YYMMDD_HHMM__description.sql`
- Update schema in `apps/server/src/assets/db/schema.sql`
- Never bypass Becca cache after migrations
## Security & Features
### Security Considerations
- Per-note encryption with granular protected sessions
- CSRF protection for API endpoints
- OpenID and TOTP authentication support
- Sanitization of user-generated content
### Scripting System
Trilium provides powerful user scripting capabilities:
- **Frontend scripts**: Run in browser context with UI access
- **Backend scripts**: Run in Node.js context with full API access
- Script API documentation in `docs/Script API/`
- Backend API available via `api` object in script context
### Internationalization
- Translation files in `apps/client/src/translations/`
- Use translation system via `t()` function
- Automatic pluralization: Add `_other` suffix to translation keys (e.g., `item` and `item_other` for singular/plural)
## Testing Conventions
```typescript
// ETAPI test pattern
describe("etapi/feature", () => {
beforeAll(async () => {
config.General.noAuthentication = false;
app = await buildApp();
token = await login(app);
});
it("should test feature", async () => {
const response = await supertest(app)
.get("/etapi/notes?search=test")
.auth(USER, token, { type: "basic" })
.expect(200);
expect(response.body.results).toBeDefined();
});
});
```
## Questions to Verify Understanding
Before implementing significant changes, confirm:
- Is this touching the cache layer? (Becca/Froca/Shaca must stay in sync via EntityChange records)
- Does this change API response shape? (Check backwards compatibility for ETAPI)
- Are you adding search features? (Understand expression-based architecture and in-memory scoring first)
- Is this a new widget? (Know which base class and lifecycle methods to use)
- Does this involve protected notes? (Check `isContentAvailable()` before accessing content)
- Is this a long-running operation? (Use TaskContext for progress reporting)
- Are you working with attributes? (Understand inheritance patterns: direct, child-prefix, template)

View File

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

View File

@@ -42,7 +42,7 @@ jobs:
steps:
- name: Checkout Repository
uses: actions/checkout@v6
uses: actions/checkout@v5
- name: Setup pnpm
uses: pnpm/action-setup@v4

View File

@@ -24,7 +24,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout the repository
uses: actions/checkout@v6
uses: actions/checkout@v5
- uses: pnpm/action-setup@v4
- name: Set up node & dependencies
@@ -46,7 +46,7 @@ jobs:
needs:
- test_dev
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v5
- uses: pnpm/action-setup@v4
- name: Install dependencies
run: pnpm install --frozen-lockfile
@@ -80,7 +80,7 @@ jobs:
- dockerfile: Dockerfile
steps:
- name: Checkout the repository
uses: actions/checkout@v6
uses: actions/checkout@v5
- uses: pnpm/action-setup@v4
- name: Install dependencies

View File

@@ -32,7 +32,7 @@ jobs:
- dockerfile: Dockerfile
steps:
- name: Checkout the repository
uses: actions/checkout@v6
uses: actions/checkout@v5
- name: Set IMAGE_NAME to lowercase
run: echo "IMAGE_NAME=${IMAGE_NAME,,}" >> $GITHUB_ENV
@@ -141,7 +141,7 @@ jobs:
run: echo "TEST_TAG=${TEST_TAG,,}" >> $GITHUB_ENV
- name: Checkout repository
uses: actions/checkout@v6
uses: actions/checkout@v5
- uses: pnpm/action-setup@v4
- name: Set up node & dependencies
uses: actions/setup-node@v6

View File

@@ -45,22 +45,9 @@ 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@v6
- uses: actions/checkout@v5
- uses: pnpm/action-setup@v4
- name: Set up node & dependencies
uses: actions/setup-node@v6
@@ -90,7 +77,7 @@ jobs:
GPG_SIGNING_KEY: ${{ secrets.GPG_SIGN_KEY }}
- name: Publish release
uses: softprops/action-gh-release@v2.5.0
uses: softprops/action-gh-release@v2.4.2
if: ${{ github.event_name != 'pull_request' }}
with:
make_latest: false
@@ -122,7 +109,7 @@ jobs:
runs-on: ubuntu-24.04-arm
runs-on: ${{ matrix.runs-on }}
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v5
- name: Run the build
uses: ./.github/actions/build-server
@@ -131,7 +118,7 @@ jobs:
arch: ${{ matrix.arch }}
- name: Publish release
uses: softprops/action-gh-release@v2.5.0
uses: softprops/action-gh-release@v2.4.2
if: ${{ github.event_name != 'pull_request' }}
with:
make_latest: false

View File

@@ -33,7 +33,7 @@ jobs:
TRILIUM_DATA_DIR: "${{ github.workspace }}/apps/server/spec/db"
TRILIUM_INTEGRATION_TEST: memory
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v5
with:
filter: tree:0
fetch-depth: 0
@@ -79,7 +79,7 @@ jobs:
if: failure()
uses: actions/upload-artifact@v5
with:
name: e2e report ${{ matrix.arch }}
name: e2e report
path: apps/server-e2e/test-output
- name: Kill the server

View File

@@ -45,7 +45,7 @@ jobs:
forge_platform: linux
runs-on: ${{ matrix.os.image }}
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v5
- uses: pnpm/action-setup@v4
- name: Set up node & dependencies
uses: actions/setup-node@v6
@@ -91,7 +91,7 @@ jobs:
runs-on: ubuntu-24.04-arm
runs-on: ${{ matrix.runs-on }}
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v5
- name: Run the build
uses: ./.github/actions/build-server
@@ -114,7 +114,7 @@ jobs:
steps:
- run: mkdir upload
- uses: actions/checkout@v6
- uses: actions/checkout@v5
with:
sparse-checkout: |
docs/Release Notes
@@ -127,7 +127,7 @@ jobs:
path: upload
- name: Publish stable release
uses: softprops/action-gh-release@v2.5.0
uses: softprops/action-gh-release@v2.4.2
with:
draft: false
body_path: docs/Release Notes/Release Notes/${{ github.ref_name }}.md

View File

@@ -25,7 +25,7 @@ jobs:
pull-requests: write # For PR preview comments
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v5
- uses: pnpm/action-setup@v4
- name: Set up node & dependencies
uses: actions/setup-node@v6

1
.gitignore vendored
View File

@@ -49,4 +49,3 @@ upload
# docs
site/
apps/*/coverage

View File

@@ -9,6 +9,7 @@
"tobermory.es6-string-html",
"vitest.explorer",
"yzhang.markdown-all-in-one",
"usernamehw.errorlens"
"svelte.svelte-vscode",
"bradlc.vscode-tailwindcss"
]
}

View File

@@ -36,8 +36,5 @@
"docs/**/*.png": true,
"apps/server/src/assets/doc_notes/**": true,
"apps/edit-docs/demo/**": true
},
"eslint.rules.customizations": [
{ "rule": "*", "severity": "warn" }
]
}
}

View File

@@ -16,14 +16,13 @@
![GitHub Downloads (all assets, all releases)](https://img.shields.io/github/downloads/triliumnext/trilium/total)
[![RelativeCI](https://badges.relative-ci.com/badges/Di5q7dz9daNDZ9UXi0Bp?branch=develop)](https://app.relative-ci.com/projects/Di5q7dz9daNDZ9UXi0Bp) [![Translation status](https://hosted.weblate.org/widget/trilium/svg-badge.svg)](https://hosted.weblate.org/engage/trilium/)
<!-- translate:off -->
<!-- LANGUAGE SWITCHER -->
[Chinese (Simplified Han script)](./docs/README-ZH_CN.md) | [Chinese (Traditional Han script)](./docs/README-ZH_TW.md) | [English](./docs/README.md) | [French](./docs/README-fr.md) | [German](./docs/README-de.md) | [Greek](./docs/README-el.md) | [Italian](./docs/README-it.md) | [Japanese](./docs/README-ja.md) | [Romanian](./docs/README-ro.md) | [Spanish](./docs/README-es.md)
<!-- translate:on -->
[English](./README.md) | [Chinese (Simplified)](./docs/README-ZH_CN.md) | [Chinese (Traditional)](./docs/README-ZH_TW.md) | [Russian](./docs/README-ru.md) | [Japanese](./docs/README-ja.md) | [Italian](./docs/README-it.md) | [Spanish](./docs/README-es.md)
Trilium Notes is a free and open-source, cross-platform hierarchical note taking application with focus on building large personal knowledge bases.
<img src="./docs/app.png" alt="Trilium Screenshot" width="1000">
See [screenshots](https://triliumnext.github.io/Docs/Wiki/screenshot-tour) for quick overview:
<a href="https://triliumnext.github.io/Docs/Wiki/screenshot-tour"><img src="./docs/app.png" alt="Trilium Screenshot" width="1000"></a>
## ⏬ Download
- [Latest release](https://github.com/TriliumNext/Trilium/releases/latest) stable version, recommended for most users.
@@ -40,39 +39,39 @@ Our documentation is available in multiple formats:
### Quick Links
- [Getting Started Guide](https://docs.triliumnotes.org/)
- [Installation Instructions](https://docs.triliumnotes.org/user-guide/setup)
- [Docker Setup](https://docs.triliumnotes.org/user-guide/setup/server/installation/docker)
- [Upgrading TriliumNext](https://docs.triliumnotes.org/user-guide/setup/upgrading)
- [Basic Concepts and Features](https://docs.triliumnotes.org/user-guide/concepts/notes)
- [Patterns of Personal Knowledge Base](https://docs.triliumnotes.org/user-guide/misc/patterns-of-personal-knowledge)
- [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://docs.triliumnotes.org/user-guide/concepts/notes/cloning))
* Rich WYSIWYG note editor including e.g. tables, images and [math](https://docs.triliumnotes.org/user-guide/note-types/text) with markdown [autoformat](https://docs.triliumnotes.org/user-guide/note-types/text/markdown-formatting)
* Support for editing [notes with source code](https://docs.triliumnotes.org/user-guide/note-types/code), including syntax highlighting
* Fast and easy [navigation between notes](https://docs.triliumnotes.org/user-guide/concepts/navigation/note-navigation), full text search and [note hoisting](https://docs.triliumnotes.org/user-guide/concepts/navigation/note-hoisting)
* Seamless [note versioning](https://docs.triliumnotes.org/user-guide/concepts/notes/note-revisions)
* Note [attributes](https://docs.triliumnotes.org/user-guide/advanced-usage/attributes) can be used for note organization, querying and advanced [scripting](https://docs.triliumnotes.org/user-guide/scripts)
* 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))
* Rich WYSIWYG note editor including e.g. tables, images and [math](https://triliumnext.github.io/Docs/Wiki/text-notes) with markdown [autoformat](https://triliumnext.github.io/Docs/Wiki/text-notes#autoformat)
* Support for editing [notes with source code](https://triliumnext.github.io/Docs/Wiki/code-notes), including syntax highlighting
* Fast and easy [navigation between notes](https://triliumnext.github.io/Docs/Wiki/note-navigation), full text search and [note hoisting](https://triliumnext.github.io/Docs/Wiki/note-hoisting)
* Seamless [note versioning](https://triliumnext.github.io/Docs/Wiki/note-revisions)
* Note [attributes](https://triliumnext.github.io/Docs/Wiki/attributes) can be used for note organization, querying and advanced [scripting](https://triliumnext.github.io/Docs/Wiki/scripts)
* UI available in English, German, Spanish, French, Romanian, and Chinese (simplified and traditional)
* Direct [OpenID and TOTP integration](https://docs.triliumnotes.org/user-guide/setup/server/mfa) for more secure login
* [Synchronization](https://docs.triliumnotes.org/user-guide/setup/synchronization) with self-hosted sync server
* there are [3rd party services for hosting synchronisation server](https://docs.triliumnotes.org/user-guide/setup/server/cloud-hosting)
* [Sharing](https://docs.triliumnotes.org/user-guide/advanced-usage/sharing) (publishing) notes to public internet
* Strong [note encryption](https://docs.triliumnotes.org/user-guide/concepts/notes/protected-notes) with per-note granularity
* Direct [OpenID and TOTP integration](./docs/User%20Guide/User%20Guide/Installation%20%26%20Setup/Server%20Installation/Multi-Factor%20Authentication.md) for more secure login
* [Synchronization](https://triliumnext.github.io/Docs/Wiki/synchronization) with self-hosted sync server
* there's a [3rd party service for hosting synchronisation server](https://trilium.cc/paid-hosting)
* [Sharing](https://triliumnext.github.io/Docs/Wiki/sharing) (publishing) notes to public internet
* Strong [note encryption](https://triliumnext.github.io/Docs/Wiki/protected-notes) with per-note granularity
* Sketching diagrams, based on [Excalidraw](https://excalidraw.com/) (note type "canvas")
* [Relation maps](https://docs.triliumnotes.org/user-guide/note-types/relation-map) and [note/link maps](https://docs.triliumnotes.org/user-guide/note-types/note-map) for visualizing notes and their relations
* [Relation maps](https://triliumnext.github.io/Docs/Wiki/relation-map) and [link maps](https://triliumnext.github.io/Docs/Wiki/link-map) for visualizing notes and their relations
* Mind maps, based on [Mind Elixir](https://docs.mind-elixir.com/)
* [Geo maps](https://docs.triliumnotes.org/user-guide/collections/geomap) with location pins and GPX tracks
* [Scripting](https://docs.triliumnotes.org/user-guide/scripts) - see [Advanced showcases](https://docs.triliumnotes.org/user-guide/advanced-usage/advanced-showcases)
* [REST API](https://docs.triliumnotes.org/user-guide/advanced-usage/etapi) for automation
* [Geo maps](./docs/User%20Guide/User%20Guide/Note%20Types/Geo%20Map.md) with location pins and GPX tracks
* [Scripting](https://triliumnext.github.io/Docs/Wiki/scripts) - see [Advanced showcases](https://triliumnext.github.io/Docs/Wiki/advanced-showcases)
* [REST API](https://triliumnext.github.io/Docs/Wiki/etapi) for automation
* Scales well in both usability and performance upwards of 100 000 notes
* Touch optimized [mobile frontend](https://docs.triliumnotes.org/user-guide/setup/mobile-frontend) for smartphones and tablets
* Built-in [dark theme](https://docs.triliumnotes.org/user-guide/concepts/themes), support for user themes
* [Evernote](https://docs.triliumnotes.org/user-guide/concepts/import-export/evernote) and [Markdown import & export](https://docs.triliumnotes.org/user-guide/concepts/import-export/markdown)
* [Web Clipper](https://docs.triliumnotes.org/user-guide/setup/web-clipper) for easy saving of web content
* Touch optimized [mobile frontend](https://triliumnext.github.io/Docs/Wiki/mobile-frontend) for smartphones and tablets
* Built-in [dark theme](https://triliumnext.github.io/Docs/Wiki/themes), support for user themes
* [Evernote](https://triliumnext.github.io/Docs/Wiki/evernote-import) and [Markdown import & export](https://triliumnext.github.io/Docs/Wiki/markdown)
* [Web Clipper](https://triliumnext.github.io/Docs/Wiki/web-clipper) for easy saving of web content
* Customizable UI (sidebar buttons, user-defined widgets, ...)
* [Metrics](https://docs.triliumnotes.org/user-guide/advanced-usage/metrics), along with a Grafana Dashboard.
* [Metrics](./docs/User%20Guide/User%20Guide/Advanced%20Usage/Metrics.md), along with a [Grafana Dashboard](./docs/User%20Guide/User%20Guide/Advanced%20Usage/Metrics/grafana-dashboard.json)
✨ Check out the following third-party resources/communities for more TriliumNext related goodies:
@@ -132,7 +131,7 @@ Note: It is best to disable automatic updates on your server installation (see b
### 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://docs.triliumnotes.org/user-guide/setup/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).
## 💻 Contribute
@@ -199,7 +198,7 @@ Trilium would not be possible without the technologies behind it:
* [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://docs.triliumnotes.org/user-guide/note-types/relation-map) and [link maps](https://docs.triliumnotes.org/user-guide/advanced-usage/note-map#link-map)
* [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)
## 🤝 Support

View File

@@ -0,0 +1,7 @@
#!/usr/bin/env node
import anonymizationService from "../src/services/anonymization.js";
import fs from "fs";
import path from "path";
fs.writeFileSync(path.resolve(__dirname, "tpl", "anonymize-database.sql"), anonymizationService.getFullAnonymizationScript());

View File

@@ -0,0 +1,7 @@
#!/usr/bin/env bash
SCHEMA_FILE_PATH=db/schema.sql
sqlite3 ./data/document.db .schema | grep -v "sqlite_sequence" > "$SCHEMA_FILE_PATH"
echo "DB schema exported to $SCHEMA_FILE_PATH"

View File

@@ -6,11 +6,10 @@
import sqlInit from "../src/services/sql_init.js";
import noteService from "../src/services/notes.js";
import attributeService from "../src/services/attributes.js";
import cloningService from "../src/services/cloning.js";
import { loremIpsum } from "lorem-ipsum";
import "../src/becca/entity_constructor.js";
import { initializeTranslations } from "../src/services/i18n.js";
import cls from "../src/services/cls.js";
import cloningService from "../src/services/cloning.js";
import loremIpsum from "lorem-ipsum";
import "../src/becca/entity_constructor.js";
const noteCount = parseInt(process.argv[2]);
@@ -28,18 +27,8 @@ function getRandomNoteId() {
}
async function start() {
if (!sqlInit.isDbInitialized()) {
console.error("Database not initialized.");
process.exit(1);
}
await initializeTranslations();
sqlInit.initializeDb();
await sqlInit.dbReady;
for (let i = 0; i < noteCount; i++) {
const title = loremIpsum({
const title = loremIpsum.loremIpsum({
count: 1,
units: "sentences",
sentenceLowerBound: 1,
@@ -47,7 +36,7 @@ async function start() {
});
const paragraphCount = Math.floor(Math.random() * Math.random() * 100);
const content = loremIpsum({
const content = loremIpsum.loremIpsum({
count: paragraphCount,
units: "paragraphs",
sentenceLowerBound: 1,
@@ -71,13 +60,13 @@ async function start() {
const parentNoteId = getRandomNoteId();
const prefix = Math.random() > 0.8 ? "prefix" : "";
const result = cloningService.cloneNoteToBranch(noteIdToClone, parentNoteId, prefix);
const result = await cloningService.cloneNoteToBranch(noteIdToClone, parentNoteId, prefix);
console.log(`Cloning ${i}:`, result.success ? "succeeded" : "FAILED");
}
// does not have to be for the current note
attributeService.createAttribute({
await attributeService.createAttribute({
noteId: getRandomNoteId(),
type: "label",
name: "label",
@@ -85,7 +74,7 @@ async function start() {
isInheritable: Math.random() > 0.1 // 10% are inheritable
});
attributeService.createAttribute({
await attributeService.createAttribute({
noteId: getRandomNoteId(),
type: "relation",
name: "relation",
@@ -101,4 +90,6 @@ async function start() {
process.exit(0);
}
cls.init(() => start());
// @TriliumNextTODO sqlInit.dbReady never seems to resolve so program hangs
// see https://github.com/TriliumNext/Trilium/issues/1020
sqlInit.dbReady.then(cls.wrap(start)).catch((err) => console.error(err));

View File

@@ -0,0 +1,16 @@
#!/usr/bin/env bash
if [[ $# -eq 0 ]] ; then
echo "Missing argument of new version"
exit 1
fi
VERSION=$1
SERIES=${VERSION:0:4}-latest
docker push zadam/trilium:$VERSION
docker push zadam/trilium:$SERIES
if [[ $1 != *"beta"* ]]; then
docker push zadam/trilium:latest
fi

View File

@@ -0,0 +1,57 @@
#!/usr/bin/env bash
if [[ $# -eq 0 ]] ; then
echo "Missing argument of new version"
exit 1
fi
VERSION=$1
if ! [[ ${VERSION} =~ ^[0-9]{1,2}\.[0-9]{1,2}\.[0-9]{1,2}(-.+)?$ ]] ;
then
echo "Version ${VERSION} isn't in format X.Y.Z"
exit 1
fi
VERSION_DATE=$(git log -1 --format=%aI "v${VERSION}" | cut -c -10)
VERSION_COMMIT=$(git rev-list -n 1 "v${VERSION}")
# expecting the directory at a specific path
cd ~/trilium-flathub || exit
if ! git diff-index --quiet HEAD --; then
echo "There are uncommitted changes"
exit 1
fi
BASE_BRANCH=main
if [[ "$VERSION" == *"beta"* ]]; then
BASE_BRANCH=beta
fi
git switch "${BASE_BRANCH}"
git pull
BRANCH=b${VERSION}
git branch "${BRANCH}"
git switch "${BRANCH}"
echo "Updating files with version ${VERSION}, date ${VERSION_DATE} and commit ${VERSION_COMMIT}"
flatpak-node-generator npm ../trilium/package-lock.json
xmlstarlet ed --inplace --update "/component/releases/release/@version" --value "${VERSION}" --update "/component/releases/release/@date" --value "${VERSION_DATE}" ./com.github.zadam.trilium.metainfo.xml
yq --inplace "(.modules[0].sources[0].tag = \"v${VERSION}\") | (.modules[0].sources[0].commit = \"${VERSION_COMMIT}\")" ./com.github.zadam.trilium.yml
git add ./generated-sources.json
git add ./com.github.zadam.trilium.metainfo.xml
git add ./com.github.zadam.trilium.yml
git commit -m "release $VERSION"
git push --set-upstream origin "${BRANCH}"
gh pr create --fill -B "${BASE_BRANCH}"
gh pr merge --auto --merge --delete-branch

49
_regroup/bin/release.sh Normal file
View File

@@ -0,0 +1,49 @@
#!/usr/bin/env bash
set -e
if [[ $# -eq 0 ]] ; then
echo "Missing argument of new version"
exit 1
fi
if ! command -v jq &> /dev/null; then
echo "Missing command: jq"
exit 1
fi
VERSION=$1
if ! [[ ${VERSION} =~ ^[0-9]{1,2}\.[0-9]{1,2}\.[0-9]{1,2}(-.+)?$ ]] ;
then
echo "Version ${VERSION} isn't in format X.Y.Z"
exit 1
fi
if ! git diff-index --quiet HEAD --; then
echo "There are uncommitted changes"
exit 1
fi
echo "Releasing Trilium $VERSION"
jq '.version = "'$VERSION'"' package.json > package.json.tmp
mv package.json.tmp package.json
git add package.json
npm run chore:update-build-info
git add src/services/build.ts
TAG=v$VERSION
echo "Committing package.json version change"
git commit -m "chore(release): $VERSION"
git push
echo "Tagging commit with $TAG"
git tag $TAG
git push origin $TAG

View File

Before

Width:  |  Height:  |  Size: 383 B

After

Width:  |  Height:  |  Size: 383 B

View File

Before

Width:  |  Height:  |  Size: 356 B

After

Width:  |  Height:  |  Size: 356 B

View File

Before

Width:  |  Height:  |  Size: 357 B

After

Width:  |  Height:  |  Size: 357 B

View File

Before

Width:  |  Height:  |  Size: 387 B

After

Width:  |  Height:  |  Size: 387 B

View File

Before

Width:  |  Height:  |  Size: 734 B

After

Width:  |  Height:  |  Size: 734 B

View File

@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>com.apple.security.cs.allow-jit</key>
<true/>
<key>com.apple.security.files.user-selected.read-write</key>
<true/>
</dict>
</plist>

51
_regroup/eslint.config.js Normal file
View File

@@ -0,0 +1,51 @@
import eslint from "@eslint/js";
import tseslint from "typescript-eslint";
import simpleImportSort from "eslint-plugin-simple-import-sort";
export default tseslint.config(
eslint.configs.recommended,
tseslint.configs.recommended,
// consider using rules below, once we have a full TS codebase and can be more strict
// tseslint.configs.strictTypeChecked,
// tseslint.configs.stylisticTypeChecked,
// tseslint.configs.recommendedTypeChecked,
{
languageOptions: {
parserOptions: {
projectService: true,
tsconfigRootDir: import.meta.dirname
}
}
},
{
plugins: {
"simple-import-sort": simpleImportSort
}
},
{
rules: {
// add rule overrides here
"no-undef": "off",
"no-unused-vars": "off",
"@typescript-eslint/no-unused-vars": [
"error",
{
argsIgnorePattern: "^_",
varsIgnorePattern: "^_"
}
],
"simple-import-sort/imports": "error",
"simple-import-sort/exports": "error"
}
},
{
ignores: [
"build/*",
"dist/*",
"docs/*",
"demo/*",
"src/public/app-dist/*",
"src/public/app/doc_notes/*"
]
}
);

View File

@@ -0,0 +1,47 @@
import stylistic from "@stylistic/eslint-plugin";
import tsParser from "@typescript-eslint/parser";
// eslint config just for formatting rules
// potentially to be merged with the linting rules into one single config,
// once we have fixed the majority of lint errors
// Go to https://eslint.style/rules/default/${rule_without_prefix} to check the rule details
export const stylisticRules = {
"@stylistic/indent": [ "error", 4 ],
"@stylistic/quotes": [ "error", "double", { avoidEscape: true, allowTemplateLiterals: "always" } ],
"@stylistic/semi": [ "error", "always" ],
"@stylistic/quote-props": [ "error", "consistent-as-needed" ],
"@stylistic/max-len": [ "error", { code: 100 } ],
"@stylistic/comma-dangle": [ "error", "never" ],
"@stylistic/linebreak-style": [ "error", "unix" ],
"@stylistic/array-bracket-spacing": [ "error", "always" ],
"@stylistic/object-curly-spacing": [ "error", "always" ],
"@stylistic/padded-blocks": [ "error", { classes: "always" } ]
};
export default [
{
files: [ "**/*.{js,ts,mjs,cjs}" ],
languageOptions: {
parser: tsParser
},
plugins: {
"@stylistic": stylistic
},
rules: {
...stylisticRules
}
},
{
ignores: [
"build/*",
"dist/*",
"docs/*",
"demo/*",
// TriliumNextTODO: check if we want to format packages here as well - for now skipping it
"packages/*",
"src/public/app-dist/*",
"src/public/app/doc_notes/*"
]
}
];

View File

@@ -0,0 +1,17 @@
import { test as setup, expect } from "@playwright/test";
const authFile = "playwright/.auth/user.json";
const ROOT_URL = "http://localhost:8082";
const LOGIN_PASSWORD = "demo1234";
// Reference: https://playwright.dev/docs/auth#basic-shared-account-in-all-tests
setup("authenticate", async ({ page }) => {
await page.goto(ROOT_URL);
await expect(page).toHaveURL(`${ROOT_URL}/login`);
await page.getByRole("textbox", { name: "Password" }).fill(LOGIN_PASSWORD);
await page.getByRole("button", { name: "Login" }).click();
await page.context().storageState({ path: authFile });
});

View File

@@ -0,0 +1,9 @@
import { test, expect } from "@playwright/test";
test("Can duplicate note with broken links", async ({ page }) => {
await page.goto(`http://localhost:8082/#2VammGGdG6Ie`);
await page.locator(".tree-wrapper .fancytree-active").getByText("Note map").click({ button: "right" });
await page.getByText("Duplicate subtree").click();
await expect(page.locator(".toast-body")).toBeHidden();
await expect(page.locator(".tree-wrapper").getByText("Note map (dup)")).toBeVisible();
});

View File

@@ -0,0 +1,18 @@
import { test, expect } from "@playwright/test";
test("has title", async ({ page }) => {
await page.goto("https://playwright.dev/");
// Expect a title "to contain" a substring.
await expect(page).toHaveTitle(/Playwright/);
});
test("get started link", async ({ page }) => {
await page.goto("https://playwright.dev/");
// Click the get started link.
await page.getByRole("link", { name: "Get started" }).click();
// Expects page to have a heading with the name of Installation.
await expect(page.getByRole("heading", { name: "Installation" })).toBeVisible();
});

View File

@@ -0,0 +1,21 @@
import test, { expect } from "@playwright/test";
test("Native Title Bar not displayed on web", async ({ page }) => {
await page.goto("http://localhost:8082/#root/_hidden/_options/_optionsAppearance");
await expect(page.getByRole("heading", { name: "Theme" })).toBeVisible();
await expect(page.getByRole("heading", { name: "Native Title Bar (requires" })).toBeHidden();
});
test("Tray settings not displayed on web", async ({ page }) => {
await page.goto("http://localhost:8082/#root/_hidden/_options/_optionsOther");
await expect(page.getByRole("heading", { name: "Note Erasure Timeout" })).toBeVisible();
await expect(page.getByRole("heading", { name: "Tray" })).toBeHidden();
});
test("Spellcheck settings not displayed on web", async ({ page }) => {
await page.goto("http://localhost:8082/#root/_hidden/_options/_optionsSpellcheck");
await expect(page.getByRole("heading", { name: "Spell Check" })).toBeVisible();
await expect(page.getByRole("heading", { name: "Tray" })).toBeHidden();
await expect(page.getByText("These options apply only for desktop builds")).toBeVisible();
await expect(page.getByText("Enable spellcheck")).toBeHidden();
});

View File

@@ -0,0 +1,18 @@
import test, { expect } from "@playwright/test";
test("Renders on desktop", async ({ page, context }) => {
await page.goto("http://localhost:8082");
await expect(page.locator(".tree")).toContainText("Trilium Integration Test");
});
test("Renders on mobile", async ({ page, context }) => {
await context.addCookies([
{
url: "http://localhost:8082",
name: "trilium-device",
value: "mobile"
}
]);
await page.goto("http://localhost:8082");
await expect(page.locator(".tree")).toContainText("Trilium Integration Test");
});

View File

@@ -0,0 +1,12 @@
import { test, expect } from "@playwright/test";
const expectedVersion = "0.90.3";
test("Displays update badge when there is a version available", async ({ page }) => {
await page.goto("http://localhost:8080");
await page.getByRole("button", { name: "" }).click();
await page.getByText(`Version ${expectedVersion} is available,`).click();
const page1 = await page.waitForEvent("popup");
expect(page1.url()).toBe(`https://github.com/TriliumNext/Trilium/releases/tag/v${expectedVersion}`);
});

56
_regroup/package.json Normal file
View File

@@ -0,0 +1,56 @@
{
"main": "./electron-main.js",
"bin": {
"trilium": "src/main.js"
},
"type": "module",
"scripts": {
"server:start-safe": "cross-env TRILIUM_DATA_DIR=./data TRILIUM_ENV=dev nodemon src/main.ts",
"server:start-no-dir": "cross-env TRILIUM_ENV=dev nodemon src/main.ts",
"server:start-test": "npm run server:switch && rimraf ./data-test && cross-env TRILIUM_DATA_DIR=./data-test TRILIUM_ENV=dev TRILIUM_PORT=9999 nodemon src/main.ts",
"server:qstart": "npm run server:switch && npm run server:start",
"server:switch": "rimraf ./node_modules/better-sqlite3 && npm install",
"electron:start-no-dir": "cross-env NODE_OPTIONS=\"--import tsx\" TRILIUM_ENV=dev TRILIUM_PORT=37742 electron --inspect=5858 .",
"electron:start-nix": "electron-rebuild --version 33.3.1 && cross-env NODE_OPTIONS=\"--import tsx\" TRILIUM_DATA_DIR=./data TRILIUM_ENV=dev nix-shell -p electron_33 --run \"electron ./electron-main.ts --inspect=5858 .\"",
"electron:start-nix-no-dir": "electron-rebuild --version 33.3.1 && cross-env NODE_OPTIONS=\"--import tsx\" TRILIUM_ENV=dev TRILIUM_PORT=37742 nix-shell -p electron_33 --run \"electron ./electron-main.ts --inspect=5858 .\"",
"electron:start-prod-no-dir": "npm run build:prepare-dist && cross-env TRILIUM_ENV=prod electron --inspect=5858 .",
"electron:start-prod-nix": "electron-rebuild --version 33.3.1 && npm run build:prepare-dist && cross-env TRILIUM_DATA_DIR=./data TRILIUM_ENV=dev nix-shell -p electron_33 --run \"electron ./dist/electron-main.js --inspect=5858 .\"",
"electron:start-prod-nix-no-dir": "electron-rebuild --version 33.3.1 && npm run build:prepare-dist && cross-env TRILIUM_ENV=dev nix-shell -p electron_33 --run \"electron ./dist/electron-main.js --inspect=5858 .\"",
"electron:qstart": "npm run electron:switch && npm run electron:start",
"electron:switch": "electron-rebuild",
"docs:build": "typedoc",
"test": "npm run client:test && npm run server:test",
"client:test": "cross-env TRILIUM_ENV=dev TRILIUM_DATA_DIR=./integration-tests/db TRILIUM_INTEGRATION_TEST=memory vitest --root src/public/app",
"client:coverage": "cross-env TRILIUM_ENV=dev TRILIUM_DATA_DIR=./integration-tests/db TRILIUM_INTEGRATION_TEST=memory vitest --root src/public/app --coverage",
"test:playwright": "playwright test --workers 1",
"test:integration-edit-db": "cross-env TRILIUM_INTEGRATION_TEST=edit TRILIUM_PORT=8081 TRILIUM_ENV=dev TRILIUM_DATA_DIR=./integration-tests/db nodemon src/main.ts",
"test:integration-mem-db": "cross-env nodemon src/main.ts",
"test:integration-mem-db-dev": "cross-env TRILIUM_INTEGRATION_TEST=memory TRILIUM_PORT=8082 TRILIUM_ENV=dev TRILIUM_DATA_DIR=./integration-tests/db nodemon src/main.ts",
"dev:watch-dist": "tsx ./bin/watch-dist.ts",
"dev:format-check": "eslint -c eslint.format.config.js .",
"dev:format-fix": "eslint -c eslint.format.config.js . --fix",
"dev:linter-check": "eslint .",
"dev:linter-fix": "eslint . --fix",
"chore:generate-document": "cross-env nodemon ./bin/generate_document.ts 1000",
"chore:generate-openapi": "tsx bin/generate-openapi.js"
},
"devDependencies": {
"@playwright/test": "1.56.1",
"@stylistic/eslint-plugin": "5.5.0",
"@types/express": "5.0.5",
"@types/node": "24.10.1",
"@types/yargs": "17.0.35",
"@vitest/coverage-v8": "3.2.4",
"eslint": "9.39.1",
"eslint-plugin-simple-import-sort": "12.1.1",
"esm": "3.2.25",
"jsdoc": "4.0.5",
"lorem-ipsum": "2.0.8",
"rcedit": "5.0.1",
"rimraf": "6.1.0",
"tslib": "2.8.1"
},
"optionalDependencies": {
"appdmg": "0.6.6"
}
}

View File

@@ -0,0 +1,9 @@
import etapi from "../support/etapi.js";
/* TriliumNextTODO: port to Vitest
etapi.describeEtapi("app_info", () => {
it("get", async () => {
const appInfo = await etapi.getEtapi("app-info");
expect(appInfo.clipperProtocolVersion).toEqual("1.0");
});
});
*/

View File

@@ -0,0 +1,10 @@
import etapi from "../support/etapi.js";
/* TriliumNextTODO: port to Vitest
etapi.describeEtapi("backup", () => {
it("create", async () => {
const response = await etapi.putEtapiContent("backup/etapi_test");
expect(response.status).toEqual(204);
});
});
*/

View File

@@ -0,0 +1,26 @@
import etapi from "../support/etapi.js";
import fs from "fs";
import path from "path";
import { fileURLToPath } from "url";
/* TriliumNextTODO: port to Vitest
etapi.describeEtapi("import", () => {
// temporarily skip this test since test-export.zip is missing
xit("import", async () => {
const scriptDir = path.dirname(fileURLToPath(import.meta.url));
const zipFileBuffer = fs.readFileSync(path.resolve(scriptDir, "test-export.zip"));
const response = await etapi.postEtapiContent("notes/root/import", zipFileBuffer);
expect(response.status).toEqual(201);
const { note, branch } = await response.json();
expect(note.title).toEqual("test-export");
expect(branch.parentNoteId).toEqual("root");
const content = await (await etapi.getEtapiContent(`notes/${note.noteId}/content`)).text();
expect(content).toContain("test export content");
});
});
*/

View File

@@ -0,0 +1,103 @@
import crypto from "crypto";
import etapi from "../support/etapi.js";
/* TriliumNextTODO: port to Vitest
etapi.describeEtapi("notes", () => {
it("create", async () => {
const { note, branch } = await etapi.postEtapi("create-note", {
parentNoteId: "root",
type: "text",
title: "Hello World!",
content: "Content",
prefix: "Custom prefix"
});
expect(note.title).toEqual("Hello World!");
expect(branch.parentNoteId).toEqual("root");
expect(branch.prefix).toEqual("Custom prefix");
const rNote = await etapi.getEtapi(`notes/${note.noteId}`);
expect(rNote.title).toEqual("Hello World!");
const rContent = await (await etapi.getEtapiContent(`notes/${note.noteId}/content`)).text();
expect(rContent).toEqual("Content");
const rBranch = await etapi.getEtapi(`branches/${branch.branchId}`);
expect(rBranch.parentNoteId).toEqual("root");
expect(rBranch.prefix).toEqual("Custom prefix");
});
it("patch", async () => {
const { note } = await etapi.postEtapi("create-note", {
parentNoteId: "root",
type: "text",
title: "Hello World!",
content: "Content"
});
await etapi.patchEtapi(`notes/${note.noteId}`, {
title: "new title",
type: "code",
mime: "text/apl",
dateCreated: "2000-01-01 12:34:56.999+0200",
utcDateCreated: "2000-01-01 10:34:56.999Z"
});
const rNote = await etapi.getEtapi(`notes/${note.noteId}`);
expect(rNote.title).toEqual("new title");
expect(rNote.type).toEqual("code");
expect(rNote.mime).toEqual("text/apl");
expect(rNote.dateCreated).toEqual("2000-01-01 12:34:56.999+0200");
expect(rNote.utcDateCreated).toEqual("2000-01-01 10:34:56.999Z");
});
it("update content", async () => {
const { note } = await etapi.postEtapi("create-note", {
parentNoteId: "root",
type: "text",
title: "Hello World!",
content: "Content"
});
await etapi.putEtapiContent(`notes/${note.noteId}/content`, "new content");
const rContent = await (await etapi.getEtapiContent(`notes/${note.noteId}/content`)).text();
expect(rContent).toEqual("new content");
});
it("create / update binary content", async () => {
const { note } = await etapi.postEtapi("create-note", {
parentNoteId: "root",
type: "file",
title: "Hello World!",
content: "ZZZ"
});
const updatedContent = crypto.randomBytes(16);
await etapi.putEtapiContent(`notes/${note.noteId}/content`, updatedContent);
const rContent = await (await etapi.getEtapiContent(`notes/${note.noteId}/content`)).arrayBuffer();
expect(Buffer.from(new Uint8Array(rContent))).toEqual(updatedContent);
});
it("delete note", async () => {
const { note } = await etapi.postEtapi("create-note", {
parentNoteId: "root",
type: "text",
title: "Hello World!",
content: "Content"
});
await etapi.deleteEtapi(`notes/${note.noteId}`);
const resp = await etapi.getEtapiResponse(`notes/${note.noteId}`);
expect(resp.status).toEqual(404);
const error = await resp.json();
expect(error.status).toEqual(404);
expect(error.code).toEqual("NOTE_NOT_FOUND");
expect(error.message).toEqual(`Note '${note.noteId}' not found.`);
});
});
*/

View File

@@ -0,0 +1,152 @@
import { describe, beforeAll, afterAll } from "vitest";
let etapiAuthToken: string | undefined;
const getEtapiAuthorizationHeader = (): string => "Basic " + Buffer.from(`etapi:${etapiAuthToken}`).toString("base64");
const PORT: string = "9999";
const HOST: string = "http://localhost:" + PORT;
type SpecDefinitionsFunc = () => void;
function describeEtapi(description: string, specDefinitions: SpecDefinitionsFunc): void {
describe(description, () => {
beforeAll(async () => {});
afterAll(() => {});
specDefinitions();
});
}
async function getEtapiResponse(url: string): Promise<Response> {
return await fetch(`${HOST}/etapi/${url}`, {
method: "GET",
headers: {
Authorization: getEtapiAuthorizationHeader()
}
});
}
async function getEtapi(url: string): Promise<any> {
const response = await getEtapiResponse(url);
return await processEtapiResponse(response);
}
async function getEtapiContent(url: string): Promise<Response> {
const response = await fetch(`${HOST}/etapi/${url}`, {
method: "GET",
headers: {
Authorization: getEtapiAuthorizationHeader()
}
});
checkStatus(response);
return response;
}
async function postEtapi(url: string, data: Record<string, unknown> = {}): Promise<any> {
const response = await fetch(`${HOST}/etapi/${url}`, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: getEtapiAuthorizationHeader()
},
body: JSON.stringify(data)
});
return await processEtapiResponse(response);
}
async function postEtapiContent(url: string, data: BodyInit): Promise<Response> {
const response = await fetch(`${HOST}/etapi/${url}`, {
method: "POST",
headers: {
"Content-Type": "application/octet-stream",
Authorization: getEtapiAuthorizationHeader()
},
body: data
});
checkStatus(response);
return response;
}
async function putEtapi(url: string, data: Record<string, unknown> = {}): Promise<any> {
const response = await fetch(`${HOST}/etapi/${url}`, {
method: "PUT",
headers: {
"Content-Type": "application/json",
Authorization: getEtapiAuthorizationHeader()
},
body: JSON.stringify(data)
});
return await processEtapiResponse(response);
}
async function putEtapiContent(url: string, data?: BodyInit): Promise<Response> {
const response = await fetch(`${HOST}/etapi/${url}`, {
method: "PUT",
headers: {
"Content-Type": "application/octet-stream",
Authorization: getEtapiAuthorizationHeader()
},
body: data
});
checkStatus(response);
return response;
}
async function patchEtapi(url: string, data: Record<string, unknown> = {}): Promise<any> {
const response = await fetch(`${HOST}/etapi/${url}`, {
method: "PATCH",
headers: {
"Content-Type": "application/json",
Authorization: getEtapiAuthorizationHeader()
},
body: JSON.stringify(data)
});
return await processEtapiResponse(response);
}
async function deleteEtapi(url: string): Promise<any> {
const response = await fetch(`${HOST}/etapi/${url}`, {
method: "DELETE",
headers: {
Authorization: getEtapiAuthorizationHeader()
}
});
return await processEtapiResponse(response);
}
async function processEtapiResponse(response: Response): Promise<any> {
const text = await response.text();
if (response.status < 200 || response.status >= 300) {
throw new Error(`ETAPI error ${response.status}: ${text}`);
}
return text?.trim() ? JSON.parse(text) : null;
}
function checkStatus(response: Response): void {
if (response.status < 200 || response.status >= 300) {
throw new Error(`ETAPI error ${response.status}`);
}
}
export default {
describeEtapi,
getEtapi,
getEtapiResponse,
getEtapiContent,
postEtapi,
postEtapiContent,
putEtapi,
putEtapiContent,
patchEtapi,
deleteEtapi
};

View File

@@ -0,0 +1,22 @@
{
"compilerOptions": {
"module": "NodeNext",
"declaration": false,
"sourceMap": true,
"outDir": "./build",
"strict": true,
"noImplicitAny": true,
"resolveJsonModule": true,
"lib": ["ES2023"],
"downlevelIteration": true,
"skipLibCheck": true,
"esModuleInterop": true,
"allowJs": true
},
"include": ["./src/public/app/**/*"],
"files": [
"./src/public/app/types.d.ts",
"./src/public/app/types-lib.d.ts",
"./src/types.d.ts"
]
}

View File

@@ -9,14 +9,14 @@
"keywords": [],
"author": "Elian Doran <contact@eliandoran.me>",
"license": "AGPL-3.0-only",
"packageManager": "pnpm@10.24.0",
"packageManager": "pnpm@10.22.0",
"devDependencies": {
"@redocly/cli": "2.12.3",
"@redocly/cli": "2.11.1",
"archiver": "7.0.1",
"fs-extra": "11.3.2",
"react": "19.2.1",
"react-dom": "19.2.1",
"typedoc": "0.28.15",
"react": "19.2.0",
"react-dom": "19.2.0",
"typedoc": "0.28.14",
"typedoc-plugin-missing-exports": "4.1.2"
}
}

View File

@@ -0,0 +1,5 @@
import baseConfig from "../../eslint.config.mjs";
export default [
...baseConfig
];

View File

@@ -1,6 +1,6 @@
{
"name": "@triliumnext/client",
"version": "0.100.0",
"version": "0.99.5",
"description": "JQuery-based client for TriliumNext, used for both web and desktop (via Electron)",
"private": true,
"license": "AGPL-3.0-only",
@@ -12,10 +12,10 @@
"scripts": {
"build": "cross-env NODE_OPTIONS=--max-old-space-size=4096 vite build",
"test": "vitest",
"coverage": "vitest --coverage",
"circular-deps": "dpdm -T src/**/*.ts --tree=false --warning=false --skip-dynamic-imports=circular"
},
"dependencies": {
"@eslint/js": "9.39.1",
"@excalidraw/excalidraw": "0.18.0",
"@fullcalendar/core": "6.1.19",
"@fullcalendar/daygrid": "6.1.19",
@@ -27,40 +27,39 @@
"@mermaid-js/layout-elk": "0.2.0",
"@mind-elixir/node-menu": "5.0.1",
"@popperjs/core": "2.11.8",
"@preact/signals": "2.5.1",
"@triliumnext/ckeditor5": "workspace:*",
"@triliumnext/codemirror": "workspace:*",
"@triliumnext/commons": "workspace:*",
"@triliumnext/highlightjs": "workspace:*",
"@triliumnext/share-theme": "workspace:*",
"@triliumnext/split.js": "workspace:*",
"@zumer/snapdom": "2.0.1",
"autocomplete.js": "0.38.1",
"bootstrap": "5.3.8",
"boxicons": "2.1.4",
"clsx": "2.1.1",
"color": "5.0.3",
"dayjs": "1.11.19",
"dayjs-plugin-utc": "0.1.2",
"debounce": "3.0.0",
"draggabilly": "3.0.0",
"force-graph": "1.51.0",
"globals": "16.5.0",
"i18next": "25.7.1",
"i18next": "25.6.2",
"i18next-http-backend": "3.0.2",
"jquery": "3.7.1",
"jquery.fancytree": "2.38.5",
"jsplumb": "2.15.6",
"katex": "0.16.27",
"katex": "0.16.25",
"knockout": "3.5.1",
"leaflet": "1.9.4",
"leaflet-gpx": "2.2.0",
"mark.js": "8.11.1",
"marked": "17.0.1",
"mermaid": "11.12.2",
"mind-elixir": "5.3.7",
"marked": "16.4.2",
"mermaid": "11.12.1",
"mind-elixir": "5.3.5",
"normalize.css": "8.0.1",
"panzoom": "9.4.3",
"preact": "10.28.0",
"react-i18next": "16.4.0",
"preact": "10.27.2",
"react-i18next": "16.3.3",
"reveal.js": "5.2.1",
"svg-pan-zoom": "3.6.2",
"tabulator-tables": "6.3.1",
@@ -74,10 +73,10 @@
"@types/leaflet": "1.9.21",
"@types/leaflet-gpx": "1.3.8",
"@types/mark.js": "8.11.12",
"@types/reveal.js": "5.2.2",
"@types/reveal.js": "5.2.1",
"@types/tabulator-tables": "6.3.0",
"copy-webpack-plugin": "13.0.1",
"happy-dom": "20.0.11",
"happy-dom": "20.0.10",
"script-loader": "0.7.2",
"vite-plugin-static-copy": "3.1.4"
}

View File

@@ -34,7 +34,6 @@ import { AddLinkOpts } from "../widgets/dialogs/add_link.jsx";
import { IncludeNoteOpts } from "../widgets/dialogs/include_note.jsx";
import { ReactWrappedWidget } from "../widgets/basic_widget.js";
import type { MarkdownImportOpts } from "../widgets/dialogs/markdown_import.jsx";
import type { InfoProps } from "../widgets/dialogs/info.jsx";
interface Layout {
getRootWidget: (appContext: AppContext) => RootContainer;
@@ -125,7 +124,7 @@ export type CommandMappings = {
isNewNote?: boolean;
};
showPromptDialog: PromptDialogOptions;
showInfoDialog: InfoProps;
showInfoDialog: ConfirmWithMessageOptions;
showConfirmDialog: ConfirmWithMessageOptions;
showRecentChanges: CommandData & { ancestorNoteId: string };
showImportDialog: CommandData & { noteId: string };
@@ -446,7 +445,6 @@ type EventMappings = {
error: string;
};
searchRefreshed: { ntxId?: string | null };
textEditorRefreshed: { ntxId?: string | null, editor: CKTextEditor };
hoistedNoteChanged: {
noteId: string;
ntxId: string | null;
@@ -488,7 +486,7 @@ type EventMappings = {
relationMapResetPanZoom: { ntxId: string | null | undefined };
relationMapResetZoomIn: { ntxId: string | null | undefined };
relationMapResetZoomOut: { ntxId: string | null | undefined };
activeNoteChanged: {ntxId: string | null | undefined};
activeNoteChanged: {};
showAddLinkDialog: AddLinkOpts;
showIncludeDialog: IncludeNoteOpts;
openBulkActionsDialog: {

View File

@@ -321,10 +321,6 @@ class NoteContext extends Component implements EventListener<"entitiesReloaded">
return false;
}
if (note.type === "search") {
return false;
}
if (!["default", "contextual-help"].includes(this.viewScope?.viewMode ?? "")) {
return false;
}

View File

@@ -165,7 +165,7 @@ export default class TabManager extends Component {
const activeNoteContext = this.getActiveContext();
this.updateDocumentTitle(activeNoteContext);
this.triggerEvent("activeNoteChanged", {ntxId:activeNoteContext?.ntxId}); // trigger this even in on popstate event
this.triggerEvent("activeNoteChanged", {}); // trigger this even in on popstate event
}
calculateHash(): string {
@@ -647,32 +647,7 @@ export default class TabManager extends Component {
...this.noteContexts.slice(-noteContexts.length),
...this.noteContexts.slice(lastClosedTab.position, -noteContexts.length)
];
// Update mainNtxId if the restored pane is the main pane in the split pane
const { oldMainNtxId, newMainNtxId } = (() => {
if (noteContexts.length !== 1) {
return { oldMainNtxId: undefined, newMainNtxId: undefined };
}
const mainNtxId = noteContexts[0]?.mainNtxId;
const index = this.noteContexts.findIndex(c => c.ntxId === mainNtxId);
// No need to update if the restored position is after mainNtxId
if (index === -1 || lastClosedTab.position > index) {
return { oldMainNtxId: undefined, newMainNtxId: undefined };
}
return {
oldMainNtxId: this.noteContexts[index].ntxId ?? undefined,
newMainNtxId: noteContexts[0]?.ntxId ?? undefined
};
})();
this.triggerCommand("noteContextReorder", {
ntxIdsInOrder: ntxsInOrder.map((nc) => nc.ntxId).filter((id) => id !== null),
oldMainNtxId,
newMainNtxId
});
this.noteContextReorderEvent({ ntxIdsInOrder: ntxsInOrder.map((nc) => nc.ntxId).filter((id) => id !== null) });
let mainNtx = noteContexts.find((nc) => nc.isMainContext());
if (mainNtx) {

View File

@@ -22,7 +22,6 @@ bundleService.getWidgetBundlesByParent().then(async (widgetBundles) => {
appContext.setLayout(new DesktopLayout(widgetBundles));
appContext.start().catch((e) => {
toastService.showPersistent({
id: "critical-error",
title: t("toast.critical-error.title"),
icon: "alert",
message: t("toast.critical-error.message", { message: e.message })
@@ -59,11 +58,12 @@ function initOnElectron() {
initDarkOrLightMode(style);
initTransparencyEffects(style, currentWindow);
initFullScreenDetection(currentWindow);
if (options.get("nativeTitleBarVisible") !== "true") {
initTitleBarButtons(style, currentWindow);
}
electron.ipcRenderer.send("ipcReady");
}
function initTitleBarButtons(style: CSSStyleDeclaration, currentWindow: Electron.BrowserWindow) {
@@ -89,11 +89,6 @@ function initTitleBarButtons(style: CSSStyleDeclaration, currentWindow: Electron
}
}
function initFullScreenDetection(currentWindow: Electron.BrowserWindow) {
currentWindow.on("enter-full-screen", () => document.body.classList.add("full-screen"));
currentWindow.on("leave-full-screen", () => document.body.classList.remove("full-screen"));
}
function initTransparencyEffects(style: CSSStyleDeclaration, currentWindow: Electron.BrowserWindow) {
if (window.glob.platform === "win32") {
const material = style.getPropertyValue("--background-material");

View File

@@ -240,7 +240,7 @@ export default class FNote {
const aNote = this.froca.getNoteFromCache(aNoteId);
if (!aNote || aNote.isArchived || aNote.isHiddenCompletely()) {
if (aNote.isArchived || aNote.isHiddenCompletely()) {
return 1;
}

View File

@@ -10,6 +10,7 @@ import FlexContainer from "../widgets/containers/flex_container.js";
import FloatingButtons from "../widgets/FloatingButtons.jsx";
import GlobalMenu from "../widgets/buttons/global_menu.jsx";
import HighlightsListWidget from "../widgets/highlights_list.js";
import LauncherContainer from "../widgets/containers/launcher_container.js";
import LeftPaneContainer from "../widgets/containers/left_pane_container.js";
import LeftPaneToggle from "../widgets/buttons/left_pane_toggle.js";
import MovePaneButton from "../widgets/buttons/move_pane_button.js";
@@ -20,6 +21,7 @@ import NoteTreeWidget from "../widgets/note_tree.js";
import NoteWrapperWidget from "../widgets/note_wrapper.js";
import options from "../services/options.js";
import PasswordNoteSetDialog from "../widgets/dialogs/password_not_set.js";
import PromotedAttributesWidget from "../widgets/promoted_attributes.js";
import QuickSearchWidget from "../widgets/quick_search.js";
import ReadOnlyNoteInfoBar from "../widgets/ReadOnlyNoteInfoBar.jsx";
import Ribbon from "../widgets/ribbon/Ribbon.jsx";
@@ -29,6 +31,7 @@ import ScrollingContainer from "../widgets/containers/scrolling_container.js";
import ScrollPadding from "../widgets/scroll_padding.js";
import SearchResult from "../widgets/search_result.jsx";
import SharedInfo from "../widgets/shared_info.jsx";
import SpacerWidget from "../widgets/spacer.js";
import SplitNoteContainer from "../widgets/containers/split_note_container.js";
import SqlResults from "../widgets/sql_result.js";
import SqlTableSchemas from "../widgets/sql_table_schemas.js";
@@ -41,9 +44,6 @@ import UploadAttachmentsDialog from "../widgets/dialogs/upload_attachments.js";
import utils from "../services/utils.js";
import WatchedFileUpdateStatusWidget from "../widgets/watched_file_update_status.js";
import NoteDetail from "../widgets/NoteDetail.jsx";
import PromotedAttributes from "../widgets/PromotedAttributes.jsx";
import SpacerWidget from "../widgets/launch_bar/SpacerWidget.jsx";
import LauncherContainer from "../widgets/launch_bar/LauncherContainer.jsx";
export default class DesktopLayout {
@@ -124,7 +124,7 @@ export default class DesktopLayout {
.cssBlock(".title-row > * { margin: 5px; }")
.child(<NoteIconWidget />)
.child(<NoteTitleWidget />)
.child(<SpacerWidget baseSize={0} growthFactor={1} />)
.child(new SpacerWidget(0, 1))
.child(<MovePaneButton direction="left" />)
.child(<MovePaneButton direction="right" />)
.child(<ClosePaneButton />)
@@ -140,7 +140,7 @@ export default class DesktopLayout {
.child(<ReadOnlyNoteInfoBar />)
.child(<SharedInfo />)
)
.child(<PromotedAttributes />)
.child(new PromotedAttributesWidget())
.child(<SqlTableSchemas />)
.child(<NoteDetail />)
.child(<NoteList media="screen" />)
@@ -184,14 +184,14 @@ export default class DesktopLayout {
launcherPane = new FlexContainer("row")
.css("height", "53px")
.class("horizontal")
.child(<LauncherContainer isHorizontalLayout={true} />)
.child(new LauncherContainer(true))
.child(<GlobalMenu isHorizontalLayout={true} />);
} else {
launcherPane = new FlexContainer("column")
.css("width", "53px")
.class("vertical")
.child(<GlobalMenu isHorizontalLayout={false} />)
.child(<LauncherContainer isHorizontalLayout={false} />)
.child(new LauncherContainer(false))
.child(<LeftPaneToggle isHorizontalLayout={false} />);
}

View File

@@ -22,9 +22,16 @@ 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 CallToActionDialog from "../widgets/dialogs/call_to_action.jsx";
import PopupEditorDialog from "../widgets/dialogs/PopupEditor.jsx";
import ToastContainer from "../widgets/Toast.jsx";
import NoteTitleWidget from "../widgets/note_title.jsx";
import FormattingToolbar from "../widgets/ribbon/FormattingToolbar.js";
import NoteList from "../widgets/collections/NoteList.jsx";
import NoteDetail from "../widgets/NoteDetail.jsx";
import StandaloneRibbonAdapter from "../widgets/ribbon/components/StandaloneRibbonAdapter.jsx";
export function applyModals(rootContainer: RootContainer) {
rootContainer
@@ -50,7 +57,16 @@ export function applyModals(rootContainer: RootContainer) {
.child(<ConfirmDialog />)
.child(<PromptDialog />)
.child(<IncorrectCpuArchDialog />)
.child(<PopupEditorDialog />)
.child(<CallToActionDialog />)
.child(<ToastContainer />)
.child(new PopupEditorDialog()
.child(new FlexContainer("row")
.class("title-row")
.css("align-items", "center")
.cssBlock(".title-row > * { margin: 5px; }")
.child(<NoteIconWidget />)
.child(<NoteTitleWidget />))
.child(<StandaloneRibbonAdapter component={FormattingToolbar} />)
.child(new PromotedAttributesWidget())
.child(<NoteDetail />)
.child(<NoteList media="screen" displayOnlyCollections />))
.child(<CallToActionDialog />);
}

View File

@@ -6,12 +6,14 @@ import FilePropertiesTab from "../widgets/ribbon/FilePropertiesTab.jsx";
import FlexContainer from "../widgets/containers/flex_container.js";
import FloatingButtons from "../widgets/FloatingButtons.jsx";
import GlobalMenuWidget from "../widgets/buttons/global_menu.js";
import LauncherContainer from "../widgets/containers/launcher_container.js";
import MobileDetailMenu from "../widgets/mobile_widgets/mobile_detail_menu.js";
import NoteList from "../widgets/collections/NoteList.jsx";
import NoteTitleWidget from "../widgets/note_title.js";
import ContentHeader from "../widgets/containers/content_header.js";
import NoteTreeWidget from "../widgets/note_tree.js";
import NoteWrapperWidget from "../widgets/note_wrapper.js";
import PromotedAttributesWidget from "../widgets/promoted_attributes.js";
import QuickSearchWidget from "../widgets/quick_search.js";
import ReadOnlyNoteInfoBar from "../widgets/ReadOnlyNoteInfoBar.jsx";
import RootContainer from "../widgets/containers/root_container.js";
@@ -27,13 +29,9 @@ import ToggleSidebarButton from "../widgets/mobile_widgets/toggle_sidebar_button
import type AppContext from "../components/app_context.js";
import NoteDetail from "../widgets/NoteDetail.jsx";
import MobileEditorToolbar from "../widgets/type_widgets/text/mobile_editor_toolbar.jsx";
import PromotedAttributes from "../widgets/PromotedAttributes.jsx";
import SplitNoteContainer from "../widgets/containers/split_note_container.js";
import LauncherContainer from "../widgets/launch_bar/LauncherContainer.jsx";
const MOBILE_CSS = `
<style>
span.keyboard-shortcut,
kbd {
display: none;
}
@@ -143,35 +141,33 @@ export default class MobileLayout {
.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 SplitNoteContainer(() =>
new NoteWrapperWidget()
.child(
new FlexContainer("row")
.contentSized()
.css("font-size", "larger")
.css("align-items", "center")
.child(<ToggleSidebarButton />)
.child(<NoteTitleWidget />)
.child(<MobileDetailMenu />)
)
.child(<FloatingButtons items={MOBILE_FLOATING_BUTTONS} />)
.child(<PromotedAttributes />)
.child(
new ScrollingContainer()
.filling()
.contentSized()
.child(new ContentHeader()
.child(<ReadOnlyNoteInfoBar />)
.child(<SharedInfoWidget />)
)
.child(<NoteDetail />)
.child(<NoteList media="screen" />)
.child(<StandaloneRibbonAdapter component={SearchDefinitionTab} />)
.child(<SearchResult />)
.child(<FilePropertiesWrapper />)
)
.child(<MobileEditorToolbar />)
)
new NoteWrapperWidget()
.child(
new FlexContainer("row")
.contentSized()
.css("font-size", "larger")
.css("align-items", "center")
.child(<ToggleSidebarButton />)
.child(<NoteTitleWidget />)
.child(<MobileDetailMenu />)
)
.child(<FloatingButtons items={MOBILE_FLOATING_BUTTONS} />)
.child(new PromotedAttributesWidget())
.child(
new ScrollingContainer()
.filling()
.contentSized()
.child(new ContentHeader()
.child(<ReadOnlyNoteInfoBar />)
.child(<SharedInfoWidget />)
)
.child(<NoteDetail />)
.child(<NoteList media="screen" />)
.child(<StandaloneRibbonAdapter component={SearchDefinitionTab} />)
.child(<SearchResult />)
.child(<FilePropertiesWrapper />)
)
.child(<MobileEditorToolbar />)
)
)
)
@@ -183,7 +179,7 @@ export default class MobileLayout {
.child(new FlexContainer("row")
.class("horizontal")
.css("height", "53px")
.child(<LauncherContainer isHorizontalLayout />)
.child(new LauncherContainer(true))
.child(<GlobalMenuWidget isHorizontalLayout />)
.id("launcher-pane"))
)

View File

@@ -2,7 +2,7 @@ import { KeyboardActionNames } from "@triliumnext/commons";
import keyboardActionService, { getActionSync } from "../services/keyboard_actions.js";
import note_tooltip from "../services/note_tooltip.js";
import utils from "../services/utils.js";
import { h, JSX, render } from "preact";
import { should } from "vitest";
export interface ContextMenuOptions<T> {
x: number;
@@ -15,11 +15,6 @@ export interface ContextMenuOptions<T> {
onHide?: () => void;
}
export interface CustomMenuItem {
kind: "custom",
componentFn: () => JSX.Element | null;
}
export interface MenuSeparatorItem {
kind: "separator";
}
@@ -56,7 +51,7 @@ export interface MenuCommandItem<T> {
columns?: number;
}
export type MenuItem<T> = MenuCommandItem<T> | CustomMenuItem | MenuSeparatorItem | MenuHeader;
export type MenuItem<T> = MenuCommandItem<T> | MenuSeparatorItem | MenuHeader;
export type MenuHandler<T> = (item: MenuCommandItem<T>, e: JQuery.MouseDownEvent<HTMLElement, undefined, HTMLElement, HTMLElement>) => void;
export type ContextMenuEvent = PointerEvent | MouseEvent | JQuery.ContextMenuEvent;
@@ -165,19 +160,16 @@ class ContextMenu {
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
let prevItemKind: string = "";
for (let index = 0; index < items.length; index++) {
const item = items[index];
const itemKind = ("kind" in item) ? item.kind : "";
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 (itemKind === "header") {
if ("kind" in item && item.kind === "header") {
if (multicolumn && !shouldResetGroup) {
shouldStartNewGroup = true;
}
@@ -203,25 +195,125 @@ class ContextMenu {
shouldStartNewGroup = false;
}
if (itemKind === "separator") {
if (prevItemKind === "separator") {
// Skip consecutive separators
continue;
}
if ("kind" in item && item.kind === "separator") {
$group.append($("<div>").addClass("dropdown-divider"));
shouldResetGroup = true; // End the group after the next item
} else if (itemKind === "header") {
$group.append($("<h6>").addClass("dropdown-header").text((item as MenuHeader).title));
} else if ("kind" in item && item.kind === "header") {
$group.append($("<h6>").addClass("dropdown-header").text(item.title));
shouldResetGroup = true;
} else {
if (itemKind === "custom") {
// Custom menu item
$group.append(this.createCustomMenuItem(item as CustomMenuItem));
} else {
// Standard menu item
$group.append(this.createMenuItem(item as MenuCommandItem<any>));
const $icon = $("<span>");
if ("uiIcon" in item || "checked" in item) {
const icon = (item.checked ? "bx bx-check" : item.uiIcon);
if (icon) {
$icon.addClass(icon);
} else {
$icon.append("&nbsp;");
}
}
const $link = $("<span>")
.append($icon)
.append(" &nbsp; ") // some space between icon and text
.append(item.title);
if ("badges" in item && item.badges) {
for (let badge of item.badges) {
const badgeElement = $(`<span class="badge">`).text(badge.title);
if (badge.className) {
badgeElement.addClass(badge.className);
}
$link.append(badgeElement);
}
}
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) {
$link.append($("<kbd>").text(item.shortcut));
}
const $item = $("<li>")
.addClass("dropdown-item")
.append($link)
.on("contextmenu", (e) => false)
// important to use mousedown instead of click since the former does not change focus
// (especially important for focused text for spell check)
.on("mousedown", (e) => {
e.stopPropagation();
if (e.which !== 1) {
// only left click triggers menu items
return false;
}
if (this.isMobile && "items" in item && item.items) {
const $item = $(e.target).closest(".dropdown-item");
$item.toggleClass("submenu-open");
$item.find("ul.dropdown-menu").toggleClass("show");
return false;
}
if ("handler" in item && item.handler) {
item.handler(item, e);
}
this.options?.selectMenuItemHandler(item, e);
// it's important to stop the propagation especially for sub-menus, otherwise the event
// might be handled again by top-level menu
return false;
});
$item.on("mouseup", (e) => {
// Prevent submenu from failing to expand on mobile
if (!this.isMobile || !("items" in item && item.items)) {
e.stopPropagation();
// Hide the content menu on mouse up to prevent the mouse event from propagating to the elements below.
this.hide();
return false;
}
});
if ("enabled" in item && item.enabled !== undefined && !item.enabled) {
$item.addClass("disabled");
}
if ("items" in item && item.items) {
$item.addClass("dropdown-submenu");
$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!);
}
this.addItems($subMenu, item.items, hasColumns);
$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) {
@@ -229,126 +321,9 @@ class ContextMenu {
shouldResetGroup = false;
};
}
prevItemKind = itemKind;
}
}
private createCustomMenuItem(item: CustomMenuItem) {
const element = document.createElement("li");
element.classList.add("dropdown-custom-item");
element.onclick = () => this.hide();
render(h(item.componentFn, {}), element);
return element;
}
private createMenuItem(item: MenuCommandItem<any>) {
const $icon = $("<span>");
if ("uiIcon" in item || "checked" in item) {
const icon = (item.checked ? "bx bx-check" : item.uiIcon);
if (icon) {
$icon.addClass(icon);
} else {
$icon.append("&nbsp;");
}
}
const $link = $("<span>")
.append($icon)
.append(" &nbsp; ") // some space between icon and text
.append(item.title);
if ("badges" in item && item.badges) {
for (let badge of item.badges) {
const badgeElement = $(`<span class="badge">`).text(badge.title);
if (badge.className) {
badgeElement.addClass(badge.className);
}
$link.append(badgeElement);
}
}
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) {
$link.append($("<kbd>").text(item.shortcut));
}
const $item = $("<li>")
.addClass("dropdown-item")
.append($link)
.on("contextmenu", (e) => false)
// important to use mousedown instead of click since the former does not change focus
// (especially important for focused text for spell check)
.on("mousedown", (e) => {
if (e.which !== 1) {
// only left click triggers menu items
return false;
}
if (this.isMobile && "items" in item && item.items) {
const $item = $(e.target).closest(".dropdown-item");
$item.toggleClass("submenu-open");
$item.find("ul.dropdown-menu").toggleClass("show");
return false;
}
// Prevent submenu from failing to expand on mobile
if (!("items" in item && item.items)) {
this.hide();
}
if ("handler" in item && item.handler) {
item.handler(item, e);
}
this.options?.selectMenuItemHandler(item, e);
// it's important to stop the propagation especially for sub-menus, otherwise the event
// might be handled again by top-level menu
return false;
});
if ("enabled" in item && item.enabled !== undefined && !item.enabled) {
$item.addClass("disabled");
}
if ("items" in item && item.items) {
$item.addClass("dropdown-submenu");
$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!);
}
this.addItems($subMenu, item.items, hasColumns);
$item.append($subMenu);
}
return $item;
}
async hide() {
this.options?.onHide?.();
this.$widget.removeClass("show");

View File

@@ -1,21 +0,0 @@
import { t } from "../services/i18n"
import attributes from "../services/attributes"
import FNote from "../entities/fnote"
export function getArchiveMenuItem(note: FNote) {
if (!note.isArchived) {
return {
title: t("board_view.archive-note"),
uiIcon: "bx bx-archive",
handler: () => attributes.addLabel(note.noteId, "archived")
}
} else {
return {
title: t("board_view.unarchive-note"),
uiIcon: "bx bx-archive-out",
handler: async () => {
attributes.removeOwnedLabelByName(note, "archived")
}
}
}
}

View File

@@ -1,86 +0,0 @@
:root {
--note-color-picker-clear-color-cell-background: var(--primary-button-background-color);
--note-color-picker-clear-color-cell-color: var(--main-background-color);
--note-color-picker-clear-color-cell-selection-outline-color: var(--primary-button-border-color);
}
.note-color-picker {
display: flex;
gap: 8px;
justify-content: space-between;
}
.note-color-picker .color-cell {
--color-picker-cell-size: 14px;
width: var(--color-picker-cell-size);
height: var(--color-picker-cell-size);
border-radius: 4px;
background-color: var(--color);
}
.note-color-picker .color-cell:not(.selected):hover {
transform: scale(1.2);
}
.note-color-picker .color-cell.disabled-color-cell {
cursor: not-allowed;
}
.note-color-picker .color-cell.selected {
outline: 2px solid var(--outline-color, var(--color));
outline-offset: 2px;
}
/*
* RESET COLOR CELL
*/
.note-color-picker .color-cell-reset {
--color: var(--note-color-picker-clear-color-cell-background);
--outline-color: var(--note-color-picker-clear-color-cell-selection-outline-color);
position: relative;
display: flex;
justify-content: center;
align-items: center;
}
.note-color-picker .color-cell-reset svg {
width: var(--color-picker-cell-size);
height: var(--color-picker-cell-size);
fill: var(--note-color-picker-clear-color-cell-color);
}
/*
* CUSTOM COLOR CELL
*/
.note-color-picker .custom-color-cell::before {
position: absolute;
content: "\ed35";
display: flex;
top: 0;
left: 0;
right: 0;
bottom: 0;
font-size: calc(var(--color-picker-cell-size) * 1.3);
justify-content: center;
align-items: center;
font-family: boxicons;
font-size: 16px;
color: var(--foreground);
}
.note-color-picker .custom-color-cell {
position: relative;
display: flex;
justify-content: center;
overflow: hidden;
}
.note-color-picker .custom-color-cell.custom-color-cell-empty {
background-image: url(./custom-color.png);
background-size: cover;
--foreground: transparent;
}

View File

@@ -1,204 +0,0 @@
import "./NoteColorPicker.css";
import { t } from "../../services/i18n";
import { useCallback, useEffect, useRef, useState} from "preact/hooks";
import {ComponentChildren} from "preact";
import attributes from "../../services/attributes";
import clsx from "clsx";
import Color, { ColorInstance } from "color";
import Debouncer from "../../utils/debouncer";
import FNote from "../../entities/fnote";
import froca from "../../services/froca";
import { isMobile } from "../../services/utils";
const COLOR_PALETTE = [
"#e64d4d", "#e6994d", "#e5e64d", "#99e64d", "#4de64d", "#4de699",
"#4de5e6", "#4d99e6", "#4d4de6", "#994de6", "#e64db3"
];
export interface NoteColorPickerProps {
/** The target Note instance or its ID string. */
note: FNote | string | null;
}
export default function NoteColorPicker(props: NoteColorPickerProps) {
if (!props.note) return null;
const [note, setNote] = useState<FNote | null>(null);
const [currentColor, setCurrentColor] = useState<string | null>(null);
const [isCustomColor, setIsCustomColor] = useState<boolean>(false);
useEffect(() => {
const retrieveNote = async (noteId: string) => {
const noteInstance = await froca.getNote(noteId, true);
if (noteInstance) {
setNote(noteInstance);
}
}
if (typeof props.note === "string") {
retrieveNote(props.note); // Get the note from the given ID string
} else {
setNote(props.note);
}
}, []);
useEffect(() => {
const colorLabel = note?.getLabel("color")?.value ?? null;
if (colorLabel) {
let color = tryParseColor(colorLabel);
if (color) {
setCurrentColor(color.hex().toLowerCase());
}
}
}, [note]);
useEffect(() => {
setIsCustomColor(currentColor !== null && COLOR_PALETTE.indexOf(currentColor) === -1);
}, [currentColor])
const onColorCellClicked = useCallback((color: string | null) => {
if (note) {
if (color !== null) {
attributes.setLabel(note.noteId, "color", color);
} else {
attributes.removeOwnedLabelByName(note, "color");
}
setCurrentColor(color);
}
}, [note, currentColor]);
return <div className="note-color-picker">
<ColorCell className="color-cell-reset"
tooltip={t("note-color.clear-color")}
color={null}
isSelected={(currentColor === null)}
isDisabled={(note === null)}
onSelect={onColorCellClicked}>
{/* https://pictogrammers.com/library/mdi/icon/close/ */}
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
<path d="M19,6.41L17.59,5L12,10.59L6.41,5L5,6.41L10.59,12L5,17.59L6.41,19L12,13.41L17.59,19L19,17.59L13.41,12L19,6.41Z" />
</svg>
</ColorCell>
{COLOR_PALETTE.map((color) => (
<ColorCell key={color}
tooltip={t("note-color.set-color")}
color={color}
isSelected={(color === currentColor)}
isDisabled={(note === null)}
onSelect={onColorCellClicked} />
))}
<CustomColorCell tooltip={t("note-color.set-custom-color")}
color={currentColor}
isSelected={isCustomColor}
isDisabled={(note === null)}
onSelect={onColorCellClicked} />
</div>
}
interface ColorCellProps {
children?: ComponentChildren,
className?: string,
tooltip?: string,
color: string | null,
isSelected: boolean,
isDisabled?: boolean,
onSelect?: (color: string | null) => void
}
function ColorCell(props: ColorCellProps) {
return <div className={clsx(props.className, {
"color-cell": true,
"selected": props.isSelected,
"disabled-color-cell": props.isDisabled
})}
style={`${(props.color !== null) ? `--color: ${props.color}` : ""}`}
title={props.tooltip}
onClick={() => props.onSelect?.(props.color)}>
{props.children}
</div>;
}
function CustomColorCell(props: ColorCellProps) {
const [pickedColor, setPickedColor] = useState<string | null>(null);
const colorInput = useRef<HTMLInputElement>(null);
const colorInputDebouncer = useRef<Debouncer<string | null> | null>(null);
const callbackRef = useRef(props.onSelect);
useEffect(() => {
colorInputDebouncer.current = new Debouncer(250, (color) => {
callbackRef.current?.(color);
setPickedColor(color);
});
return () => {
colorInputDebouncer.current?.destroy();
}
}, []);
useEffect(() => {
if (props.isSelected && pickedColor === null) {
setPickedColor(props.color);
}
}, [props.isSelected])
useEffect(() => {
callbackRef.current = props.onSelect;
}, [props.onSelect]);
const onSelect = useCallback(() => {
if (pickedColor !== null) {
callbackRef.current?.(pickedColor);
}
colorInput.current?.click();
}, [pickedColor]);
return <div style={`--foreground: ${getForegroundColor(props.color)};`}
onClick={isMobile() ? (e) => {
// The color picker dropdown will close on some browser if the parent context menu is
// dismissed, so stop the click propagation to prevent dismissing the menu.
e.stopPropagation();
} : undefined}>
<ColorCell {...props}
color={pickedColor}
className={clsx("custom-color-cell", {
"custom-color-cell-empty": (pickedColor === null)
})}
onSelect={onSelect}>
<input ref={colorInput}
type="color"
value={pickedColor ?? props.color ?? "#40bfbf"}
onChange={() => {colorInputDebouncer.current?.updateValue(colorInput.current?.value ?? null)}}
style="width: 0; height: 0; opacity: 0" />
</ColorCell>
</div>
}
function getForegroundColor(backgroundColor: string | null) {
if (backgroundColor === null) return "inherit";
const colorHsl = tryParseColor(backgroundColor)?.hsl();
if (colorHsl) {
let l = colorHsl.lightness();
return colorHsl.saturationl(0).lightness(l >= 50 ? 0 : 100).hex();
} else {
return "inherit";
}
}
function tryParseColor(colorStr: string): ColorInstance | null {
try {
return new Color(colorStr);
} catch(ex) {
console.error(ex);
}
return null;
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.2 KiB

View File

@@ -4,7 +4,7 @@ import zoomService from "../components/zoom.js";
import contextMenu, { type MenuItem } from "./context_menu.js";
import { t } from "../services/i18n.js";
import type { BrowserWindow } from "electron";
import type { CommandNames, AppContext } from "../components/app_context.js";
import type { CommandNames } from "../components/app_context.js";
function setupContextMenu() {
const electron = utils.dynamicRequire("electron");
@@ -13,8 +13,6 @@ function setupContextMenu() {
// FIXME: Remove typecast once Electron is properly integrated.
const { webContents } = remote.getCurrentWindow() as BrowserWindow;
let appContext: AppContext;
webContents.on("context-menu", (event, params) => {
const { editFlags } = params;
const hasText = params.selectionText.trim().length > 0;
@@ -121,20 +119,6 @@ function setupContextMenu() {
uiIcon: "bx bx-search-alt",
handler: () => electron.shell.openExternal(searchUrl)
});
items.push({
title: t("electron_context_menu.search_in_trilium", { term: shortenedSelection }),
uiIcon: "bx bx-search",
handler: async () => {
if (!appContext) {
appContext = (await import("../components/app_context.js")).default;
}
await appContext.triggerCommand("searchNotes", {
searchString: params.selectionText
});
}
});
}
if (items.length === 0) {

View File

@@ -2,32 +2,26 @@ import { t } from "../services/i18n.js";
import contextMenu, { type ContextMenuEvent, type MenuItem } from "./context_menu.js";
import appContext, { type CommandNames } from "../components/app_context.js";
import type { ViewScope } from "../services/link.js";
import utils, { isMobile } from "../services/utils.js";
import { getClosestNtxId } from "../widgets/widget_utils.js";
import type { LeafletMouseEvent } from "leaflet";
function openContextMenu(notePath: string, e: ContextMenuEvent, viewScope: ViewScope = {}, hoistedNoteId: string | null = null) {
contextMenu.show({
x: e.pageX,
y: e.pageY,
items: getItems(e),
selectMenuItemHandler: ({ command }) => handleLinkContextMenuItem(command, e, notePath, viewScope, hoistedNoteId)
items: getItems(),
selectMenuItemHandler: ({ command }) => handleLinkContextMenuItem(command, notePath, viewScope, hoistedNoteId)
});
}
function getItems(e: ContextMenuEvent | LeafletMouseEvent): MenuItem<CommandNames>[] {
const ntxId = getNtxId(e);
const isMobileSplitOpen = isMobile() && appContext.tabManager.getNoteContextById(ntxId).getMainContext().getSubContexts().length > 1;
function getItems(): MenuItem<CommandNames>[] {
return [
{ title: t("link_context_menu.open_note_in_new_tab"), command: "openNoteInNewTab", uiIcon: "bx bx-link-external" },
{ title: !isMobileSplitOpen ? t("link_context_menu.open_note_in_new_split") : t("link_context_menu.open_note_in_other_split"), command: "openNoteInNewSplit", uiIcon: "bx bx-dock-right" },
{ 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" }
];
}
function handleLinkContextMenuItem(command: string | undefined, e: ContextMenuEvent | LeafletMouseEvent, notePath: string, viewScope = {}, hoistedNoteId: string | null = null) {
function handleLinkContextMenuItem(command: string | undefined, notePath: string, viewScope = {}, hoistedNoteId: string | null = null) {
if (!hoistedNoteId) {
hoistedNoteId = appContext.tabManager.getActiveContext()?.hoistedNoteId ?? null;
}
@@ -35,8 +29,15 @@ function handleLinkContextMenuItem(command: string | undefined, e: ContextMenuEv
if (command === "openNoteInNewTab") {
appContext.tabManager.openContextWithNote(notePath, { hoistedNoteId, viewScope });
} else if (command === "openNoteInNewSplit") {
const ntxId = getNtxId(e);
if (!ntxId) return;
const subContexts = appContext.tabManager.getActiveContext()?.getSubContexts();
if (!subContexts) {
logError("subContexts is null");
return;
}
const { ntxId } = subContexts[subContexts.length - 1];
appContext.triggerCommand("openNewNoteSplit", { ntxId, notePath, hoistedNoteId, viewScope });
} else if (command === "openNoteInNewWindow") {
appContext.triggerCommand("openInWindow", { notePath, hoistedNoteId, viewScope });
@@ -45,18 +46,6 @@ function handleLinkContextMenuItem(command: string | undefined, e: ContextMenuEv
}
}
function getNtxId(e: ContextMenuEvent | LeafletMouseEvent) {
if (utils.isDesktop()) {
const subContexts = appContext.tabManager.getActiveContext()?.getSubContexts();
if (!subContexts) return null;
return subContexts[subContexts.length - 1].ntxId;
} else if (e.target instanceof HTMLElement) {
return getClosestNtxId(e.target);
} else {
return null;
}
}
export default {
getItems,
handleLinkContextMenuItem,

View File

@@ -1,4 +1,3 @@
import NoteColorPicker from "./custom-items/NoteColorPicker.jsx";
import treeService from "../services/tree.js";
import froca from "../services/froca.js";
import clipboard from "../services/clipboard.js";
@@ -140,13 +139,7 @@ export default class TreeContextMenu implements SelectMenuItemEventListener<Tree
uiIcon: "bx bx-rename",
enabled: isNotRoot && parentNotSearch && notOptionsOrHelp
},
{
title:
t("tree-context-menu.convert-to-attachment"),
command: "convertNoteToAttachment",
uiIcon: "bx bx-paperclip",
enabled: isNotRoot && !isHoisted && notOptionsOrHelp && selectedNotes.some(note => note.isEligibleForConversionToAttachment())
},
{ title: t("tree-context-menu.convert-to-attachment"), command: "convertNoteToAttachment", uiIcon: "bx bx-paperclip", enabled: isNotRoot && !isHoisted && notOptionsOrHelp },
{ kind: "separator" },
@@ -248,15 +241,6 @@ export default class TreeContextMenu implements SelectMenuItemEventListener<Tree
enabled: isNotRoot && !isHoisted && parentNotSearch && notOptionsOrHelp
},
{ kind: "separator"},
(notOptionsOrHelp && selectedNotes.length === 1) ? {
kind: "custom",
componentFn: () => {
return NoteColorPicker({note});
}
} : null,
{ kind: "separator" },
{ title: t("tree-context-menu.import-into-note"), command: "importIntoNote", uiIcon: "bx bx-import", enabled: notSearch && noSelectedNotes && notOptionsOrHelp },

View File

@@ -1,15 +1,12 @@
import FNote from "./entities/fnote";
import { render } from "preact";
import { CustomNoteList, useNoteViewType } from "./widgets/collections/NoteList";
import { CustomNoteList } from "./widgets/collections/NoteList";
import { useCallback, useLayoutEffect, useRef } from "preact/hooks";
import content_renderer from "./services/content_renderer";
import { dynamicRequire, isElectron } from "./services/utils";
import { applyInlineMermaid } from "./services/content_renderer_text";
interface RendererProps {
note: FNote;
onReady: () => void;
onProgressChanged?: (progress: number) => void;
}
async function main() {
@@ -26,21 +23,13 @@ async function main() {
function App({ note, noteId }: { note: FNote | null | undefined, noteId: string }) {
const sentReadyEvent = useRef(false);
const onProgressChanged = useCallback((progress: number) => {
if (isElectron()) {
const { ipcRenderer } = dynamicRequire('electron');
ipcRenderer.send("print-progress", progress);
} else {
window.dispatchEvent(new CustomEvent("note-load-progress", { detail: { progress } }));
}
}, []);
const onReady = useCallback(() => {
if (sentReadyEvent.current) return;
window.dispatchEvent(new Event("note-ready"));
window._noteReady = true;
sentReadyEvent.current = true;
}, []);
const props: RendererProps | undefined | null = note && { note, onReady, onProgressChanged };
const props: RendererProps | undefined | null = note && { note, onReady };
if (!note || !props) return <Error404 noteId={noteId} />
@@ -82,11 +71,6 @@ function SingleNoteRenderer({ note, onReady }: RendererProps) {
})
);
// Initialize mermaid.
if (note.type === "text") {
await applyInlineMermaid(container);
}
// Check custom CSS.
await loadCustomCss(note);
}
@@ -100,10 +84,8 @@ function SingleNoteRenderer({ note, onReady }: RendererProps) {
</>;
}
function CollectionRenderer({ note, onReady, onProgressChanged }: RendererProps) {
const viewType = useNoteViewType(note);
function CollectionRenderer({ note, onReady }: RendererProps) {
return <CustomNoteList
viewType={viewType}
isEnabled
note={note}
notePath={note.getBestNotePath().join("/")}
@@ -114,7 +96,6 @@ function CollectionRenderer({ note, onReady, onProgressChanged }: RendererProps)
await loadCustomCss(note);
onReady();
}}
onProgressChanged={onProgressChanged}
/>;
}

View File

@@ -126,7 +126,9 @@ function isAffecting(attrRow: AttributeRow, affectedNote: FNote | null | undefin
}
}
if (attrRow.isInheritable) {
// TODO: This doesn't seem right.
//@ts-ignore
if (this.isInheritable) {
for (const owningNote of owningNotes) {
if (owningNote.hasAncestor(attrNote.noteId, true)) {
return true;

View File

@@ -1,6 +1,6 @@
import utils from "./utils.js";
import server from "./server.js";
import toastService, { type ToastOptionsWithRequiredId } from "./toast.js";
import toastService, { type ToastOptions } from "./toast.js";
import froca from "./froca.js";
import hoistedNoteService from "./hoisted_note.js";
import ws from "./ws.js";
@@ -176,6 +176,11 @@ async function moveNodeUpInHierarchy(node: Fancytree.FancytreeNode) {
toastService.showError(resp.message);
return;
}
if (!hoistedNoteService.isTopLevelNode(node) && node.getParent().getChildren().length <= 1) {
node.getParent().folder = false;
node.getParent().renderTitle();
}
}
function filterSearchBranches(branchIds: string[]) {
@@ -195,11 +200,11 @@ function filterRootNote(branchIds: string[]) {
});
}
function makeToast(id: string, message: string): ToastOptionsWithRequiredId {
function makeToast(id: string, message: string): ToastOptions {
return {
id,
id: id,
title: t("branches.delete-status"),
message,
message: message,
icon: "trash"
};
}
@@ -216,7 +221,7 @@ ws.subscribeToMessages(async (message) => {
toastService.showPersistent(makeToast(message.taskId, t("branches.delete-notes-in-progress", { count: message.progressCount })));
} else if (message.type === "taskSucceeded") {
const toast = makeToast(message.taskId, t("branches.delete-finished-successfully"));
toast.timeout = 5000;
toast.closeAfter = 5000;
toastService.showPersistent(toast);
}
@@ -234,7 +239,7 @@ ws.subscribeToMessages(async (message) => {
toastService.showPersistent(makeToast(message.taskId, t("branches.undeleting-notes-in-progress", { count: message.progressCount })));
} else if (message.type === "taskSucceeded") {
const toast = makeToast(message.taskId, t("branches.undeleting-notes-finished-successfully"));
toast.timeout = 5000;
toast.closeAfter = 5000;
toastService.showPersistent(toast);
}
@@ -242,7 +247,7 @@ ws.subscribeToMessages(async (message) => {
async function cloneNoteToBranch(childNoteId: string, parentBranchId: string, prefix?: string) {
const resp = await server.put<Response>(`notes/${childNoteId}/clone-to-branch/${parentBranchId}`, {
prefix
prefix: prefix
});
if (!resp.success) {
@@ -252,7 +257,7 @@ async function cloneNoteToBranch(childNoteId: string, parentBranchId: string, pr
async function cloneNoteToParentNote(childNoteId: string, parentNoteId: string, prefix?: string) {
const resp = await server.put<Response>(`notes/${childNoteId}/clone-to-note/${parentNoteId}`, {
prefix
prefix: prefix
});
if (!resp.success) {

View File

@@ -1,44 +0,0 @@
import { describe, expect, it } from "vitest";
import { Bundle, executeBundle } from "./bundle";
import { buildNote } from "../test/easy-froca";
describe("Script bundle", () => {
it("dayjs is available", async () => {
const script = /* js */`return api.dayjs().format("YYYY-MM-DD");`;
const bundle = getBundle(script);
const result = await executeBundle(bundle, null, $());
expect(result).toMatch(/^\d{4}-\d{2}-\d{2}$/);
});
it("dayjs is-same-or-before plugin exists", async () => {
const script = /* js */`return api.dayjs("2023-10-01").isSameOrBefore(api.dayjs("2023-10-02"));`;
const bundle = getBundle(script);
const result = await executeBundle(bundle, null, $());
expect(result).toBe(true);
});
});
function getBundle(script: string) {
const id = buildNote({
title: "Script note"
}).noteId;
const bundle: Bundle = {
script: [
'',
`apiContext.modules['${id}'] = { exports: {} };`,
`return await ((async function(exports, module, require, api) {`,
`try {`,
`${script}`,
`;`,
`} catch (e) { throw new Error(\"Load of script note \\\"Client\\\" (${id}) failed with: \" + e.message); }`,
`for (const exportKey in exports) module.exports[exportKey] = exports[exportKey];`,
`return module.exports;`,
`}).call({}, {}, apiContext.modules['${id}'], apiContext.require([]), apiContext.apis['${id}']));`,
''
].join('\n'),
html: "",
noteId: id,
allNoteIds: [ id ]
};
return bundle;
}

View File

@@ -27,7 +27,7 @@ async function getAndExecuteBundle(noteId: string, originEntity = null, script =
return await executeBundle(bundle, originEntity);
}
export async function executeBundle(bundle: Bundle, originEntity?: Entity | null, $container?: JQuery<HTMLElement>) {
async function executeBundle(bundle: Bundle, originEntity?: Entity | null, $container?: JQuery<HTMLElement>) {
const apiContext = await ScriptContext(bundle.noteId, bundle.allNoteIds, originEntity, $container);
try {
@@ -36,17 +36,10 @@ export async function executeBundle(bundle: Bundle, originEntity?: Entity | null
}.call(apiContext);
} catch (e: any) {
const note = await froca.getNote(bundle.noteId);
toastService.showPersistent({
id: `custom-script-failure-${note?.noteId}`,
title: t("toast.bundle-error.title"),
icon: "bx bx-error-circle",
message: t("toast.bundle-error.message", {
id: note?.noteId,
title: note?.title,
message: e.message
})
});
logError("Widget initialization failed: ", e);
const message = `Execution of JS note "${note?.title}" with ID ${bundle.noteId} failed with error: ${e?.message}`;
showError(message);
logError(message);
}
}
@@ -109,9 +102,8 @@ async function getWidgetBundlesByParent() {
const noteId = bundle.noteId;
const note = await froca.getNote(noteId);
toastService.showPersistent({
id: `custom-script-failure-${noteId}`,
title: t("toast.bundle-error.title"),
icon: "bx bx-error-circle",
icon: "alert",
message: t("toast.bundle-error.message", {
id: noteId,
title: note?.title,

View File

@@ -2,21 +2,24 @@ import renderService from "./render.js";
import protectedSessionService from "./protected_session.js";
import protectedSessionHolder from "./protected_session_holder.js";
import openService from "./open.js";
import froca from "./froca.js";
import utils from "./utils.js";
import linkService from "./link.js";
import treeService from "./tree.js";
import FNote from "../entities/fnote.js";
import FAttachment from "../entities/fattachment.js";
import imageContextMenuService from "../menus/image_context_menu.js";
import { applySingleBlockSyntaxHighlight } from "./syntax_highlight.js";
import { applySingleBlockSyntaxHighlight, formatCodeBlocks } from "./syntax_highlight.js";
import { loadElkIfNeeded, postprocessMermaidSvg } from "./mermaid.js";
import renderDoc from "./doc_renderer.js";
import { t } from "../services/i18n.js";
import WheelZoom from 'vanilla-js-wheel-zoom';
import { renderMathInElement } from "./math.js";
import { normalizeMimeTypeForCKEditor } from "@triliumnext/commons";
import renderText from "./content_renderer_text.js";
let idCounter = 1;
export interface RenderOptions {
interface Options {
tooltip?: boolean;
trim?: boolean;
imageHasZoom?: boolean;
@@ -26,7 +29,7 @@ export interface RenderOptions {
const CODE_MIME_TYPES = new Set(["application/json"]);
export async function getRenderedContent(this: {} | { ctx: string }, entity: FNote | FAttachment, options: RenderOptions = {}) {
export async function getRenderedContent(this: {} | { ctx: string }, entity: FNote | FAttachment, options: Options = {}) {
options = Object.assign(
{
@@ -113,6 +116,32 @@ export async function getRenderedContent(this: {} | { ctx: string }, entity: FNo
};
}
async function renderText(note: FNote | FAttachment, $renderedContent: JQuery<HTMLElement>, options: Options = {}) {
// entity must be FNote
const blob = await note.getBlob();
if (blob && !utils.isHtmlEmpty(blob.content)) {
$renderedContent.append($('<div class="ck-content">').html(blob.content));
if ($renderedContent.find("span.math-tex").length > 0) {
renderMathInElement($renderedContent[0], { trust: true });
}
const getNoteIdFromLink = (el: HTMLElement) => treeService.getNoteIdFromUrl($(el).attr("href") || "");
const referenceLinks = $renderedContent.find("a.reference-link");
const noteIdsToPrefetch = referenceLinks.map((i, el) => getNoteIdFromLink(el));
await froca.getNotes(noteIdsToPrefetch);
for (const el of referenceLinks) {
await linkService.loadReferenceLinkTitle($(el));
}
await formatCodeBlocks($renderedContent);
} else if (note instanceof FNote && !options.noChildrenList) {
await renderChildrenList($renderedContent, note);
}
}
/**
* Renders a code note, by displaying its content and applying syntax highlighting based on the selected MIME type.
*/
@@ -134,7 +163,7 @@ async function renderCode(note: FNote | FAttachment, $renderedContent: JQuery<HT
await applySingleBlockSyntaxHighlight($codeBlock, normalizeMimeTypeForCKEditor(note.mime));
}
function renderImage(entity: FNote | FAttachment, $renderedContent: JQuery<HTMLElement>, options: RenderOptions = {}) {
function renderImage(entity: FNote | FAttachment, $renderedContent: JQuery<HTMLElement>, options: Options = {}) {
const encodedTitle = encodeURIComponent(entity.title);
let url;
@@ -276,6 +305,40 @@ async function renderMermaid(note: FNote | FAttachment, $renderedContent: JQuery
}
}
/**
* @param {jQuery} $renderedContent
* @param {FNote} note
* @returns {Promise<void>}
*/
async function renderChildrenList($renderedContent: JQuery<HTMLElement>, note: FNote) {
let childNoteIds = note.getChildNoteIds();
if (!childNoteIds.length) {
return;
}
$renderedContent.css("padding", "10px");
$renderedContent.addClass("text-with-ellipsis");
if (childNoteIds.length > 10) {
childNoteIds = childNoteIds.slice(0, 10);
}
// just load the first 10 child notes
const childNotes = await froca.getNotes(childNoteIds);
for (const childNote of childNotes) {
$renderedContent.append(
await linkService.createLink(`${note.noteId}/${childNote.noteId}`, {
showTooltip: false,
showNoteIcon: true
})
);
$renderedContent.append("<br>");
}
}
function getRenderingType(entity: FNote | FAttachment) {
let type: string = "";
if ("type" in entity) {

View File

@@ -1,126 +0,0 @@
import { formatCodeBlocks } from "./syntax_highlight.js";
import { getMermaidConfig } from "./mermaid.js";
import { renderMathInElement } from "./math.js";
import FNote from "../entities/fnote.js";
import FAttachment from "../entities/fattachment.js";
import tree from "./tree.js";
import froca from "./froca.js";
import link from "./link.js";
import { isHtmlEmpty } from "./utils.js";
import { default as content_renderer, type RenderOptions } from "./content_renderer.js";
export default async function renderText(note: FNote | FAttachment, $renderedContent: JQuery<HTMLElement>, options: RenderOptions = {}) {
// entity must be FNote
const blob = await note.getBlob();
if (blob && !isHtmlEmpty(blob.content)) {
$renderedContent.append($('<div class="ck-content">').html(blob.content));
await renderIncludedNotes($renderedContent[0]);
if ($renderedContent.find("span.math-tex").length > 0) {
renderMathInElement($renderedContent[0], { trust: true });
}
const getNoteIdFromLink = (el: HTMLElement) => tree.getNoteIdFromUrl($(el).attr("href") || "");
const referenceLinks = $renderedContent.find("a.reference-link");
const noteIdsToPrefetch = referenceLinks.map((i, el) => getNoteIdFromLink(el));
await froca.getNotes(noteIdsToPrefetch);
for (const el of referenceLinks) {
await link.loadReferenceLinkTitle($(el));
}
await rewriteMermaidDiagramsInContainer($renderedContent[0] as HTMLDivElement);
await formatCodeBlocks($renderedContent);
} else if (note instanceof FNote && !options.noChildrenList) {
await renderChildrenList($renderedContent, note);
}
}
async function renderIncludedNotes(contentEl: HTMLElement) {
// TODO: Consider duplicating with server's share/content_renderer.ts.
const includeNoteEls = contentEl.querySelectorAll("section.include-note");
// Gather the list of items to load.
const noteIds: string[] = [];
for (const includeNoteEl of includeNoteEls) {
const noteId = includeNoteEl.getAttribute("data-note-id");
if (noteId) {
noteIds.push(noteId);
}
}
// Load the required notes.
await froca.getNotes(noteIds);
// Render and integrate the notes.
for (const includeNoteEl of includeNoteEls) {
const noteId = includeNoteEl.getAttribute("data-note-id");
if (!noteId) continue;
const note = froca.getNoteFromCache(noteId);
if (!note) {
console.warn(`Unable to include ${noteId} because it could not be found.`);
continue;
}
const renderedContent = (await content_renderer.getRenderedContent(note)).$renderedContent;
includeNoteEl.replaceChildren(...renderedContent);
}
}
/** Rewrite the code block from <pre><code> to <div> in order not to apply a codeblock style to it. */
export async function rewriteMermaidDiagramsInContainer(container: HTMLDivElement) {
const mermaidBlocks = container.querySelectorAll('pre:has(code[class="language-mermaid"])');
if (!mermaidBlocks.length) return;
const nodes: HTMLElement[] = [];
for (const mermaidBlock of mermaidBlocks) {
const div = document.createElement("div");
div.classList.add("mermaid-diagram");
div.innerHTML = mermaidBlock.querySelector("code")?.innerHTML ?? "";
mermaidBlock.replaceWith(div);
nodes.push(div);
}
}
export async function applyInlineMermaid(container: HTMLDivElement) {
// Initialize mermaid
const mermaid = (await import("mermaid")).default;
mermaid.initialize(getMermaidConfig());
const nodes = Array.from(container.querySelectorAll<HTMLElement>("div.mermaid-diagram"));
try {
await mermaid.run({ nodes });
} catch (e) {
console.log(e);
}
}
async function renderChildrenList($renderedContent: JQuery<HTMLElement>, note: FNote) {
let childNoteIds = note.getChildNoteIds();
if (!childNoteIds.length) {
return;
}
$renderedContent.css("padding", "10px");
$renderedContent.addClass("text-with-ellipsis");
if (childNoteIds.length > 10) {
childNoteIds = childNoteIds.slice(0, 10);
}
// just load the first 10 child notes
const childNotes = await froca.getNotes(childNoteIds);
for (const childNote of childNotes) {
$renderedContent.append(
await link.createLink(`${note.noteId}/${childNote.noteId}`, {
showTooltip: false,
showNoteIcon: true
})
);
$renderedContent.append("<br>");
}
}

View File

@@ -1,9 +1,7 @@
import clsx from "clsx";
import {readCssVar} from "../utils/css-var";
import Color, { ColorInstance } from "color";
const registeredClasses = new Set<string>();
const colorsWithHue = new Set<string>();
// Read the color lightness limits defined in the theme as CSS variables
@@ -28,24 +26,19 @@ function createClassForColor(colorString: string | null) {
if (!registeredClasses.has(className)) {
const adjustedColor = adjustColorLightness(color, lightThemeColorMaxLightness!,
darkThemeColorMinLightness!);
const hue = getHue(color);
$("head").append(`<style>
.${className}, span.fancytree-active.${className} {
--original-custom-color: ${color.hex()};
--light-theme-custom-color: ${adjustedColor.lightThemeColor};
--dark-theme-custom-color: ${adjustedColor.darkThemeColor};
--custom-color-hue: ${hue ?? 'unset'};
--custom-color-hue: ${getHue(color) ?? 'unset'};
}
</style>`);
registeredClasses.add(className);
if (hue !== undefined) {
colorsWithHue.add(className);
}
}
return clsx("use-note-color", className, colorsWithHue.has(className) && "with-hue");
return className;
}
function parseColor(color: string) {

View File

@@ -1,4 +1,4 @@
import { dayjs } from "@triliumnext/commons";
import dayjs from "dayjs";
import type { FNoteRow } from "../entities/fnote.js";
import froca from "./froca.js";
import server from "./server.js";

View File

@@ -12,7 +12,7 @@
* @param whether to execute at the beginning (`false`)
* @api public
*/
function debounce<T>(func: (...args: any[]) => T, waitMs: number, immediate: boolean = false) {
function debounce<T>(func: (...args: unknown[]) => T, waitMs: number, immediate: boolean = false) {
let timeout: any; // TODO: fix once we split client and server.
let args: unknown[] | null;
let context: unknown;

View File

@@ -1,9 +1,8 @@
import { Modal } from "bootstrap";
import appContext from "../components/app_context.js";
import type { ConfirmDialogOptions, ConfirmDialogResult, ConfirmWithMessageOptions, MessageType } from "../widgets/dialogs/confirm.js";
import type { ConfirmDialogOptions, ConfirmDialogResult, ConfirmWithMessageOptions } from "../widgets/dialogs/confirm.js";
import type { PromptDialogOptions } from "../widgets/dialogs/prompt.js";
import { focusSavedElement, saveFocusedElement } from "./focus.js";
import { InfoExtraProps } from "../widgets/dialogs/info.jsx";
export async function openDialog($dialog: JQuery<HTMLElement>, closeActDialog = true, config?: Partial<Modal.Options>) {
if (closeActDialog) {
@@ -38,8 +37,8 @@ export function closeActiveDialog() {
}
}
async function info(message: MessageType, extraProps?: InfoExtraProps) {
return new Promise((res) => appContext.triggerCommand("showInfoDialog", { ...extraProps, message, callback: res }));
async function info(message: string) {
return new Promise((res) => appContext.triggerCommand("showInfoDialog", { message, callback: res }));
}
/**

View File

@@ -13,7 +13,7 @@ export interface Froca {
getBlob(entityType: string, entityId: string): Promise<FBlob | null>;
getNote(noteId: string, silentNotFoundError?: boolean): Promise<FNote | null>;
getNoteFromCache(noteId: string): FNote | undefined;
getNoteFromCache(noteId: string): FNote;
getNotesFromCache(noteIds: string[], silentNotFoundError?: boolean): FNote[];
getNotes(noteIds: string[], silentNotFoundError?: boolean): Promise<FNote[]>;

View File

@@ -288,7 +288,7 @@ class FrocaImpl implements Froca {
return (await this.getNotes([noteId], silentNotFoundError))[0];
}
getNoteFromCache(noteId: string): FNote | undefined {
getNoteFromCache(noteId: string) {
if (!noteId) {
throw new Error("Empty noteId");
}

View File

@@ -17,7 +17,7 @@ import shortcutService from "./shortcuts.js";
import dialogService from "./dialog.js";
import type FNote from "../entities/fnote.js";
import { t } from "./i18n.js";
import { dayjs } from "@triliumnext/commons";
import dayjs from "dayjs";
import type NoteContext from "../components/note_context.js";
import type Component from "../components/component.js";
import { formatLogMessage } from "@triliumnext/commons";
@@ -77,10 +77,6 @@ export interface Api {
/**
* Entity whose event triggered this execution.
*
* <p>
* For front-end scripts, generally there's no origin entity specified since the scripts are run by the user or automatically by the UI (widgets).
* If there is an origin entity specified, then it's going to be a note entity.
*/
originEntity: unknown | null;
@@ -282,16 +278,12 @@ export interface Api {
getActiveContextNote(): FNote;
/**
* Obtains the currently active/focused split in the current tab.
*
* Note that this method does not return the note context of the "Quick edit" panel, it will return the note context behind it.
* @returns returns active context (split)
*/
getActiveContext(): NoteContext;
/**
* Obtains the main context of the current tab. This is the left-most split.
*
* Note that this method does not return the note context of the "Quick edit" panel, it will return the note context behind it.
* @returns returns active main context (first split in a tab, represents the tab as a whole)
*/
getActiveMainContext(): NoteContext;

View File

@@ -2,7 +2,7 @@ import options from "./options.js";
import i18next from "i18next";
import i18nextHttpBackend from "i18next-http-backend";
import server from "./server.js";
import { LOCALE_IDS, setDayjsLocale, type Locale } from "@triliumnext/commons";
import type { Locale } from "@triliumnext/commons";
import { initReactI18next } from "react-i18next";
let locales: Locale[] | null;
@@ -13,7 +13,7 @@ let locales: Locale[] | null;
export let translationsInitializedPromise = $.Deferred();
export async function initLocale() {
const locale = ((options.get("locale") as string) || "en") as LOCALE_IDS;
const locale = (options.get("locale") as string) || "en";
locales = await server.get<Locale[]>("options/locales");
@@ -27,7 +27,6 @@ export async function initLocale() {
returnEmptyString: false
});
await setDayjsLocale(locale);
translationsInitializedPromise.resolve();
}

View File

@@ -1,4 +1,4 @@
import toastService, { type ToastOptionsWithRequiredId } from "./toast.js";
import toastService, { type ToastOptions } from "./toast.js";
import server from "./server.js";
import ws from "./ws.js";
import utils from "./utils.js";
@@ -57,11 +57,11 @@ export async function uploadFiles(entityType: string, parentNoteId: string, file
}
}
function makeToast(id: string, message: string): ToastOptionsWithRequiredId {
function makeToast(id: string, message: string): ToastOptions {
return {
id,
id: id,
title: t("import.import-status"),
message,
message: message,
icon: "plus"
};
}
@@ -78,7 +78,7 @@ ws.subscribeToMessages(async (message) => {
toastService.showPersistent(makeToast(message.taskId, t("import.in-progress", { progress: message.progressCount })));
} else if (message.type === "taskSucceeded") {
const toast = makeToast(message.taskId, t("import.successful"));
toast.timeout = 5000;
toast.closeAfter = 5000;
toastService.showPersistent(toast);
@@ -100,7 +100,7 @@ ws.subscribeToMessages(async (message: WebSocketMessage) => {
toastService.showPersistent(makeToast(message.taskId, t("import.in-progress", { progress: message.progressCount })));
} else if (message.type === "taskSucceeded") {
const toast = makeToast(message.taskId, t("import.successful"));
toast.timeout = 5000;
toast.closeAfter = 5000;
toastService.showPersistent(toast);

View File

@@ -28,7 +28,7 @@ async function getActionsForScope(scope: string) {
return actions.filter((action) => action.scope === scope);
}
async function setupActionsForElement(scope: string, $el: JQuery<HTMLElement>, component: Component, ntxId: string | null | undefined) {
async function setupActionsForElement(scope: string, $el: JQuery<HTMLElement>, component: Component) {
if (!$el[0]) return [];
const actions = await getActionsForScope(scope);
@@ -36,9 +36,7 @@ async function setupActionsForElement(scope: string, $el: JQuery<HTMLElement>, c
for (const action of actions) {
for (const shortcut of action.effectiveShortcuts ?? []) {
const binding = shortcutService.bindElShortcut($el, shortcut, () => {
component.triggerCommand(action.actionName, { ntxId });
});
const binding = shortcutService.bindElShortcut($el, shortcut, () => component.triggerCommand(action.actionName, { ntxId: appContext.tabManager.activeNtxId }));
if (binding) {
bindings.push(binding);
}

View File

@@ -467,30 +467,28 @@ function getReferenceLinkTitleSync(href: string) {
}
}
if (glob.device !== "print") {
// TODO: Check why the event is not supported.
//@ts-ignore
$(document).on("click", "a", goToLink);
// TODO: Check why the event is not supported.
//@ts-ignore
$(document).on("auxclick", "a", goToLink); // to handle the middle button
// TODO: Check why the event is not supported.
//@ts-ignore
$(document).on("contextmenu", "a", linkContextMenu);
// TODO: Check why the event is not supported.
//@ts-ignore
$(document).on("dblclick", "a", goToLink);
// TODO: Check why the event is not supported.
//@ts-ignore
$(document).on("click", "a", goToLink);
// TODO: Check why the event is not supported.
//@ts-ignore
$(document).on("auxclick", "a", goToLink); // to handle the middle button
// TODO: Check why the event is not supported.
//@ts-ignore
$(document).on("contextmenu", "a", linkContextMenu);
// TODO: Check why the event is not supported.
//@ts-ignore
$(document).on("dblclick", "a", goToLink);
$(document).on("mousedown", "a", (e) => {
if (e.which === 2) {
// prevent paste on middle click
// https://github.com/zadam/trilium/issues/2995
// https://developer.mozilla.org/en-US/docs/Web/API/Element/auxclick_event#preventing_default_actions
e.preventDefault();
return false;
}
});
}
$(document).on("mousedown", "a", (e) => {
if (e.which === 2) {
// prevent paste on middle click
// https://github.com/zadam/trilium/issues/2995
// https://developer.mozilla.org/en-US/docs/Web/API/Element/auxclick_event#preventing_default_actions
e.preventDefault();
return false;
}
});
export default {
getNotePathFromUrl,

View File

@@ -41,17 +41,6 @@ function parse(value: string) {
return defObj;
}
/**
* For an attribute definition name (e.g. `label:TEST:TEST1`), extracts its type (label) and name (TEST:TEST1).
* @param definitionAttrName the attribute definition name, without the leading `#` (e.g. `label:TEST:TEST1`)
* @return a tuple of [type, name].
*/
export function extractAttributeDefinitionTypeAndName(definitionAttrName: string): [ "label" | "relation", string ] {
const valueType = definitionAttrName.startsWith("label:") ? "label" : "relation";
const valueName = definitionAttrName.substring(valueType.length + 1);
return [ valueType, valueName ];
}
export default {
parse
};

View File

@@ -1,7 +1,7 @@
import server from "./server.js";
import protectedSessionHolder from "./protected_session_holder.js";
import toastService from "./toast.js";
import type { ToastOptionsWithRequiredId } from "./toast.js";
import type { ToastOptions } from "./toast.js";
import ws from "./ws.js";
import appContext from "../components/app_context.js";
import froca from "./froca.js";
@@ -97,7 +97,7 @@ async function protectNote(noteId: string, protect: boolean, includingSubtree: b
await server.put(`notes/${noteId}/protect/${protect ? 1 : 0}?subtree=${includingSubtree ? 1 : 0}`);
}
function makeToast(message: Message, title: string, text: string): ToastOptionsWithRequiredId {
function makeToast(message: Message, title: string, text: string): ToastOptions {
return {
id: message.taskId,
title,
@@ -124,7 +124,7 @@ ws.subscribeToMessages(async (message) => {
} else if (message.type === "taskSucceeded") {
const text = isProtecting ? t("protected_session.protecting-finished-successfully") : t("protected_session.unprotecting-finished-successfully");
const toast = makeToast(message, title, text);
toast.timeout = 3000;
toast.closeAfter = 3000;
toastService.showPersistent(toast);
}

View File

@@ -263,7 +263,7 @@ async function reportError(method: string, url: string, statusCode: number, resp
const toastService = (await import("./toast.js")).default;
const messageStr = (typeof message === "string" ? message : JSON.stringify(message)) || "-";
const messageStr = typeof message === "string" ? message : JSON.stringify(message);
if ([400, 404].includes(statusCode) && response && typeof response === "object") {
toastService.showError(messageStr);
@@ -274,22 +274,10 @@ async function reportError(method: string, url: string, statusCode: number, resp
...response
});
} else {
const { t } = await import("./i18n.js");
if (statusCode === 400 && (url.includes("%23") || url.includes("%2F"))) {
toastService.showPersistent({
id: "trafik-blocked",
icon: "bx bx-unlink",
title: t("server.unknown_http_error_title"),
message: t("server.traefik_blocks_requests")
});
} else {
toastService.showErrorTitleAndMessage(
t("server.unknown_http_error_title"),
t("server.unknown_http_error_content", { statusCode, method, url, message: messageStr }),
15_000);
}
const title = `${statusCode} ${method} ${url}`;
toastService.showErrorTitleAndMessage(title, messageStr);
const { throwError } = await import("./ws.js");
throwError(`${statusCode} ${method} ${url} - ${message}`);
throwError(`${title} - ${message}`);
}
}

View File

@@ -1,7 +1,7 @@
import utils from "./utils.js";
type ElementType = HTMLElement | Document;
export type Handler = (e: KeyboardEvent) => void;
type Handler = (e: KeyboardEvent) => void;
export interface ShortcutBinding {
element: HTMLElement | Document;

View File

@@ -1,5 +1,3 @@
import { signal } from "@preact/signals";
import utils from "./utils.js";
export interface ToastOptions {
@@ -7,84 +5,112 @@ export interface ToastOptions {
icon: string;
title?: string;
message: string;
timeout?: number;
progress?: number;
buttons?: {
text: string;
onClick: (api: { dismissToast: () => void }) => void;
}[];
delay?: number;
autohide?: boolean;
closeAfter?: number;
}
export type ToastOptionsWithRequiredId = Omit<ToastOptions, "id"> & Required<Pick<ToastOptions, "id">>;
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">
<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>`
);
function showPersistent(options: ToastOptionsWithRequiredId) {
const existingToast = toasts.value.find(toast => toast.id === options.id);
if (existingToast) {
updateToast(options.id, options);
$toast.toggleClass("no-title", !options.title);
$toast.find(".toast-title").text(options.title ?? "");
$toast.find(".toast-body").html(options.message);
if (options.id) {
$toast.attr("id", `toast-${options.id}`);
}
$("#toast-container").append($toast);
$toast.toast({
delay: options.delay || 3000,
autohide: !!options.autohide
});
$toast.on("hidden.bs.toast", (e) => e.target.remove());
$toast.toast("show");
return $toast;
}
function showPersistent(options: ToastOptions) {
let $toast = $(`#toast-${options.id}`);
if ($toast.length > 0) {
$toast.find(".toast-body").html(options.message);
} else {
addToast(options);
options.autohide = false;
$toast = toast(options);
}
if (options.closeAfter) {
setTimeout(() => $toast.remove(), options.closeAfter);
}
}
function closePersistent(id: string) {
removeToastFromStore(id);
$(`#toast-${id}`).remove();
}
function showMessage(message: string, timeout = 2000, icon = "bx bx-check") {
function showMessage(message: string, delay = 2000, icon = "check") {
console.debug(utils.now(), "message:", message);
addToast({
toast({
icon,
message,
timeout
message: message,
autohide: true,
delay
});
}
export function showError(message: string, timeout = 10000) {
export function showError(message: string, delay = 10000) {
console.log(utils.now(), "error: ", message);
addToast({
icon: "bx bx-error-circle",
message,
timeout
toast({
icon: "alert",
message: message,
autohide: true,
delay
});
}
function showErrorTitleAndMessage(title: string, message: string, timeout = 10000) {
function showErrorTitleAndMessage(title: string, message: string, delay = 10000) {
console.log(utils.now(), "error: ", message);
addToast({
title,
icon: "bx bx-error-circle",
message,
timeout
toast({
title: title,
icon: "alert",
message: message,
autohide: true,
delay
});
}
//#region Toast store
export const toasts = signal<ToastOptionsWithRequiredId[]>([]);
function addToast(opts: ToastOptions) {
const id = opts.id ?? crypto.randomUUID();
const toast = { ...opts, id };
toasts.value = [ ...toasts.value, toast ];
return id;
}
function updateToast(id: string, partial: Partial<ToastOptions>) {
toasts.value = toasts.value.map(toast => {
if (toast.id === id) {
return { ...toast, ...partial }
}
return toast;
});
}
export function removeToastFromStore(id: string) {
toasts.value = toasts.value.filter(toast => toast.id !== id);
}
//#endregion
export default {
showMessage,
showError,

View File

@@ -89,7 +89,7 @@ async function resolveNotePathToSegments(notePath: string, hoistedNoteId = "root
effectivePathSegments.reverse();
if (effectivePathSegments.includes(hoistedNoteId) && effectivePathSegments.includes('root')) {
if (effectivePathSegments.includes(hoistedNoteId)) {
return effectivePathSegments;
} else {
const noteId = getNoteIdFromUrl(notePath);

View File

@@ -1,7 +1,6 @@
import { dayjs } from "@triliumnext/commons";
import dayjs from "dayjs";
import type { ViewScope } from "./link.js";
import FNote from "../entities/fnote";
import { snapdom } from "@zumer/snapdom";
const SVG_MIME = "image/svg+xml";
@@ -151,7 +150,7 @@ export function isMac() {
export const hasTouchBar = (isMac() && isElectron());
export function isCtrlKey(evt: KeyboardEvent | MouseEvent | JQuery.ClickEvent | JQuery.ContextMenuEvent | JQuery.TriggeredEvent | React.PointerEvent<HTMLCanvasElement> | JQueryEventObject) {
function isCtrlKey(evt: KeyboardEvent | MouseEvent | JQuery.ClickEvent | JQuery.ContextMenuEvent | JQuery.TriggeredEvent | React.PointerEvent<HTMLCanvasElement> | JQueryEventObject) {
return (!isMac() && evt.ctrlKey) || (isMac() && evt.metaKey);
}
@@ -208,7 +207,7 @@ function toObject<T, R>(array: T[], fn: (arg0: T) => [key: string, value: R]) {
return obj;
}
export function randomString(len: number) {
function randomString(len: number) {
let text = "";
const possible = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789";
@@ -237,7 +236,7 @@ export function isIOS() {
return /iPad|iPhone|iPod/.test(navigator.userAgent);
}
export function isDesktop() {
function isDesktop() {
return (
window.glob?.device === "desktop" ||
// window.glob.device is not available in setup
@@ -275,7 +274,7 @@ function getMimeTypeClass(mime: string) {
return `mime-${mime.toLowerCase().replace(/[\W_]+/g, "-")}`;
}
export function isHtmlEmpty(html: string) {
function isHtmlEmpty(html: string) {
if (!html) {
return true;
} else if (typeof html !== "string") {
@@ -629,69 +628,16 @@ export function createImageSrcUrl(note: FNote) {
return `api/images/${note.noteId}/${encodeURIComponent(note.title)}?timestamp=${Date.now()}`;
}
/**
* Helper function to prepare an element for snapdom rendering.
* Handles string parsing and temporary DOM attachment for style computation.
*
* @param source - Either an SVG/HTML string to be parsed, or an existing SVG/HTML element.
* @returns An object containing the prepared element and a cleanup function.
* The cleanup function removes temporarily attached elements from the DOM,
* or is a no-op if the element was already in the DOM.
*/
function prepareElementForSnapdom(source: string | SVGElement | HTMLElement): {
element: SVGElement | HTMLElement;
cleanup: () => void;
} {
if (typeof source === 'string') {
const parser = new DOMParser();
// Detect if content is SVG or HTML
const isSvg = source.trim().startsWith('<svg');
const mimeType = isSvg ? SVG_MIME : 'text/html';
const doc = parser.parseFromString(source, mimeType);
const element = doc.documentElement;
// Temporarily attach to DOM for proper style computation
element.style.position = 'absolute';
element.style.left = '-9999px';
element.style.top = '-9999px';
document.body.appendChild(element);
return {
element,
cleanup: () => document.body.removeChild(element)
};
}
return {
element: source,
cleanup: () => {} // No-op for existing elements
};
}
/**
* Downloads an SVG using snapdom for proper rendering. Can accept either an SVG string, an SVG element, or an HTML element.
* Given a string representation of an SVG, triggers a download of the file on the client device.
*
* @param nameWithoutExtension the name of the file. The .svg suffix is automatically added to it.
* @param svgSource either an SVG string, an SVGElement, or an HTMLElement to be downloaded.
* @param svgContent the content of the SVG file download.
*/
async function downloadAsSvg(nameWithoutExtension: string, svgSource: string | SVGElement | HTMLElement) {
const { element, cleanup } = prepareElementForSnapdom(svgSource);
try {
const result = await snapdom(element, {
backgroundColor: "transparent",
scale: 2
});
triggerDownload(`${nameWithoutExtension}.svg`, result.url);
} finally {
cleanup();
}
function downloadSvg(nameWithoutExtension: string, svgContent: string) {
const filename = `${nameWithoutExtension}.svg`;
const dataUrl = `data:image/svg+xml;charset=utf-8,${encodeURIComponent(svgContent)}`;
triggerDownload(filename, dataUrl);
}
/**
@@ -712,26 +658,62 @@ function triggerDownload(fileName: string, dataUrl: string) {
document.body.removeChild(element);
}
/**
* Downloads an SVG as PNG using snapdom. Can accept either an SVG string, an SVG element, or an HTML element.
* Given a string representation of an SVG, renders the SVG to PNG and triggers a download of the file on the client device.
*
* Note that the SVG must specify its width and height as attributes in order for it to be rendered.
*
* @param nameWithoutExtension the name of the file. The .png suffix is automatically added to it.
* @param svgSource either an SVG string, an SVGElement, or an HTMLElement to be converted to PNG.
* @param svgContent the content of the SVG file download.
* @returns a promise which resolves if the operation was successful, or rejects if it failed (permissions issue or some other issue).
*/
async function downloadAsPng(nameWithoutExtension: string, svgSource: string | SVGElement | HTMLElement) {
const { element, cleanup } = prepareElementForSnapdom(svgSource);
function downloadSvgAsPng(nameWithoutExtension: string, svgContent: string) {
return new Promise<void>((resolve, reject) => {
// First, we need to determine the width and the height from the input SVG.
const result = getSizeFromSvg(svgContent);
if (!result) {
reject();
return;
}
try {
const result = await snapdom(element, {
backgroundColor: "transparent",
scale: 2
});
const pngImg = await result.toPng();
await triggerDownload(`${nameWithoutExtension}.png`, pngImg.src);
} finally {
cleanup();
}
// Convert the image to a blob.
const { width, height } = result;
// Create an image element and load the SVG.
const imageEl = new Image();
imageEl.width = width;
imageEl.height = height;
imageEl.crossOrigin = "anonymous";
imageEl.onload = () => {
try {
// Draw the image with a canvas.
const canvasEl = document.createElement("canvas");
canvasEl.width = imageEl.width;
canvasEl.height = imageEl.height;
document.body.appendChild(canvasEl);
const ctx = canvasEl.getContext("2d");
if (!ctx) {
reject();
}
ctx?.drawImage(imageEl, 0, 0);
const imgUri = canvasEl.toDataURL("image/png")
triggerDownload(`${nameWithoutExtension}.png`, imgUri);
document.body.removeChild(canvasEl);
resolve();
} catch (e) {
console.warn(e);
reject();
}
};
imageEl.onerror = (e) => reject(e);
imageEl.src = `data:image/svg+xml;charset=utf-8,${encodeURIComponent(svgContent)}`;
});
}
export function getSizeFromSvg(svgContent: string) {
const svgDocument = (new DOMParser()).parseFromString(svgContent, SVG_MIME);
@@ -943,8 +925,8 @@ export default {
areObjectsEqual,
copyHtmlToClipboard,
createImageSrcUrl,
downloadAsSvg,
downloadAsPng,
downloadSvg,
downloadSvgAsPng,
compareVersions,
isUpdateAvailable,
isLaunchBarConfig

View File

@@ -4,12 +4,9 @@
box-sizing: border-box;
}
.dropdown-menu:not(.static).calendar-dropdown-menu {
padding: 0 !important;
}
.calendar-dropdown-widget {
margin: 0 auto;
overflow: hidden;
width: 100%;
}
@@ -173,13 +170,4 @@
background-color: var(--hover-item-background-color);
color: var(--hover-item-text-color);
text-decoration: underline;
}
.calendar-dropdown-widget .form-control {
padding: 0;
}
.calendar-dropdown-widget .calendar-month-selector .dropdown-menu {
left: 50%;
transform: translateX(-50%);
}

View File

@@ -25,11 +25,7 @@
--bs-body-font-weight: var(--main-font-weight) !important;
--bs-body-color: var(--main-text-color) !important;
--bs-body-bg: var(--main-background-color) !important;
--ck-mention-list-max-height: 500px;
--tn-modal-max-height: 90vh;
--tree-item-light-theme-max-color-lightness: 50;
--tree-item-dark-theme-min-color-lightness: 75;
--ck-mention-list-max-height: 500px;
}
body#trilium-app.motion-disabled *,
@@ -216,16 +212,6 @@ input::placeholder,
background-color: var(--modal-backdrop-color) !important;
}
body.mobile .modal .modal-dialog {
left: 50%;
transform: translateX(-50%);
width: 100%;
}
body.mobile .modal .modal-content {
border-radius: var(--bs-modal-border-radius) var(--bs-modal-border-radius) 0 0;
}
.component {
contain: size;
}
@@ -257,11 +243,6 @@ button.close:hover {
color: var(--hover-item-text-color);
}
button.custom-title-bar-button {
background: transparent;
border: unset;
}
.modal-content {
background-color: var(--modal-background-color) !important;
}
@@ -458,8 +439,7 @@ body.desktop .tabulator-popup-container,
}
body.desktop .dropdown-menu:not(#context-menu-container) .dropdown-item,
body #context-menu-container .dropdown-item > span,
body.mobile .dropdown .dropdown-submenu > span {
body #context-menu-container .dropdown-item > span {
display: flex;
align-items: center;
}
@@ -514,10 +494,6 @@ body.mobile .dropdown .dropdown-submenu > span {
width: 100%;
}
.dropdown-menu .note-color-picker {
padding: 4px 12px 8px 12px;
}
.cm-editor {
height: 100%;
outline: none !important;
@@ -600,6 +576,11 @@ button.btn-sm {
color: var(--left-pane-text-color);
}
.btn.active:not(.btn-primary) {
background-color: var(--button-disabled-background-color) !important;
opacity: 0.4;
}
.ck.ck-block-toolbar-button {
transform: translateX(7px);
color: var(--muted-text-color);
@@ -720,6 +701,11 @@ table.promoted-attributes-in-tooltip th {
z-index: 32767 !important;
}
.tooltip-trigger {
background: transparent;
pointer-events: none;
}
.bs-tooltip-bottom .tooltip-arrow::before {
border-bottom-color: var(--main-border-color) !important;
}
@@ -1015,7 +1001,7 @@ div[data-notify="container"] {
font-family: var(--monospace-font-family);
}
svg.ck-icon.note-icon {
svg.ck-icon .note-icon {
color: var(--main-text-color);
font-size: 20px;
}
@@ -1126,6 +1112,10 @@ a.external:not(.no-arrow):after, a[href^="http://"]:not(.no-arrow):after, a[href
display: inline-block;
}
.note-detail-empty {
margin: 50px;
}
.modal-header {
padding: 0.5rem 1rem 0.5rem 1rem !important; /* make modal header padding slightly smaller */
}
@@ -1135,6 +1125,50 @@ a.external:not(.no-arrow):after, a[href^="http://"]:not(.no-arrow):after, a[href
margin: 0 12px;
}
#toast-container {
position: absolute;
width: 100%;
top: 20px;
pointer-events: none;
}
.toast {
--bs-toast-bg: var(--accented-background-color);
--bs-toast-color: var(--main-text-color);
z-index: 9999999999 !important;
pointer-events: all;
}
.toast-header {
background-color: var(--more-accented-background-color) !important;
color: var(--main-text-color) !important;
}
.toast-body {
white-space: preserve-breaks;
overflow: hidden;
}
.toast.no-title {
display: flex;
flex-direction: row;
}
.toast.no-title .toast-icon {
display: flex;
align-items: center;
padding: var(--bs-toast-padding-y) var(--bs-toast-padding-x);
}
.toast.no-title .toast-body {
padding-inline-start: 0;
padding-inline-end: 0;
}
.toast.no-title .toast-header {
background-color: unset !important;
}
.ck-mentions .ck-button {
font-size: var(--detail-font-size) !important;
padding: 5px;
@@ -1266,11 +1300,11 @@ body.mobile #context-menu-container.mobile-bottom-menu {
inset-inline-end: 0 !important;
bottom: 0 !important;
top: unset !important;
max-height: var(--tn-modal-max-height);
max-height: 70vh;
overflow: auto !important;
user-select: none;
-webkit-user-select: none;
padding-bottom: max(env(safe-area-inset-bottom), var(--padding, var(--menu-padding-size))) !important;
padding-bottom: env(safe-area-inset-bottom) !important;
}
body.mobile .dropdown-menu {
@@ -1329,20 +1363,6 @@ body:not(.mobile) #launcher-pane.horizontal .dropdown-submenu > .dropdown-menu {
flex-shrink: 0;
}
.right-dropdown-widget .right-dropdown-button {
position: relative;
}
.tooltip-trigger {
background: transparent;
pointer-events: none;
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
}
#launcher-pane.horizontal .right-dropdown-widget {
width: 53px;
}
@@ -1518,15 +1538,12 @@ body:not(.mobile) #launcher-pane.horizontal .dropdown-submenu > .dropdown-menu {
@media (max-width: 991px) {
body.mobile #launcher-pane .dropdown.global-menu > .dropdown-menu.show,
body.mobile #launcher-container .dropdown > .dropdown-menu.show {
--dropdown-bottom: calc(var(--mobile-bottom-offset) + var(--launcher-pane-size));
position: fixed !important;
bottom: var(--dropdown-bottom) !important;
bottom: calc(var(--mobile-bottom-offset) + var(--launcher-pane-size)) !important;
top: unset !important;
inset-inline-start: 0 !important;
inset-inline-end: 0 !important;
transform: unset !important;
overflow-y: auto;
max-height: calc(var(--tn-modal-max-height) - var(--dropdown-bottom));
}
#mobile-sidebar-container {
@@ -1561,14 +1578,6 @@ body:not(.mobile) #launcher-pane.horizontal .dropdown-submenu > .dropdown-menu {
width: 100%;
}
.note-split.empty-note {
--max-content-width: var(--preferred-max-content-width);
}
.note-detail-empty {
margin: 15px;
}
#mobile-sidebar-container.show #mobile-sidebar-wrapper {
transform: translateX(0);
}
@@ -1631,6 +1640,46 @@ body:not(.mobile) #launcher-pane.horizontal .dropdown-submenu > .dropdown-menu {
body.mobile .modal-dialog.modal-dialog-scrollable {
height: unset;
}
body.mobile .revisions-dialog .modal-dialog {
height: 95vh;
}
body.mobile .revisions-dialog .modal-body {
height: 100% !important;
flex-direction: column;
padding: 0;
}
body.mobile .revisions-dialog .revision-list {
height: unset;
max-height: 20vh;
border-bottom: 1px solid var(--main-border-color) !important;
padding: 0 1em;
}
body.mobile .revisions-dialog .modal-body > .revision-content-wrapper {
flex-grow: 1;
height: 100%;
overflow: auto;
margin: 0;
}
body.mobile .revisions-dialog .modal-body > .revision-content-wrapper > div:first-of-type {
flex-direction: column;
}
body.mobile .revisions-dialog .revision-title {
font-size: 1rem;
}
body.mobile .revisions-dialog .revision-title-buttons {
text-align: center;
}
body.mobile .revisions-dialog .revision-content {
padding: 0.5em;
}
}
/* Mobile, tablet mode */
@@ -1936,7 +1985,7 @@ body.electron.platform-darwin:not(.native-titlebar) .tab-row-container {
-webkit-app-region: drag;
}
body.electron.platform-darwin:not(.native-titlebar):not(.full-screen) #tab-row-left-spacer {
body.electron.platform-darwin:not(.native-titlebar) #tab-row-left-spacer {
width: 80px;
}
@@ -2385,15 +2434,6 @@ footer.webview-footer button {
.admonition.caution::before { content: "\eac7"; }
.admonition.warning::before { content: "\eac5"; }
.ck-content ul.todo-list li span.todo-list__label__description {
transition: opacity 200ms ease;
}
.ck-content ul.todo-list li:has(> span.todo-list__label input[type="checkbox"]:checked) > span.todo-list__label span.todo-list__label__description {
text-decoration: line-through;
opacity: 0.6;
}
.chat-options-container {
display: flex;
margin: 5px 0;
@@ -2519,38 +2559,9 @@ iframe.print-iframe {
flex-direction: column;
}
.note-detail.full-height,
.scrolling-container > .note-detail.full-height,
.scrolling-container > .note-list-widget.full-height {
position: relative;
flex-grow: 1;
width: 100%;
}
/* Calendar collection */
.calendar-view a.fc-timegrid-event,
.calendar-view a.fc-daygrid-event {
/* Workaround: set font weight only if the theme-next is not active */
font-weight: var(--root-background, 800);
}
@media (max-width: 991px) {
body.mobile {
.split-note-container-widget {
flex-direction: column !important;
.note-split {
width: 100%;
}
.note-split.visible + .note-split.visible {
border-top: 1px solid var(--main-border-color);
}
}
#root-widget.virtual-keyboard-opened .note-split:not(:focus-within) {
max-height: 80px;
opacity: 0.4;
}
}
}

View File

@@ -76,9 +76,6 @@
--mermaid-theme: dark;
--native-titlebar-background: #00000000;
--calendar-coll-event-background-saturation: 30%;
--calendar-coll-event-background-lightness: 30%;
}
body ::-webkit-calendar-picker-indicator {
@@ -112,10 +109,3 @@ body .todo-list input[type="checkbox"]:not(:checked):before {
box-shadow: 1px 1px 3px rgba(0, 0, 0, 0.6) !important;
}
.use-note-color {
--custom-color: var(--dark-theme-custom-color);
}
span.fancytree-active {
color: var(--dark-theme-custom-color);
}

View File

@@ -80,9 +80,6 @@ html {
--mermaid-theme: default;
--native-titlebar-background: #ffffff00;
--calendar-coll-event-background-lightness: 95%;
--calendar-coll-event-background-saturation: 80%;
}
#left-pane .fancytree-node.tinted {
@@ -94,12 +91,4 @@ html {
.ck-content a.reference-link > span,
.board-note {
color: var(--light-theme-custom-color, inherit);
}
.use-note-color {
--custom-color: var(--light-theme-custom-color);
}
span.fancytree-active {
color: var(--light-theme-custom-color);
}
}

View File

@@ -41,9 +41,6 @@
--cmd-button-keyboard-shortcut-color: white;
--cmd-button-disabled-opacity: 0.5;
--button-group-active-button-background: #ffffff4e;
--button-group-active-button-text-color: white;
--icon-button-color: currentColor;
--icon-button-hover-background: var(--hover-item-background-color);
--icon-button-hover-color: var(--hover-item-text-color);
@@ -101,7 +98,6 @@
--menu-item-delimiter-color: #ffffff1c;
--menu-item-group-header-color: #ffffff91;
--menu-section-background-color: #fefefe08;
--menu-submenu-mobile-background-color: rgba(0, 0, 0, 0.15);
--modal-backdrop-color: #000;
--modal-shadow-color: rgba(0, 0, 0, .5);
@@ -270,14 +266,6 @@
--ck-editor-toolbar-button-on-color: white;
--ck-editor-toolbar-button-on-shadow: 1px 1px 2px rgba(0, 0, 0, .75);
--ck-editor-toolbar-dropdown-button-open-background: #ffffff14;
--calendar-coll-event-background-saturation: 25%;
--calendar-coll-event-background-lightness: 20%;
--calendar-coll-event-background-color: #3c3c3c;
--calendar-coll-event-text-color: white;
--calendar-coll-event-hover-filter: brightness(1.25);
--callendar-coll-event-archived-sripe-color: #00000026;
--calendar-coll-today-background-color: #ffffff08;
}
/*
@@ -312,12 +300,8 @@ body .todo-list input[type="checkbox"]:not(:checked):before {
border-color: var(--muted-text-color) !important;
}
.quick-edit-dialog-wrapper.with-hue {
.tinted-quick-edit-dialog {
--modal-background-color: hsl(var(--custom-color-hue), 8.8%, 11.2%);
--modal-border-color: hsl(var(--custom-color-hue), 9.4%, 25.1%);
--promoted-attribute-card-background-color: hsl(var(--custom-color-hue), 13.2%, 20.8%);
}
.use-note-color {
--custom-color: var(--dark-theme-custom-color);
}

View File

@@ -41,9 +41,6 @@
--cmd-button-keyboard-shortcut-color: black;
--cmd-button-disabled-opacity: 0.5;
--button-group-active-button-background: #00000026;
--button-group-active-button-text-color: black;
--icon-button-color: currentColor;
--icon-button-hover-background: var(--hover-item-background-color);
--icon-button-hover-color: var(--hover-item-text-color);
@@ -268,14 +265,6 @@
--ck-editor-toolbar-button-on-color: black;
--ck-editor-toolbar-button-on-shadow: none;
--ck-editor-toolbar-dropdown-button-open-background: #0000000f;
--calendar-coll-event-background-lightness: 95%;
--calendar-coll-event-background-saturation: 80%;
--calendar-coll-event-background-color: #eaeaea;
--calendar-coll-event-text-color: black;
--calendar-coll-event-hover-filter: brightness(.95) saturate(1.25);
--callendar-coll-event-archived-sripe-color: #0000000a;
--calendar-coll-today-background-color: #00000006;
}
#left-pane .fancytree-node.tinted {
@@ -287,7 +276,7 @@
--custom-bg-color: hsl(var(--custom-color-hue), 37%, 89%, 1);
}
.quick-edit-dialog-wrapper.with-hue {
.tinted-quick-edit-dialog {
--modal-background-color: hsl(var(--custom-color-hue), 56%, 96%);
--modal-border-color: hsl(var(--custom-color-hue), 33%, 41%);
--promoted-attribute-card-background-color: hsl(var(--custom-color-hue), 40%, 88%);

View File

@@ -62,7 +62,6 @@
--menu-padding-size: 8px;
--menu-item-icon-vert-offset: -2px;
--menu-submenu-mobile-background-color: rgba(255, 255, 255, 0.15);
--more-accented-background-color: var(--card-background-hover-color);
@@ -100,14 +99,6 @@
--tree-item-dark-theme-min-color-lightness: 65;
}
body {
user-select: none;
}
.selectable-text {
user-select: text;
}
body.backdrop-effects-disabled {
/* Backdrop effects are disabled, replace the menu background color with the
* no-backdrop fallback color */
@@ -128,6 +119,17 @@ body.backdrop-effects-disabled {
font-size: 0.9rem !important;
}
body.mobile .dropdown-menu {
backdrop-filter: var(--dropdown-backdrop-filter);
border-radius: var(--dropdown-border-radius);
position: relative;
}
body.mobile .dropdown-menu .dropdown-menu {
backdrop-filter: unset !important;
border-radius: unset !important;
}
body.desktop .dropdown-menu::before,
:root .ck.ck-dropdown__panel::before,
:root .excalidraw .popover::before,
@@ -155,12 +157,17 @@ body.desktop .dropdown-submenu .dropdown-menu::before {
content: unset;
}
body.mobile .dropdown-submenu .dropdown-menu {
background: transparent !important;
}
body.desktop .dropdown-submenu .dropdown-menu {
backdrop-filter: var(--dropdown-backdrop-filter);
background: transparent;
}
.dropdown-item,
body.mobile .dropdown-submenu .dropdown-toggle,
.excalidraw .context-menu .context-menu-item {
--menu-item-start-padding: 8px;
--menu-item-end-padding: 22px;
@@ -194,6 +201,10 @@ body.mobile .dropdown-item:not(:last-of-type) {
margin-bottom: 0.5em;
}
body.mobile .dropdown-submenu:hover {
background: transparent !important;
}
html body .dropdown-item.disabled,
html body .dropdown-item[disabled] {
color: var(--menu-text-color) !important;
@@ -310,126 +321,17 @@ body.desktop .dropdown-menu.static .dropdown-item.active {
--active-item-text-color: var(--menu-text-color);
}
/* #region Mobile tweaks for dropdown menus */
body.mobile #context-menu-cover {
transition: background-color 150ms ease-in;
&.show {
background: rgba(0, 0, 0, 0.7);
}
&.global-menu-cover {
bottom: calc(var(--mobile-bottom-offset) + var(--launcher-pane-size));
@media (min-width: 992px) {
bottom: 0;
}
}
}
body.mobile .dropdown-menu.mobile-bottom-menu,
body.mobile .dropdown.global-menu .dropdown-menu {
border-radius: var(--dropdown-border-radius) var(--dropdown-border-radius) 0 0;
}
body.mobile .dropdown-menu {
--dropdown-menu-padding-vertical: 0.7em;
--dropdown-menu-padding-horizontal: 1em;
--hover-item-background-color: var(--card-background-color);
font-size: 1em !important;
backdrop-filter: var(--dropdown-backdrop-filter);
position: relative;
.dropdown-toggle::after {
top: 0.5em;
right: var(--dropdown-menu-padding-horizontal);
transform: translateX(50%) rotate(90deg);
}
.dropdown-item.submenu-open .dropdown-toggle::after {
transform: rotate(270deg);
}
.dropdown-item,
.dropdown-custom-item {
margin-bottom: 0;
padding: var(--dropdown-menu-padding-vertical) var(--dropdown-menu-padding-horizontal) !important;
background: var(--card-background-color);
border-bottom: 1px solid var(--menu-item-delimiter-color) !important;
border-radius: 0;
}
.dropdown-item:first-of-type,
.dropdown-divider + .dropdown-item,
.dropdown-custom-item:first-of-type,
.dropdown-divider + .dropdown-custom-item {
border-top-left-radius: 6px;
border-top-right-radius: 6px;
}
.dropdown-item:last-of-type,
.dropdown-item:has(+ .dropdown-divider),
.dropdown-custom-item:last-of-type,
.dropdown-custom-item:has(+ .dropdown-divider) {
border-bottom-left-radius: 6px;
border-bottom-right-radius: 6px;
border-bottom: 0 !important;
}
.dropdown-divider {
visibility: hidden;
}
.dropdown-submenu {
padding: 0 !important;
backdrop-filter: unset !important;
.dropdown-toggle {
padding: var(--dropdown-menu-padding-vertical) var(--dropdown-menu-padding-horizontal);
}
.dropdown-menu {
--menu-background-color: --menu-submenu-mobile-background-color;
--bs-dropdown-divider-margin-y: 0.25rem;
border-radius: 0;
max-height: 0;
transition: max-height 100ms ease-in;
display: block !important;
&.show {
max-height: 1000px;
padding: 0.5rem 0.75rem !important;
}
}
&.submenu-open {
.dropdown-toggle {
padding-bottom: var(--dropdown-menu-padding-vertical);
}
}
}
.dropdown-custom-item:has(.note-color-picker) {
overflow-x: auto;
}
.note-color-picker {
padding: 0;
width: fit-content;
.color-cell {
--color-picker-cell-size: 26px;
flex-shrink: 0;
}
}
}
/* #endregion */
body.desktop .dropdown-menu .dropdown-toggle::after {
height: 100%;
}
body.mobile .dropdown-menu .dropdown-toggle::after {
transform: rotate(90deg);
}
body.mobile .dropdown-menu .dropdown-item.submenu-open .dropdown-toggle::after {
transform: rotate(270deg);
}
/* Dropdown item button (used in zoom buttons in global menu) */
@@ -445,12 +347,6 @@ li.dropdown-item a.dropdown-item-button:focus-visible {
outline: 2px solid var(--input-focus-outline-color) !important;
}
:root .dropdown-menu .note-color-picker {
padding: 4px 10px;
--note-color-picker-clear-color-cell-background: var(--main-text-color);
--note-color-picker-clear-color-cell-selection-outline-color: var(--main-text-color);
}
/*
* TOASTS
*/

View File

@@ -25,7 +25,6 @@
.modal .modal-header .btn-close,
.modal .modal-header .help-button,
.modal .modal-header .custom-title-bar-button,
#toast-container .toast .toast-header .btn-close {
display: flex;
justify-content: center;
@@ -56,17 +55,15 @@
font-family: boxicons;
}
.modal .modal-header .help-button,
.modal .modal-header .custom-title-bar-button {
.modal .modal-header .help-button {
margin-inline-end: 0;
font-size: calc(var(--modal-control-button-size) * .70);
font-size: calc(var(--modal-control-button-size) * .75);
font-family: unset;
font-weight: bold;
}
.modal .modal-header .btn-close:hover,
.modal .modal-header .help-button:hover,
.modal .modal-header .custom-title-bar-button:hover,
#toast-container .toast .toast-header .btn-close:hover {
background: var(--modal-control-button-hover-background);
color: var(--modal-control-button-hover-color);
@@ -74,7 +71,6 @@
.modal .modal-header .btn-close:active,
.modal .modal-header .help-button:active,
.modal .modal-header .custom-title-bar-button:active,
#toast-container .toast .toast-header .btn-close:active {
transform: scale(.85);
}

View File

@@ -17,10 +17,6 @@ button.ck.ck-button:is(.ck-button-action, .ck-button-save, .ck-button-cancel, .c
padding: 4px 16px;
background: var(--cmd-button-background-color);
color: var(--cmd-button-text-color);
&.dropdown-toggle-split {
min-width: unset;
}
}
button.btn.btn-primary:hover,
@@ -146,14 +142,6 @@ button.btn.btn-success kbd {
outline: 2px solid var(--input-focus-outline-color);
}
/* Button groups */
/* Active button */
:root .btn-group button.btn.active {
background-color: var(--button-group-active-button-background);
color: var(--button-group-active-button-text-color);
}
/*
* Input boxes
*/

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