Compare commits

..

38 Commits

Author SHA1 Message Date
perfectra1n
09df73e125 fix(fts): remove migration statements of old 0236 2025-11-28 21:36:12 -08:00
perfectra1n
f21aa321f6 fix(fts): remove index of components 2025-11-28 21:29:48 -08:00
perfectra1n
7be8b6c71e fix(fts): merge the two migrations into one file 2025-11-28 21:27:01 -08:00
perfectra1n
bb8e5ebd4a fix(fts): fix suggestions from elian 2025-11-28 21:25:24 -08:00
perfectra1n
6b8b71f7d1 feat(fts): implement missing unit tests 2025-11-28 21:12:39 -08:00
perfectra1n
191a18d7f6 feat(fts): add fts to in-memory sqlite for testing 2025-11-28 21:08:49 -08:00
perfectra1n
574a3441ee feat(fts): update imports from breaking up large fts_search file 2025-11-28 20:59:50 -08:00
perfectra1n
9940ee3bee feat(fts): break up the huge fts_search into smaller files 2025-11-28 20:57:18 -08:00
perfectra1n
41f6fedc61 feat(fts5): get rid of search comparison code 2025-11-24 14:24:07 -08:00
perfectra1n
0ddf48c460 feat(fts5): add more unit tests for search 2025-11-24 13:30:40 -08:00
perfectra1n
3957d789da feat(fts5): also create an fts5 index for attributes, and allow them to be searchable using fts5 indexes 2025-11-18 13:08:11 -08:00
perfectra1n
15719a1ee9 fix(fts5): correctly allow for exact word searches with fts5 2025-11-18 13:07:51 -08:00
perfectra1n
334c7dd27a Merge branch 'main' into feat/rice-searching-with-sqlite
Resolved conflicts by:
- Combining imports from both branches in search.ts (added
hoistedNoteService and beccaService alongside existing ftsSearchService
and log imports)
- Integrating FTS5 search optimization from feature branch with flatText
attribute search logic from main in note_content_fulltext.ts
- Maintained backward compatibility by keeping fallback search
implementation for cases where FTS5 is not available or not suitable
2025-11-16 14:23:01 -08:00
perfectra1n
30da95d75a feat(search): update fulltext search and add stress test improvements
- Modified note_content_fulltext.ts for enhanced search capabilities
- Updated becca_mocking.ts for better test support
- Improved stress-test-populate.ts script
2025-11-16 14:20:46 -08:00
perfectra1n
09ff9ccc65 feat(dev): add new stress test population script 2025-11-15 16:54:09 -08:00
perf3ct
5f1773609f fix(tests): rename some of the silly-ily named tests 2025-11-04 15:56:49 -08:00
perf3ct
da0302066d fix(tests): resolve issues with new search tests not passing 2025-11-04 15:55:42 -08:00
perf3ct
942647ab9c fix(search): get rid of exporting dbConnection 2025-11-04 14:47:46 -08:00
perf3ct
b8aa7402d8 feat(tests): create a ton of tests for the various search capabilities that we support 2025-11-04 14:34:50 -08:00
perf3ct
052e28ab1b feat(search): if the search is empty, return all notes 2025-11-04 11:59:41 -08:00
perf3ct
16912e606e fix(search): resolve compilation issue due to performance log in new search 2025-11-03 12:04:00 -08:00
Jon Fuller
321752ac18 Merge branch 'main' into feat/rice-searching-with-sqlite 2025-11-03 11:47:44 -08:00
perf3ct
10988095c2 feat(search): get the correct comparison and rice out the fts5 search 2025-10-27 14:37:44 -07:00
perf3ct
253da139de feat(search): try again to get fts5 searching done well 2025-10-24 21:47:06 -07:00
Jon Fuller
d992a5e4a2 Merge branch 'main' into feat/rice-searching-with-sqlite 2025-10-24 09:18:11 -07:00
perf3ct
58c225237c feat(search): try a ground-up sqlite search approach 2025-09-03 00:34:55 +00:00
perf3ct
d074841885 Revert "feat(search): try to get fts search to work in large environments"
This reverts commit 053f722cb8.
2025-09-02 19:24:50 +00:00
perf3ct
06b2d71b27 Revert "feat(search): try to decrease complexity"
This reverts commit 5b79e0d71e.
2025-09-02 19:24:47 +00:00
perf3ct
0afb8a11c8 Revert "feat(search): try to deal with huge dbs, might need to squash later"
This reverts commit 37d0136c50.
2025-09-02 19:24:46 +00:00
perf3ct
f529ddc601 Revert "feat(search): further improve fts search"
This reverts commit 7c5553bd4b.
2025-09-02 19:24:45 +00:00
perf3ct
8572f82e0a Revert "feat(search): I honestly have no idea what I'm doing"
This reverts commit b09a2c386d.
2025-09-02 19:24:44 +00:00
perf3ct
b09a2c386d feat(search): I honestly have no idea what I'm doing 2025-09-01 22:29:59 -07:00
perf3ct
7c5553bd4b feat(search): further improve fts search 2025-09-01 21:40:05 -07:00
perf3ct
37d0136c50 feat(search): try to deal with huge dbs, might need to squash later 2025-09-01 04:33:10 +00:00
perf3ct
5b79e0d71e feat(search): try to decrease complexity 2025-08-30 22:30:01 -07:00
perf3ct
053f722cb8 feat(search): try to get fts search to work in large environments 2025-08-31 03:15:29 +00:00
perf3ct
21aaec2c38 feat(search): also fix tests for new fts functionality 2025-08-30 20:48:42 +00:00
perf3ct
1db4971da6 feat(search): implement FST5 w/ sqlite for faster and better searching
feat(search): don't limit the number of blobs to put in virtual tables

fix(search): improve FTS triggers to handle all SQL operations correctly

The root cause of FTS index issues during import was that database triggers
weren't properly handling all SQL operations, particularly upsert operations
(INSERT ... ON CONFLICT ... DO UPDATE) that are commonly used during imports.

Key improvements:
- Fixed INSERT trigger to handle INSERT OR REPLACE operations
- Updated UPDATE trigger to fire on ANY change (not just specific columns)
- Improved blob triggers to use INSERT OR REPLACE for atomic updates
- Added proper handling for notes created before their blobs (import scenario)
- Added triggers for protection state changes
- All triggers now use LEFT JOIN to handle missing blobs gracefully

This ensures the FTS index stays synchronized even when:
- Entity events are disabled during import
- Notes are re-imported (upsert operations)
- Blobs are deduplicated across notes
- Notes are created before their content blobs

The solution works entirely at the database level through triggers,
removing the need for application-level workarounds.

fix(search): consolidate FTS trigger fixes into migration 234

- Merged improved trigger logic from migration 235 into 234
- Deleted unnecessary migration 235 since DB version is still 234
- Ensures triggers handle all SQL operations (INSERT OR REPLACE, upserts)
- Fixes FTS indexing for imported notes by handling missing blobs
- Schema.sql and migration 234 now have identical trigger implementations
2025-08-30 20:39:40 +00:00
557 changed files with 27446 additions and 19559 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,7 +58,6 @@ function initOnElectron() {
initDarkOrLightMode(style);
initTransparencyEffects(style, currentWindow);
initFullScreenDetection(currentWindow);
if (options.get("nativeTitleBarVisible") !== "true") {
initTitleBarButtons(style, currentWindow);
@@ -89,11 +87,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