diff --git a/.github/instructions/nx.instructions.md b/.github/instructions/nx.instructions.md new file mode 100644 index 000000000..d5894c44d --- /dev/null +++ b/.github/instructions/nx.instructions.md @@ -0,0 +1,40 @@ +--- +applyTo: '**' +--- + +// This file is automatically generated by Nx Console + +You are in an nx workspace using Nx 21.3.9 and pnpm as the package manager. + +You have access to the Nx MCP server and the tools it provides. Use them. Follow these guidelines in order to best help the user: + +# General Guidelines +- When answering questions, use the nx_workspace tool first to gain an understanding of the workspace architecture +- For questions around nx configuration, best practices or if you're unsure, use the nx_docs tool to get relevant, up-to-date docs!! Always use this instead of assuming things about nx configuration +- If the user needs help with an Nx configuration or project graph error, use the 'nx_workspace' tool to get any errors +- To help answer questions about the workspace structure or simply help with demonstrating how tasks depend on each other, use the 'nx_visualize_graph' tool + +# Generation Guidelines +If the user wants to generate something, use the following flow: + +- learn about the nx workspace and any specifics the user needs by using the 'nx_workspace' tool and the 'nx_project_details' tool if applicable +- get the available generators using the 'nx_generators' tool +- decide which generator to use. If no generators seem relevant, check the 'nx_available_plugins' tool to see if the user could install a plugin to help them +- get generator details using the 'nx_generator_schema' tool +- you may use the 'nx_docs' tool to learn more about a specific generator or technology if you're unsure +- decide which options to provide in order to best complete the user's request. Don't make any assumptions and keep the options minimalistic +- open the generator UI using the 'nx_open_generate_ui' tool +- wait for the user to finish the generator +- read the generator log file using the 'nx_read_generator_log' tool +- use the information provided in the log file to answer the user's question or continue with what they were doing + +# Running Tasks Guidelines +If the user wants help with tasks or commands (which include keywords like "test", "build", "lint", or other similar actions), use the following flow: +- Use the 'nx_current_running_tasks_details' tool to get the list of tasks (this can include tasks that were completed, stopped or failed). +- If there are any tasks, ask the user if they would like help with a specific task then use the 'nx_current_running_task_output' tool to get the terminal output for that task/command +- Use the terminal output from 'nx_current_running_task_output' to see what's wrong and help the user fix their problem. Use the appropriate tools if necessary +- If the user would like to rerun the task or command, always use `nx run ` to rerun in the terminal. This will ensure that the task will run in the nx context and will be run the same way it originally executed +- If the task was marked as "continuous" do not offer to rerun the task. This task is already running and the user can see the output in the terminal. You can use 'nx_current_running_task_output' to get the output of the task to verify the output. + + + diff --git a/.github/workflows/main-docker.yml b/.github/workflows/main-docker.yml index 40c5149c7..ca84dd20b 100644 --- a/.github/workflows/main-docker.yml +++ b/.github/workflows/main-docker.yml @@ -223,7 +223,7 @@ jobs: - build steps: - name: Download digests - uses: actions/download-artifact@v4 + uses: actions/download-artifact@v5 with: path: /tmp/digests pattern: digests-* diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index b05cb4939..1d84519ea 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -107,7 +107,7 @@ jobs: docs/Release Notes - name: Download all artifacts - uses: actions/download-artifact@v4 + uses: actions/download-artifact@v5 with: merge-multiple: true pattern: release-* diff --git a/.github/workflows/unblock_signing.yml b/.github/workflows/unblock_signing.yml new file mode 100644 index 000000000..0860f89da --- /dev/null +++ b/.github/workflows/unblock_signing.yml @@ -0,0 +1,11 @@ +name: Unblock signing +on: + workflow_dispatch: + +jobs: + unblock-win-signing: + runs-on: win-signing + steps: + - run: | + cat ${{ vars.WINDOWS_SIGN_ERROR_LOG }} + rm ${{ vars.WINDOWS_SIGN_ERROR_LOG }} diff --git a/.gitignore b/.gitignore index d7694258d..09749c270 100644 --- a/.gitignore +++ b/.gitignore @@ -10,6 +10,7 @@ node_modules # IDEs and editors /.idea +.idea .project .classpath .c9/ diff --git a/.idea/.gitignore b/.idea/.gitignore deleted file mode 100644 index 2102c8715..000000000 --- a/.idea/.gitignore +++ /dev/null @@ -1,6 +0,0 @@ -# Default ignored files -/workspace.xml - -# Datasource local storage ignored files -/dataSources.local.xml -/dataSources/ diff --git a/.idea/codeStyles/Project.xml b/.idea/codeStyles/Project.xml deleted file mode 100644 index d49935027..000000000 --- a/.idea/codeStyles/Project.xml +++ /dev/null @@ -1,15 +0,0 @@ - - - - - - - - - \ No newline at end of file diff --git a/.idea/codeStyles/codeStyleConfig.xml b/.idea/codeStyles/codeStyleConfig.xml deleted file mode 100644 index 79ee123c2..000000000 --- a/.idea/codeStyles/codeStyleConfig.xml +++ /dev/null @@ -1,5 +0,0 @@ - - - - \ No newline at end of file diff --git a/.idea/dataSources.xml b/.idea/dataSources.xml deleted file mode 100644 index 88634a324..000000000 --- a/.idea/dataSources.xml +++ /dev/null @@ -1,12 +0,0 @@ - - - - - sqlite.xerial - true - org.sqlite.JDBC - jdbc:sqlite:$PROJECT_DIR$/data/document.db - $ProjectFileDir$ - - - \ No newline at end of file diff --git a/.idea/encodings.xml b/.idea/encodings.xml deleted file mode 100644 index 15a15b218..000000000 --- a/.idea/encodings.xml +++ /dev/null @@ -1,4 +0,0 @@ - - - - \ No newline at end of file diff --git a/.idea/git_toolbox_prj.xml b/.idea/git_toolbox_prj.xml deleted file mode 100644 index 02b915b85..000000000 --- a/.idea/git_toolbox_prj.xml +++ /dev/null @@ -1,15 +0,0 @@ - - - - - - - \ No newline at end of file diff --git a/.idea/inspectionProfiles/Project_Default.xml b/.idea/inspectionProfiles/Project_Default.xml deleted file mode 100644 index 22cdf9bd9..000000000 --- a/.idea/inspectionProfiles/Project_Default.xml +++ /dev/null @@ -1,11 +0,0 @@ - - - - \ No newline at end of file diff --git a/.idea/jsLibraryMappings.xml b/.idea/jsLibraryMappings.xml deleted file mode 100644 index d23208fbb..000000000 --- a/.idea/jsLibraryMappings.xml +++ /dev/null @@ -1,6 +0,0 @@ - - - - - - \ No newline at end of file diff --git a/.idea/jsLinters/jslint.xml b/.idea/jsLinters/jslint.xml deleted file mode 100644 index 742a5fe03..000000000 --- a/.idea/jsLinters/jslint.xml +++ /dev/null @@ -1,9 +0,0 @@ - - - - - \ No newline at end of file diff --git a/.idea/misc.xml b/.idea/misc.xml deleted file mode 100644 index 44ee38ede..000000000 --- a/.idea/misc.xml +++ /dev/null @@ -1,8 +0,0 @@ - - - - - - - \ No newline at end of file diff --git a/.idea/modules.xml b/.idea/modules.xml deleted file mode 100644 index 09c4a5cbf..000000000 --- a/.idea/modules.xml +++ /dev/null @@ -1,8 +0,0 @@ - - - - - - - - \ No newline at end of file diff --git a/.idea/sqldialects.xml b/.idea/sqldialects.xml deleted file mode 100644 index dd88c0a28..000000000 --- a/.idea/sqldialects.xml +++ /dev/null @@ -1,7 +0,0 @@ - - - - - - - \ No newline at end of file diff --git a/.idea/vcs.xml b/.idea/vcs.xml deleted file mode 100644 index dcb6b8c4c..000000000 --- a/.idea/vcs.xml +++ /dev/null @@ -1,6 +0,0 @@ - - - - - - diff --git a/.nvmrc b/.nvmrc index 818ab238a..89b93fd74 100644 --- a/.nvmrc +++ b/.nvmrc @@ -1 +1 @@ -22.17.1 \ No newline at end of file +22.18.0 \ No newline at end of file diff --git a/.vscode/i18n-ally-custom-framework.yml b/.vscode/i18n-ally-custom-framework.yml index 32ec786aa..43c0ddff5 100644 --- a/.vscode/i18n-ally-custom-framework.yml +++ b/.vscode/i18n-ally-custom-framework.yml @@ -3,6 +3,7 @@ languageIds: - javascript - typescript + - typescriptreact - html # An array of RegExes to find the key usage. **The key should be captured in the first match group**. @@ -25,9 +26,10 @@ scopeRangeRegex: "useTranslation\\(\\s*\\[?\\s*['\"`](.*?)['\"`]" # The "$1" will be replaced by the keypath specified. refactorTemplates: - t("$1") + - {t("$1")} - ${t("$1")} - <%= t("$1") %> # If set to true, only enables this custom framework (will disable all built-in frameworks) -monopoly: true \ No newline at end of file +monopoly: true diff --git a/.vscode/mcp.json b/.vscode/mcp.json new file mode 100644 index 000000000..28994bb29 --- /dev/null +++ b/.vscode/mcp.json @@ -0,0 +1,8 @@ +{ + "servers": { + "nx-mcp": { + "type": "http", + "url": "http://localhost:9461/mcp" + } + } +} \ No newline at end of file diff --git a/.vscode/settings.json b/.vscode/settings.json index 9ee96f4c1..4ee21bb3c 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -35,5 +35,6 @@ "docs/**/*.png": true, "apps/server/src/assets/doc_notes/**": true, "apps/edit-docs/demo/**": true - } + }, + "nxConsole.generateAiAgentRules": true } \ No newline at end of file diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 000000000..942ad06e3 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,161 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Overview + +Trilium Notes is a hierarchical note-taking application with advanced features like synchronization, scripting, and rich text editing. It's built as a TypeScript monorepo using NX, with multiple applications and shared packages. + +## Development Commands + +### Setup +- `pnpm install` - Install all dependencies +- `corepack enable` - Enable pnpm if not available + +### Running Applications +- `pnpm run server:start` - Start development server (http://localhost:8080) +- `pnpm nx run server:serve` - Alternative server start command +- `pnpm nx run desktop:serve` - Run desktop Electron app +- `pnpm run server:start-prod` - Run server in production mode + +### Building +- `pnpm nx build ` - Build specific project (server, client, desktop, etc.) +- `pnpm run client:build` - Build client application +- `pnpm run server:build` - Build server application +- `pnpm run electron:build` - Build desktop application + +### Testing +- `pnpm test:all` - Run all tests (parallel + sequential) +- `pnpm test:parallel` - Run tests that can run in parallel +- `pnpm test:sequential` - Run tests that must run sequentially (server, ckeditor5-mermaid, ckeditor5-math) +- `pnpm nx test ` - Run tests for specific project +- `pnpm coverage` - Generate coverage reports + +### Linting & Type Checking +- `pnpm nx run :lint` - Lint specific project +- `pnpm nx run :typecheck` - Type check specific project + +## Architecture Overview + +### Monorepo Structure +- **apps/**: Runnable applications + - `client/` - Frontend application (shared by server and desktop) + - `server/` - Node.js server with web interface + - `desktop/` - Electron desktop application + - `web-clipper/` - Browser extension for saving web content + - Additional tools: `db-compare`, `dump-db`, `edit-docs` + +- **packages/**: Shared libraries + - `commons/` - Shared interfaces and utilities + - `ckeditor5/` - Custom rich text editor with Trilium-specific plugins + - `codemirror/` - Code editor customizations + - `highlightjs/` - Syntax highlighting + - Custom CKEditor plugins: `ckeditor5-admonition`, `ckeditor5-footnotes`, `ckeditor5-math`, `ckeditor5-mermaid` + +### Core Architecture Patterns + +#### Three-Layer Cache System +- **Becca** (Backend Cache): Server-side entity cache (`apps/server/src/becca/`) +- **Froca** (Frontend Cache): Client-side mirror of backend data (`apps/client/src/services/froca.ts`) +- **Shaca** (Share Cache): Optimized cache for shared/published notes (`apps/server/src/share/`) + +#### Entity System +Core entities are defined in `apps/server/src/becca/entities/`: +- `BNote` - Notes with content and metadata +- `BBranch` - Hierarchical relationships between notes (allows multiple parents) +- `BAttribute` - Key-value metadata attached to notes +- `BRevision` - Note version history +- `BOption` - Application configuration + +#### Widget-Based UI +Frontend uses a widget system (`apps/client/src/widgets/`): +- `BasicWidget` - Base class for all UI components +- `NoteContextAwareWidget` - Widgets that respond to note changes +- `RightPanelWidget` - Widgets displayed in the right panel +- Type-specific widgets in `type_widgets/` directory + +#### API Architecture +- **Internal API**: REST endpoints in `apps/server/src/routes/api/` +- **ETAPI**: External API for third-party integrations (`apps/server/src/etapi/`) +- **WebSocket**: Real-time synchronization (`apps/server/src/services/ws.ts`) + +### Key Files for Understanding Architecture + +1. **Application Entry Points**: + - `apps/server/src/main.ts` - Server startup + - `apps/client/src/desktop.ts` - Client initialization + +2. **Core Services**: + - `apps/server/src/becca/becca.ts` - Backend data management + - `apps/client/src/services/froca.ts` - Frontend data synchronization + - `apps/server/src/services/backend_script_api.ts` - Scripting API + +3. **Database Schema**: + - `apps/server/src/assets/db/schema.sql` - Core database structure + +4. **Configuration**: + - `nx.json` - NX workspace configuration + - `package.json` - Project dependencies and scripts + +## Note Types and Features + +Trilium supports multiple note types, each with specialized widgets: +- **Text**: Rich text with CKEditor5 (markdown import/export) +- **Code**: Syntax-highlighted code editing with CodeMirror +- **File**: Binary file attachments +- **Image**: Image display with editing capabilities +- **Canvas**: Drawing/diagramming with Excalidraw +- **Mermaid**: Diagram generation +- **Relation Map**: Visual note relationship mapping +- **Web View**: Embedded web pages +- **Doc/Book**: Hierarchical documentation structure + +## Development Guidelines + +### Testing Strategy +- Server tests run sequentially due to shared database +- Client tests can run in parallel +- E2E tests use Playwright for both server and desktop apps +- Build validation tests check artifact integrity + +### Scripting System +Trilium provides powerful user scripting capabilities: +- Frontend scripts run in browser context +- Backend scripts run in Node.js context with full API access +- Script API documentation available in `docs/Script API/` + +### Internationalization +- Translation files in `apps/client/src/translations/` +- Supported languages: English, German, Spanish, French, Romanian, Chinese + +### Security Considerations +- Per-note encryption with granular protected sessions +- CSRF protection for API endpoints +- OpenID and TOTP authentication support +- Sanitization of user-generated content + +## Common Development Tasks + +### Adding New Note Types +1. Create widget in `apps/client/src/widgets/type_widgets/` +2. Register in `apps/client/src/services/note_types.ts` +3. Add backend handling in `apps/server/src/services/notes.ts` + +### Extending Search +- Search expressions handled in `apps/server/src/services/search/` +- Add new search operators in search context files + +### Custom CKEditor Plugins +- Create new package in `packages/` following existing plugin structure +- Register in `packages/ckeditor5/src/plugins.ts` + +### Database Migrations +- Add migration scripts in `apps/server/src/migrations/` +- Update schema in `apps/server/src/assets/db/schema.sql` + +## Build System Notes +- Uses NX for monorepo management with build caching +- Vite for fast development builds +- ESBuild for production optimization +- pnpm workspaces for dependency management +- Docker support with multi-stage builds \ No newline at end of file diff --git a/README.md b/README.md index 540d5f226..0b66ccb87 100644 --- a/README.md +++ b/README.md @@ -1,10 +1,9 @@ # Trilium Notes -Donate: ![GitHub Sponsors](https://img.shields.io/github/sponsors/eliandoran?style=flat-square) ![LiberaPay patrons](https://img.shields.io/liberapay/patrons/ElianDoran?style=flat-square) - -![Docker Pulls](https://img.shields.io/docker/pulls/triliumnext/notes?style=flat-square) -![GitHub Downloads (all assets, all releases)](https://img.shields.io/github/downloads/triliumnext/notes/total?style=flat-square) -[![RelativeCI](https://badges.relative-ci.com/badges/Di5q7dz9daNDZ9UXi0Bp?branch=develop&style=flat-square)](https://app.relative-ci.com/projects/Di5q7dz9daNDZ9UXi0Bp) +![GitHub Sponsors](https://img.shields.io/github/sponsors/eliandoran) ![LiberaPay patrons](https://img.shields.io/liberapay/patrons/ElianDoran) +![Docker Pulls](https://img.shields.io/docker/pulls/triliumnext/notes) +![GitHub Downloads (all assets, all releases)](https://img.shields.io/github/downloads/triliumnext/notes/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/) [English](./README.md) | [Chinese](./docs/README-ZH_CN.md) | [Russian](./docs/README.ru.md) | [Japanese](./docs/README.ja.md) | [Italian](./docs/README.it.md) | [Spanish](./docs/README.es.md) @@ -83,7 +82,7 @@ Feel free to join our official conversations. We would love to hear what feature ### Windows / MacOS -Download the binary release for your platform from the [latest release page](https://github.com/TriliumNext/Notes/releases/latest), unzip the package and run the `trilium` executable. +Download the binary release for your platform from the [latest release page](https://github.com/TriliumNext/Trilium/releases/latest), unzip the package and run the `trilium` executable. ### Linux @@ -91,7 +90,7 @@ If your distribution is listed in the table below, use your distribution's packa [![Packaging status](https://repology.org/badge/vertical-allrepos/triliumnext.svg)](https://repology.org/project/triliumnext/versions) -You may also download the binary release for your platform from the [latest release page](https://github.com/TriliumNext/Notes/releases/latest), unzip the package and run the `trilium` executable. +You may also download the binary release for your platform from the [latest release page](https://github.com/TriliumNext/Trilium/releases/latest), unzip the package and run the `trilium` executable. TriliumNext is also provided as a Flatpak, but not yet published on FlatHub. @@ -116,6 +115,14 @@ To install TriliumNext on your own server (including via Docker from [Dockerhub] ## 💻 Contribute +### Translations + +If you are a native speaker, help us translate Trilium by heading over to our [Weblate page](https://hosted.weblate.org/engage/trilium/). + +Here's the language coverage we have so far: + +[![Translation status](https://hosted.weblate.org/widget/trilium/multi-auto.svg)](https://hosted.weblate.org/engage/trilium/) + ### Code Download the repository, install dependencies using `pnpm` and then run the server (available at http://localhost:8080): diff --git a/_regroup/package.json b/_regroup/package.json index 1ed28ed65..827f8ff85 100644 --- a/_regroup/package.json +++ b/_regroup/package.json @@ -35,13 +35,13 @@ "chore:generate-openapi": "tsx bin/generate-openapi.js" }, "devDependencies": { - "@playwright/test": "1.54.1", - "@stylistic/eslint-plugin": "5.2.0", + "@playwright/test": "1.54.2", + "@stylistic/eslint-plugin": "5.2.3", "@types/express": "5.0.3", - "@types/node": "22.16.5", + "@types/node": "22.17.1", "@types/yargs": "17.0.33", "@vitest/coverage-v8": "3.2.4", - "eslint": "9.31.0", + "eslint": "9.33.0", "eslint-plugin-simple-import-sort": "12.1.1", "esm": "3.2.25", "jsdoc": "4.0.4", @@ -49,7 +49,7 @@ "rcedit": "4.0.1", "rimraf": "6.0.1", "tslib": "2.8.1", - "typedoc": "0.28.7", + "typedoc": "0.28.10", "typedoc-plugin-missing-exports": "4.0.0" }, "optionalDependencies": { diff --git a/apps/client/package.json b/apps/client/package.json index 5651f4ea5..eebcef3ad 100644 --- a/apps/client/package.json +++ b/apps/client/package.json @@ -1,6 +1,6 @@ { "name": "@triliumnext/client", - "version": "0.97.0", + "version": "0.97.2", "description": "JQuery-based client for TriliumNext, used for both web and desktop (via Electron)", "private": true, "license": "AGPL-3.0-only", @@ -10,14 +10,15 @@ "url": "https://github.com/TriliumNext/Notes" }, "dependencies": { - "@eslint/js": "9.31.0", + "@eslint/js": "9.33.0", "@excalidraw/excalidraw": "0.18.0", - "@fullcalendar/core": "6.1.18", - "@fullcalendar/daygrid": "6.1.18", - "@fullcalendar/interaction": "6.1.18", - "@fullcalendar/list": "6.1.18", - "@fullcalendar/multimonth": "6.1.18", - "@fullcalendar/timegrid": "6.1.18", + "@fullcalendar/core": "6.1.19", + "@fullcalendar/daygrid": "6.1.19", + "@fullcalendar/interaction": "6.1.19", + "@fullcalendar/list": "6.1.19", + "@fullcalendar/multimonth": "6.1.19", + "@fullcalendar/timegrid": "6.1.19", + "@maplibre/maplibre-gl-leaflet": "0.1.3", "@mermaid-js/layout-elk": "0.1.8", "@mind-elixir/node-menu": "5.0.0", "@popperjs/core": "2.11.8", @@ -35,10 +36,9 @@ "draggabilly": "3.0.0", "force-graph": "1.50.1", "globals": "16.3.0", - "i18next": "25.3.2", + "i18next": "25.3.4", "i18next-http-backend": "3.0.2", "jquery": "3.7.1", - "jquery-hotkeys": "0.2.2", "jquery.fancytree": "2.38.5", "jsplumb": "2.15.6", "katex": "0.16.22", @@ -46,12 +46,12 @@ "leaflet": "1.9.4", "leaflet-gpx": "2.2.0", "mark.js": "8.11.1", - "marked": "16.1.1", + "marked": "16.1.2", "mermaid": "11.9.0", - "mind-elixir": "5.0.2", + "mind-elixir": "5.0.5", "normalize.css": "8.0.1", "panzoom": "9.4.3", - "preact": "10.26.9", + "preact": "10.27.0", "split.js": "1.6.5", "svg-pan-zoom": "3.6.2", "tabulator-tables": "6.3.1", @@ -59,12 +59,13 @@ }, "devDependencies": { "@ckeditor/ckeditor5-inspector": "5.0.0", + "@preact/preset-vite": "2.10.2", "@types/bootstrap": "5.2.10", "@types/jquery": "3.5.32", "@types/leaflet": "1.9.20", "@types/leaflet-gpx": "1.3.7", "@types/mark.js": "8.11.12", - "@types/tabulator-tables": "6.2.7", + "@types/tabulator-tables": "6.2.9", "copy-webpack-plugin": "13.0.0", "happy-dom": "18.0.1", "script-loader": "0.7.2", diff --git a/apps/client/src/components/app_context.ts b/apps/client/src/components/app_context.ts index f960a76c4..4c750a544 100644 --- a/apps/client/src/components/app_context.ts +++ b/apps/client/src/components/app_context.ts @@ -30,6 +30,7 @@ import type CodeMirror from "@triliumnext/codemirror"; import { StartupChecks } from "./startup_checks.js"; import type { CreateNoteOpts } from "../services/note_create.js"; import { ColumnComponent } from "tabulator-tables"; +import { ChooseNoteTypeCallback } from "../widgets/dialogs/note_type_chooser.jsx"; interface Layout { getRootWidget: (appContext: AppContext) => RootWidget; @@ -92,7 +93,9 @@ export type CommandMappings = { closeTocCommand: CommandData; closeHlt: CommandData; showLaunchBarSubtree: CommandData; - showRevisions: CommandData; + showRevisions: CommandData & { + noteId?: string | null; + }; showLlmChat: CommandData; createAiChat: CommandData; showOptions: CommandData & { @@ -133,6 +136,8 @@ export type CommandMappings = { hideLeftPane: CommandData; showCpuArchWarning: CommandData; showLeftPane: CommandData; + showAttachments: CommandData; + showSearchHistory: CommandData; hoistNote: CommandData & { noteId: string }; leaveProtectedSession: CommandData; enterProtectedSession: CommandData; @@ -173,7 +178,7 @@ export type CommandMappings = { deleteNotes: ContextMenuCommandData; importIntoNote: ContextMenuCommandData; exportNote: ContextMenuCommandData; - searchInSubtree: ContextMenuCommandData; + searchInSubtree: CommandData & { notePath: string; }; moveNoteUp: ContextMenuCommandData; moveNoteDown: ContextMenuCommandData; moveNoteUpInHierarchy: ContextMenuCommandData; @@ -262,6 +267,73 @@ export type CommandMappings = { closeThisNoteSplit: CommandData; moveThisNoteSplit: CommandData & { isMovingLeft: boolean }; jumpToNote: CommandData; + commandPalette: CommandData; + + // Keyboard shortcuts + backInNoteHistory: CommandData; + forwardInNoteHistory: CommandData; + forceSaveRevision: CommandData; + scrollToActiveNote: CommandData; + quickSearch: CommandData; + collapseTree: CommandData; + createNoteAfter: CommandData; + createNoteInto: CommandData; + addNoteAboveToSelection: CommandData; + addNoteBelowToSelection: CommandData; + openNewTab: CommandData; + activateNextTab: CommandData; + activatePreviousTab: CommandData; + openNewWindow: CommandData; + toggleTray: CommandData; + firstTab: CommandData; + secondTab: CommandData; + thirdTab: CommandData; + fourthTab: CommandData; + fifthTab: CommandData; + sixthTab: CommandData; + seventhTab: CommandData; + eigthTab: CommandData; + ninthTab: CommandData; + lastTab: CommandData; + showNoteSource: CommandData; + showSQLConsole: CommandData; + showBackendLog: CommandData; + showCheatsheet: CommandData; + showHelp: CommandData; + addLinkToText: CommandData; + followLinkUnderCursor: CommandData; + insertDateTimeToText: CommandData; + pasteMarkdownIntoText: CommandData; + cutIntoNote: CommandData; + addIncludeNoteToText: CommandData; + editReadOnlyNote: CommandData; + toggleRibbonTabClassicEditor: CommandData; + toggleRibbonTabBasicProperties: CommandData; + toggleRibbonTabBookProperties: CommandData; + toggleRibbonTabFileProperties: CommandData; + toggleRibbonTabImageProperties: CommandData; + toggleRibbonTabOwnedAttributes: CommandData; + toggleRibbonTabInheritedAttributes: CommandData; + toggleRibbonTabPromotedAttributes: CommandData; + toggleRibbonTabNoteMap: CommandData; + toggleRibbonTabNoteInfo: CommandData; + toggleRibbonTabNotePaths: CommandData; + toggleRibbonTabSimilarNotes: CommandData; + toggleRightPane: CommandData; + printActiveNote: CommandData; + exportAsPdf: CommandData; + openNoteExternally: CommandData; + renderActiveNote: CommandData; + unhoist: CommandData; + reloadFrontendApp: CommandData; + openDevTools: CommandData; + findInText: CommandData; + toggleLeftPane: CommandData; + toggleFullscreen: CommandData; + zoomOut: CommandData; + zoomIn: CommandData; + zoomReset: CommandData; + copyWithoutFormatting: CommandData; // Geomap deleteFromMap: { noteId: string }; @@ -299,6 +371,9 @@ export type CommandMappings = { }; refreshTouchBar: CommandData; reloadTextEditor: CommandData; + chooseNoteType: CommandData & { + callback: ChooseNoteTypeCallback + } }; type EventMappings = { diff --git a/apps/client/src/components/entrypoints.ts b/apps/client/src/components/entrypoints.ts index 0ad2c76ed..2e55a9b9d 100644 --- a/apps/client/src/components/entrypoints.ts +++ b/apps/client/src/components/entrypoints.ts @@ -30,13 +30,6 @@ interface CreateChildrenResponse { export default class Entrypoints extends Component { constructor() { super(); - - if (jQuery.hotkeys) { - // hot keys are active also inside inputs and content editables - jQuery.hotkeys.options.filterInputAcceptingElements = false; - jQuery.hotkeys.options.filterContentEditable = false; - jQuery.hotkeys.options.filterTextInputs = false; - } } openDevToolsCommand() { @@ -113,7 +106,9 @@ export default class Entrypoints extends Component { if (win.isFullScreenable()) { win.setFullScreen(!win.isFullScreen()); } - } // outside of electron this is handled by the browser + } else { + document.documentElement.requestFullscreen(); + } } reloadFrontendAppCommand() { diff --git a/apps/client/src/components/note_context.ts b/apps/client/src/components/note_context.ts index 75c66b1bc..1bc4e5498 100644 --- a/apps/client/src/components/note_context.ts +++ b/apps/client/src/components/note_context.ts @@ -325,8 +325,9 @@ class NoteContext extends Component implements EventListener<"entitiesReloaded"> return false; } - // Some book types must always display a note list, even if no children. - if (["calendar", "table", "geoMap"].includes(note.getLabelValue("viewType") ?? "")) { + // Collections must always display a note list, even if no children. + const viewType = note.getLabelValue("viewType") ?? "grid"; + if (!["list", "grid"].includes(viewType)) { return true; } diff --git a/apps/client/src/desktop.ts b/apps/client/src/desktop.ts index 65e2e285a..2791f0577 100644 --- a/apps/client/src/desktop.ts +++ b/apps/client/src/desktop.ts @@ -13,7 +13,6 @@ import type ElectronRemote from "@electron/remote"; import type Electron from "electron"; import "./stylesheets/bootstrap.scss"; import "boxicons/css/boxicons.min.css"; -import "jquery-hotkeys"; import "autocomplete.js/index_jquery.js"; await appContext.earlyInit(); diff --git a/apps/client/src/layouts/mobile_layout.ts b/apps/client/src/layouts/mobile_layout.ts index ba656d445..10b6d2ebe 100644 --- a/apps/client/src/layouts/mobile_layout.ts +++ b/apps/client/src/layouts/mobile_layout.ts @@ -26,6 +26,7 @@ import TabRowWidget from "../widgets/tab_row.js"; import RefreshButton from "../widgets/floating_buttons/refresh_button.js"; import MobileEditorToolbar from "../widgets/ribbon_widgets/mobile_editor_toolbar.js"; import { applyModals } from "./layout_commons.js"; +import CloseZenButton from "../widgets/close_zen_button.js"; const MOBILE_CSS = ` -`; - -export default class AboutDialog extends BasicWidget { - private $appVersion!: JQuery; - private $dbVersion!: JQuery; - private $syncVersion!: JQuery; - private $buildDate!: JQuery; - private $buildRevision!: JQuery; - private $dataDirectory!: JQuery; - - doRender(): void { - this.$widget = $(TPL); - this.$appVersion = this.$widget.find(".app-version"); - this.$dbVersion = this.$widget.find(".db-version"); - this.$syncVersion = this.$widget.find(".sync-version"); - this.$buildDate = this.$widget.find(".build-date"); - this.$buildRevision = this.$widget.find(".build-revision"); - this.$dataDirectory = this.$widget.find(".data-directory"); - } - - async refresh() { - const appInfo = await server.get("app-info"); - - this.$appVersion.text(appInfo.appVersion); - this.$dbVersion.text(appInfo.dbVersion.toString()); - this.$syncVersion.text(appInfo.syncVersion.toString()); - this.$buildDate.text(formatDateTime(appInfo.buildDate)); - this.$buildRevision.text(appInfo.buildRevision); - this.$buildRevision.attr("href", `https://github.com/TriliumNext/Trilium/commit/${appInfo.buildRevision}`); - if (utils.isElectron()) { - this.$dataDirectory.html( - $("", { - href: "#", - class: "tn-link", - text: appInfo.dataDirectory - }).prop("outerHTML") - ); - this.$dataDirectory.find("a").on("click", (event: JQuery.ClickEvent) => { - event.preventDefault(); - openService.openDirectory(appInfo.dataDirectory); - }); - } else { - this.$dataDirectory.text(appInfo.dataDirectory); - } - } - - async openAboutDialogEvent() { - await this.refresh(); - openDialog(this.$widget); - } -} diff --git a/apps/client/src/widgets/dialogs/about.tsx b/apps/client/src/widgets/dialogs/about.tsx new file mode 100644 index 000000000..2b49add70 --- /dev/null +++ b/apps/client/src/widgets/dialogs/about.tsx @@ -0,0 +1,92 @@ +import ReactBasicWidget from "../react/ReactBasicWidget.js"; +import Modal from "../react/Modal.js"; +import { t } from "../../services/i18n.js"; +import { formatDateTime } from "../../utils/formatters.js"; +import server from "../../services/server.js"; +import utils from "../../services/utils.js"; +import openService from "../../services/open.js"; +import { useState } from "preact/hooks"; +import type { CSSProperties } from "preact/compat"; +import type { AppInfo } from "@triliumnext/commons"; +import useTriliumEvent from "../react/hooks.jsx"; + +function AboutDialogComponent() { + let [appInfo, setAppInfo] = useState(null); + let [shown, setShown] = useState(false); + const forceWordBreak: CSSProperties = { wordBreak: "break-all" }; + + useTriliumEvent("openAboutDialog", () => setShown(true)); + + return ( + { + const appInfo = await server.get("app-info"); + setAppInfo(appInfo); + }} + onHidden={() => setShown(false)} + > + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
{t("about.homepage")}https://github.com/TriliumNext/Trilium
{t("about.app_version")}{appInfo?.appVersion}
{t("about.db_version")}{appInfo?.dbVersion}
{t("about.sync_version")}{appInfo?.syncVersion}
{t("about.build_date")} + {appInfo?.buildDate ? formatDateTime(appInfo.buildDate) : ""} +
{t("about.build_revision")} + {appInfo?.buildRevision && {appInfo.buildRevision}} +
{t("about.data_directory")} + {appInfo?.dataDirectory && ()} +
+
+ ); +} + +function DirectoryLink({ directory, style }: { directory: string, style?: CSSProperties }) { + if (utils.isElectron()) { + const onClick = (e: MouseEvent) => { + e.preventDefault(); + openService.openDirectory(directory); + }; + + return + } else { + return {directory}; + } +} + +export default class AboutDialog extends ReactBasicWidget { + + get component() { + return ; + } + +} \ No newline at end of file diff --git a/apps/client/src/widgets/dialogs/add_link.ts b/apps/client/src/widgets/dialogs/add_link.ts deleted file mode 100644 index d7758c92d..000000000 --- a/apps/client/src/widgets/dialogs/add_link.ts +++ /dev/null @@ -1,188 +0,0 @@ -import { t } from "../../services/i18n.js"; -import treeService from "../../services/tree.js"; -import noteAutocompleteService from "../../services/note_autocomplete.js"; -import BasicWidget from "../basic_widget.js"; -import type { Suggestion } from "../../services/note_autocomplete.js"; -import type { default as TextTypeWidget } from "../type_widgets/editable_text.js"; -import type { EventData } from "../../components/app_context.js"; -import { openDialog } from "../../services/dialog.js"; - -const TPL = /*html*/` -`; - -export default class AddLinkDialog extends BasicWidget { - private $form!: JQuery; - private $autoComplete!: JQuery; - private $linkTitle!: JQuery; - private $addLinkTitleSettings!: JQuery; - private $addLinkTitleRadios!: JQuery; - private $addLinkTitleFormGroup!: JQuery; - private textTypeWidget: TextTypeWidget | null = null; - - doRender() { - this.$widget = $(TPL); - this.$form = this.$widget.find(".add-link-form"); - this.$autoComplete = this.$widget.find(".add-link-note-autocomplete"); - this.$linkTitle = this.$widget.find(".link-title"); - this.$addLinkTitleSettings = this.$widget.find(".add-link-title-settings"); - this.$addLinkTitleRadios = this.$widget.find(".add-link-title-radios"); - this.$addLinkTitleFormGroup = this.$widget.find(".add-link-title-form-group"); - - this.$form.on("submit", () => { - if (this.$autoComplete.getSelectedNotePath()) { - this.$widget.modal("hide"); - - const linkTitle = this.getLinkType() === "reference-link" ? null : this.$linkTitle.val() as string; - - this.textTypeWidget?.addLink(this.$autoComplete.getSelectedNotePath()!, linkTitle); - } else if (this.$autoComplete.getSelectedExternalLink()) { - this.$widget.modal("hide"); - - this.textTypeWidget?.addLink(this.$autoComplete.getSelectedExternalLink()!, this.$linkTitle.val() as string, true); - } else { - logError("No link to add."); - } - - return false; - }); - } - - async showAddLinkDialogEvent({ textTypeWidget, text = "" }: EventData<"showAddLinkDialog">) { - this.textTypeWidget = textTypeWidget; - - this.$addLinkTitleSettings.toggle(!this.textTypeWidget.hasSelection()); - - this.$addLinkTitleSettings.find("input[type=radio]").on("change", () => this.updateTitleSettingsVisibility()); - - // with selection hyperlink is implied - if (this.textTypeWidget.hasSelection()) { - this.$addLinkTitleSettings.find("input[value='hyper-link']").prop("checked", true); - } else { - this.$addLinkTitleSettings.find("input[value='reference-link']").prop("checked", true); - } - - this.updateTitleSettingsVisibility(); - - await openDialog(this.$widget); - - this.$autoComplete.val(""); - this.$linkTitle.val(""); - - const setDefaultLinkTitle = async (noteId: string) => { - const noteTitle = await treeService.getNoteTitle(noteId); - this.$linkTitle.val(noteTitle); - }; - - noteAutocompleteService.initNoteAutocomplete(this.$autoComplete, { - allowExternalLinks: true, - allowCreatingNotes: true - }); - - this.$autoComplete.on("autocomplete:noteselected", (event: JQuery.Event, suggestion: Suggestion) => { - if (!suggestion.notePath) { - return false; - } - - this.updateTitleSettingsVisibility(); - - const noteId = treeService.getNoteIdFromUrl(suggestion.notePath); - - if (noteId) { - setDefaultLinkTitle(noteId); - } - }); - - this.$autoComplete.on("autocomplete:externallinkselected", (event: JQuery.Event, suggestion: Suggestion) => { - if (!suggestion.externalLink) { - return false; - } - - this.updateTitleSettingsVisibility(); - - this.$linkTitle.val(suggestion.externalLink); - }); - - this.$autoComplete.on("autocomplete:cursorchanged", (event: JQuery.Event, suggestion: Suggestion) => { - if (suggestion.externalLink) { - this.$linkTitle.val(suggestion.externalLink); - } else { - const noteId = treeService.getNoteIdFromUrl(suggestion.notePath!); - - if (noteId) { - setDefaultLinkTitle(noteId); - } - } - }); - - if (text && text.trim()) { - noteAutocompleteService.setText(this.$autoComplete, text); - } else { - noteAutocompleteService.showRecentNotes(this.$autoComplete); - } - - this.$autoComplete.trigger("focus").trigger("select"); // to be able to quickly remove entered text - } - - private getLinkType() { - if (this.$autoComplete.getSelectedExternalLink()) { - return "external-link"; - } - - return this.$addLinkTitleSettings.find("input[type=radio]:checked").val(); - } - - private updateTitleSettingsVisibility() { - const linkType = this.getLinkType(); - - this.$addLinkTitleFormGroup.toggle(linkType !== "reference-link"); - this.$addLinkTitleRadios.toggle(linkType !== "external-link"); - } -} diff --git a/apps/client/src/widgets/dialogs/add_link.tsx b/apps/client/src/widgets/dialogs/add_link.tsx new file mode 100644 index 000000000..78867304c --- /dev/null +++ b/apps/client/src/widgets/dialogs/add_link.tsx @@ -0,0 +1,162 @@ +import { t } from "../../services/i18n"; +import Modal from "../react/Modal"; +import ReactBasicWidget from "../react/ReactBasicWidget"; +import Button from "../react/Button"; +import FormRadioGroup from "../react/FormRadioGroup"; +import NoteAutocomplete from "../react/NoteAutocomplete"; +import { useRef, useState, useEffect } from "preact/hooks"; +import tree from "../../services/tree"; +import note_autocomplete, { Suggestion } from "../../services/note_autocomplete"; +import { default as TextTypeWidget } from "../type_widgets/editable_text.js"; +import { logError } from "../../services/ws"; +import FormGroup from "../react/FormGroup.js"; +import { refToJQuerySelector } from "../react/react_utils"; +import useTriliumEvent from "../react/hooks"; + +type LinkType = "reference-link" | "external-link" | "hyper-link"; + +function AddLinkDialogComponent() { + const [ textTypeWidget, setTextTypeWidget ] = useState(); + const initialText = useRef(); + const [ linkTitle, setLinkTitle ] = useState(""); + const hasSelection = textTypeWidget?.hasSelection(); + const [ linkType, setLinkType ] = useState(hasSelection ? "hyper-link" : "reference-link"); + const [ suggestion, setSuggestion ] = useState(null); + const [ shown, setShown ] = useState(false); + + useTriliumEvent("showAddLinkDialog", ( { textTypeWidget, text }) => { + setTextTypeWidget(textTypeWidget); + initialText.current = text; + setShown(true); + }); + + async function setDefaultLinkTitle(noteId: string) { + const noteTitle = await tree.getNoteTitle(noteId); + setLinkTitle(noteTitle); + } + + function resetExternalLink() { + if (linkType === "external-link") { + setLinkType("reference-link"); + } + } + + useEffect(() => { + if (!suggestion) { + resetExternalLink(); + return; + } + + if (suggestion.notePath) { + const noteId = tree.getNoteIdFromUrl(suggestion.notePath); + if (noteId) { + setDefaultLinkTitle(noteId); + } + resetExternalLink(); + } + + if (suggestion.externalLink) { + setLinkTitle(suggestion.externalLink); + setLinkType("external-link"); + } + }, [suggestion]); + + function onShown() { + const $autocompleteEl = refToJQuerySelector(autocompleteRef); + if (!initialText.current) { + note_autocomplete.showRecentNotes($autocompleteEl); + } else { + note_autocomplete.setText($autocompleteEl, initialText.current); + } + + // to be able to quickly remove entered text + $autocompleteEl + .trigger("focus") + .trigger("select"); + } + + function onSubmit() { + if (suggestion?.notePath) { + // Handle note link + setShown(false); + textTypeWidget?.addLink(suggestion.notePath, linkType === "reference-link" ? null : linkTitle); + } else if (suggestion?.externalLink) { + // Handle external link + setShown(false); + textTypeWidget?.addLink(suggestion.externalLink, linkTitle, true); + } else { + logError("No link to add."); + } + } + + const autocompleteRef = useRef(null); + + return ( + } + onSubmit={onSubmit} + onShown={onShown} + onHidden={() => { + setSuggestion(null); + setShown(false); + }} + show={shown} + > + + + + + {!hasSelection && ( +
+ {(linkType !== "external-link") && ( + <> + setLinkType(newValue as LinkType)} + /> + + )} + + {(linkType !== "reference-link" && ( +
+
+ +
+ ))} +
+ )} +
+ ); +} + +export default class AddLinkDialog extends ReactBasicWidget { + + get component() { + return ; + } + +} diff --git a/apps/client/src/widgets/dialogs/branch_prefix.ts b/apps/client/src/widgets/dialogs/branch_prefix.ts deleted file mode 100644 index 496700a0c..000000000 --- a/apps/client/src/widgets/dialogs/branch_prefix.ts +++ /dev/null @@ -1,108 +0,0 @@ -import treeService from "../../services/tree.js"; -import server from "../../services/server.js"; -import froca from "../../services/froca.js"; -import toastService from "../../services/toast.js"; -import BasicWidget from "../basic_widget.js"; -import appContext from "../../components/app_context.js"; -import { t } from "../../services/i18n.js"; -import { Modal } from "bootstrap"; -import { openDialog } from "../../services/dialog.js"; - -const TPL = /*html*/``; - -export default class BranchPrefixDialog extends BasicWidget { - private modal!: Modal; - private $form!: JQuery; - private $treePrefixInput!: JQuery; - private $noteTitle!: JQuery; - private branchId: string | null = null; - - doRender() { - this.$widget = $(TPL); - this.modal = Modal.getOrCreateInstance(this.$widget[0]); - this.$form = this.$widget.find(".branch-prefix-form"); - this.$treePrefixInput = this.$widget.find(".branch-prefix-input"); - this.$noteTitle = this.$widget.find(".branch-prefix-note-title"); - - this.$form.on("submit", () => { - this.savePrefix(); - return false; - }); - - this.$widget.on("shown.bs.modal", () => this.$treePrefixInput.trigger("focus")); - } - - async refresh(notePath: string) { - const { noteId, parentNoteId } = treeService.getNoteIdAndParentIdFromUrl(notePath); - - if (!noteId || !parentNoteId) { - return; - } - - const newBranchId = await froca.getBranchId(parentNoteId, noteId); - if (!newBranchId) { - return; - } - this.branchId = newBranchId; - - const branch = froca.getBranch(this.branchId); - if (!branch || branch.noteId === "root") { - return; - } - - const parentNote = await froca.getNote(branch.parentNoteId); - if (!parentNote || parentNote.type === "search") { - return; - } - - this.$treePrefixInput.val(branch.prefix || ""); - - const noteTitle = await treeService.getNoteTitle(noteId); - this.$noteTitle.text(` - ${noteTitle}`); - } - - async editBranchPrefixEvent() { - const notePath = appContext.tabManager.getActiveContextNotePath(); - if (!notePath) { - return; - } - - await this.refresh(notePath); - openDialog(this.$widget); - } - - async savePrefix() { - const prefix = this.$treePrefixInput.val(); - - await server.put(`branches/${this.branchId}/set-prefix`, { prefix: prefix }); - - this.modal.hide(); - - toastService.showMessage(t("branch_prefix.branch_prefix_saved")); - } -} diff --git a/apps/client/src/widgets/dialogs/branch_prefix.tsx b/apps/client/src/widgets/dialogs/branch_prefix.tsx new file mode 100644 index 000000000..67ab3d62b --- /dev/null +++ b/apps/client/src/widgets/dialogs/branch_prefix.tsx @@ -0,0 +1,89 @@ +import { useRef, useState } from "preact/hooks"; +import appContext from "../../components/app_context.js"; +import { t } from "../../services/i18n.js"; +import server from "../../services/server.js"; +import toast from "../../services/toast.js"; +import Modal from "../react/Modal.jsx"; +import ReactBasicWidget from "../react/ReactBasicWidget.js"; +import froca from "../../services/froca.js"; +import tree from "../../services/tree.js"; +import Button from "../react/Button.jsx"; +import FormGroup from "../react/FormGroup.js"; +import useTriliumEvent from "../react/hooks.jsx"; +import FBranch from "../../entities/fbranch.js"; + +function BranchPrefixDialogComponent() { + const [ shown, setShown ] = useState(false); + const [ branch, setBranch ] = useState(); + const [ prefix, setPrefix ] = useState(branch?.prefix ?? ""); + const branchInput = useRef(null); + + useTriliumEvent("editBranchPrefix", async () => { + const notePath = appContext.tabManager.getActiveContextNotePath(); + if (!notePath) { + return; + } + + const { noteId, parentNoteId } = tree.getNoteIdAndParentIdFromUrl(notePath); + + if (!noteId || !parentNoteId) { + return; + } + + const newBranchId = await froca.getBranchId(parentNoteId, noteId); + if (!newBranchId) { + return; + } + const parentNote = await froca.getNote(parentNoteId); + if (!parentNote || parentNote.type === "search") { + return; + } + + setBranch(froca.getBranch(newBranchId)); + setShown(true); + }); + + async function onSubmit() { + if (!branch) { + return; + } + + savePrefix(branch.branchId, prefix); + setShown(false); + } + + return ( + branchInput.current?.focus()} + onHidden={() => setShown(false)} + onSubmit={onSubmit} + helpPageId="TBwsyfadTA18" + footer={ - - - - - -`; - -export default class BulkActionsDialog extends BasicWidget { - private $includeDescendants!: JQuery; - private $affectedNoteCount!: JQuery; - private $availableActionList!: JQuery; - private $existingActionList!: JQuery; - private $executeButton!: JQuery; - private selectedOrActiveNoteIds: string[] | null = null; - - doRender() { - this.$widget = $(TPL); - this.$includeDescendants = this.$widget.find(".include-descendants"); - this.$includeDescendants.on("change", () => this.refresh()); - - this.$affectedNoteCount = this.$widget.find(".affected-note-count"); - - this.$availableActionList = this.$widget.find(".bulk-available-action-list"); - this.$existingActionList = this.$widget.find(".bulk-existing-action-list"); - - this.$widget.on("click", "[data-action-add]", async (event) => { - const actionName = $(event.target).attr("data-action-add"); - if (!actionName) { - return; - } - - await bulkActionService.addAction("_bulkAction", actionName); - await this.refresh(); - }); - - this.$executeButton = this.$widget.find(".execute-bulk-actions"); - this.$executeButton.on("click", async () => { - await server.post("bulk-action/execute", { - noteIds: this.selectedOrActiveNoteIds, - includeDescendants: this.$includeDescendants.is(":checked") - }); - - toastService.showMessage(t("bulk_actions.bulk_actions_executed"), 3000); - closeActiveDialog(); - }); - } - - async refresh() { - this.renderAvailableActions(); - - if (!this.selectedOrActiveNoteIds) { - return; - } - - const { affectedNoteCount } = await server.post("bulk-action/affected-notes", { - noteIds: this.selectedOrActiveNoteIds, - includeDescendants: this.$includeDescendants.is(":checked") - }) as { affectedNoteCount: number }; - - this.$affectedNoteCount.text(affectedNoteCount); - - const bulkActionNote = await froca.getNote("_bulkAction"); - if (!bulkActionNote) { - return; - } - - const actions = bulkActionService.parseActions(bulkActionNote); - - this.$existingActionList.empty(); - - if (actions.length > 0) { - this.$existingActionList.append(...actions.map((action) => action.render()).filter((action) => action !== null)); - } else { - this.$existingActionList.append($("

").text(t("bulk_actions.none_yet"))); - } - } - - renderAvailableActions() { - this.$availableActionList.empty(); - - for (const actionGroup of bulkActionService.ACTION_GROUPS) { - const $actionGroupList = $(""); - const $actionGroup = $("") - .append($("").text(`${actionGroup.title}: `)) - .append($actionGroupList); - - for (const action of actionGroup.actions) { - $actionGroupList.append($(' - - -

- - -
- - -`; - -export default class CloneToDialog extends BasicWidget { - private $form!: JQuery; - private $noteAutoComplete!: JQuery; - private $clonePrefix!: JQuery; - private $noteList!: JQuery; - private clonedNoteIds: string[] | null = null; - - constructor() { - super(); - } - - doRender() { - this.$widget = $(TPL); - this.$form = this.$widget.find(".clone-to-form"); - this.$noteAutoComplete = this.$widget.find(".clone-to-note-autocomplete"); - this.$clonePrefix = this.$widget.find(".clone-prefix"); - this.$noteList = this.$widget.find(".clone-to-note-list"); - - this.$form.on("submit", () => { - const notePath = this.$noteAutoComplete.getSelectedNotePath(); - - if (notePath) { - this.$widget.modal("hide"); - this.cloneNotesTo(notePath); - } else { - logError(t("clone_to.no_path_to_clone_to")); - } - - return false; - }); - } - - async cloneNoteIdsToEvent({ noteIds }: EventData<"cloneNoteIdsTo">) { - if (!noteIds || noteIds.length === 0) { - noteIds = [appContext.tabManager.getActiveContextNoteId() ?? ""]; - } - - this.clonedNoteIds = []; - - for (const noteId of noteIds) { - if (!this.clonedNoteIds.includes(noteId)) { - this.clonedNoteIds.push(noteId); - } - } - - openDialog(this.$widget); - this.$noteAutoComplete.val("").trigger("focus"); - this.$noteList.empty(); - - for (const noteId of this.clonedNoteIds) { - const note = await froca.getNote(noteId); - if (!note) { - continue; - } - this.$noteList.append($("
  • ").text(note.title)); - } - - noteAutocompleteService.initNoteAutocomplete(this.$noteAutoComplete); - noteAutocompleteService.showRecentNotes(this.$noteAutoComplete); - } - - async cloneNotesTo(notePath: string) { - const { noteId, parentNoteId } = treeService.getNoteIdAndParentIdFromUrl(notePath); - if (!noteId || !parentNoteId) { - return; - } - - const targetBranchId = await froca.getBranchId(parentNoteId, noteId); - if (!targetBranchId || !this.clonedNoteIds) { - return; - } - - for (const cloneNoteId of this.clonedNoteIds) { - await branchService.cloneNoteToBranch(cloneNoteId, targetBranchId, this.$clonePrefix.val() as string); - - const clonedNote = await froca.getNote(cloneNoteId); - const targetBranch = froca.getBranch(targetBranchId); - if (!clonedNote || !targetBranch) { - continue; - } - const targetNote = await targetBranch.getNote(); - if (!targetNote) { - continue; - } - - toastService.showMessage(t("clone_to.note_cloned", { clonedTitle: clonedNote.title, targetTitle: targetNote.title })); - } - } -} diff --git a/apps/client/src/widgets/dialogs/clone_to.tsx b/apps/client/src/widgets/dialogs/clone_to.tsx new file mode 100644 index 000000000..f8929f00d --- /dev/null +++ b/apps/client/src/widgets/dialogs/clone_to.tsx @@ -0,0 +1,120 @@ +import { useRef, useState } from "preact/hooks"; +import appContext from "../../components/app_context"; +import { t } from "../../services/i18n"; +import Modal from "../react/Modal"; +import ReactBasicWidget from "../react/ReactBasicWidget"; +import NoteAutocomplete from "../react/NoteAutocomplete"; +import froca from "../../services/froca"; +import FormGroup from "../react/FormGroup"; +import FormTextBox from "../react/FormTextBox"; +import Button from "../react/Button"; +import { Suggestion, triggerRecentNotes } from "../../services/note_autocomplete"; +import { logError } from "../../services/ws"; +import tree from "../../services/tree"; +import branches from "../../services/branches"; +import toast from "../../services/toast"; +import NoteList from "../react/NoteList"; +import useTriliumEvent from "../react/hooks"; + +function CloneToDialogComponent() { + const [ clonedNoteIds, setClonedNoteIds ] = useState(); + const [ prefix, setPrefix ] = useState(""); + const [ suggestion, setSuggestion ] = useState(null); + const [ shown, setShown ] = useState(false); + const autoCompleteRef = useRef(null); + + useTriliumEvent("cloneNoteIdsTo", ({ noteIds }) => { + if (!noteIds || noteIds.length === 0) { + noteIds = [appContext.tabManager.getActiveContextNoteId() ?? ""]; + } + + const clonedNoteIds: string[] = []; + + for (const noteId of noteIds) { + if (!clonedNoteIds.includes(noteId)) { + clonedNoteIds.push(noteId); + } + } + + setClonedNoteIds(clonedNoteIds); + setShown(true); + }); + + function onSubmit() { + if (!clonedNoteIds) { + return; + } + + const notePath = suggestion?.notePath; + if (!notePath) { + logError(t("clone_to.no_path_to_clone_to")); + return; + } + + setShown(false); + cloneNotesTo(notePath, clonedNoteIds, prefix); + } + + return ( + } + onSubmit={onSubmit} + onShown={() => triggerRecentNotes(autoCompleteRef.current)} + onHidden={() => setShown(false)} + show={shown} + > +
    {t("clone_to.notes_to_clone")}
    + + + + + + + +
    + ) +} + +export default class CloneToDialog extends ReactBasicWidget { + + get component() { + return ; + } + +} + +async function cloneNotesTo(notePath: string, clonedNoteIds: string[], prefix?: string) { + const { noteId, parentNoteId } = tree.getNoteIdAndParentIdFromUrl(notePath); + if (!noteId || !parentNoteId) { + return; + } + + const targetBranchId = await froca.getBranchId(parentNoteId, noteId); + if (!targetBranchId || !clonedNoteIds) { + return; + } + + for (const cloneNoteId of clonedNoteIds) { + await branches.cloneNoteToBranch(cloneNoteId, targetBranchId, prefix); + + const clonedNote = await froca.getNote(cloneNoteId); + const targetBranch = froca.getBranch(targetBranchId); + if (!clonedNote || !targetBranch) { + continue; + } + const targetNote = await targetBranch.getNote(); + if (!targetNote) { + continue; + } + + toast.showMessage(t("clone_to.note_cloned", { clonedTitle: clonedNote.title, targetTitle: targetNote.title })); + } +} \ No newline at end of file diff --git a/apps/client/src/widgets/dialogs/confirm.ts b/apps/client/src/widgets/dialogs/confirm.ts deleted file mode 100644 index 8fa25a728..000000000 --- a/apps/client/src/widgets/dialogs/confirm.ts +++ /dev/null @@ -1,151 +0,0 @@ -import BasicWidget from "../basic_widget.js"; -import { t } from "../../services/i18n.js"; -import { Modal } from "bootstrap"; - -const DELETE_NOTE_BUTTON_CLASS = "confirm-dialog-delete-note"; - -const TPL = /*html*/` -`; - -export type ConfirmDialogResult = false | ConfirmDialogOptions; -export type ConfirmDialogCallback = (val?: ConfirmDialogResult) => void; - -export interface ConfirmDialogOptions { - confirmed: boolean; - isDeleteNoteChecked: boolean; -} - -// For "showConfirmDialog" - -export interface ConfirmWithMessageOptions { - message: string | HTMLElement | JQuery; - callback: ConfirmDialogCallback; -} - -export interface ConfirmWithTitleOptions { - title: string; - callback: ConfirmDialogCallback; -} - -export default class ConfirmDialog extends BasicWidget { - private resolve: ConfirmDialogCallback | null; - - private modal!: Modal; - private $originallyFocused!: JQuery | null; - private $confirmContent!: JQuery; - private $okButton!: JQuery; - private $cancelButton!: JQuery; - private $custom!: JQuery; - - constructor() { - super(); - - this.resolve = null; - this.$originallyFocused = null; // element focused before the dialog was opened, so we can return to it afterward - } - - doRender() { - this.$widget = $(TPL); - this.modal = Modal.getOrCreateInstance(this.$widget[0]); - this.$confirmContent = this.$widget.find(".confirm-dialog-content"); - this.$okButton = this.$widget.find(".confirm-dialog-ok-button"); - this.$cancelButton = this.$widget.find(".confirm-dialog-cancel-button"); - this.$custom = this.$widget.find(".confirm-dialog-custom"); - - this.$widget.on("shown.bs.modal", () => this.$okButton.trigger("focus")); - - this.$widget.on("hidden.bs.modal", () => { - if (this.resolve) { - this.resolve(false); - } - - if (this.$originallyFocused) { - this.$originallyFocused.trigger("focus"); - this.$originallyFocused = null; - } - }); - - this.$cancelButton.on("click", () => this.doResolve(false)); - this.$okButton.on("click", () => this.doResolve(true)); - } - - showConfirmDialogEvent({ message, callback }: ConfirmWithMessageOptions) { - this.$originallyFocused = $(":focus"); - - this.$custom.hide(); - - glob.activeDialog = this.$widget; - - if (typeof message === "string") { - message = $("
    ").text(message); - } - - this.$confirmContent.empty().append(message); - - this.modal.show(); - - this.resolve = callback; - } - - showConfirmDeleteNoteBoxWithNoteDialogEvent({ title, callback }: ConfirmWithTitleOptions) { - glob.activeDialog = this.$widget; - - this.$confirmContent.text(`${t("confirm.are_you_sure_remove_note", { title: title })}`); - - this.$custom - .empty() - .append("
    ") - .append( - $("
    ") - .addClass("form-check") - .append( - $("
    - - -
    - -`; - -export default class DeleteNotesDialog extends BasicWidget { - private branchIds: string[] | null; - private resolve!: (options: ResolveOptions) => void; - - private $content!: JQuery; - private $okButton!: JQuery; - private $cancelButton!: JQuery; - private $deleteNotesList!: JQuery; - private $brokenRelationsList!: JQuery; - private $deletedNotesCount!: JQuery; - private $noNoteToDeleteWrapper!: JQuery; - private $deleteNotesListWrapper!: JQuery; - private $brokenRelationsListWrapper!: JQuery; - private $brokenRelationsCount!: JQuery; - private $deleteAllClones!: JQuery; - private $eraseNotes!: JQuery; - - private forceDeleteAllClones?: boolean; - - constructor() { - super(); - - this.branchIds = null; - } - - doRender() { - this.$widget = $(TPL); - this.$content = this.$widget.find(".recent-changes-content"); - this.$okButton = this.$widget.find(".delete-notes-dialog-ok-button"); - this.$cancelButton = this.$widget.find(".delete-notes-dialog-cancel-button"); - this.$deleteNotesList = this.$widget.find(".delete-notes-list"); - this.$brokenRelationsList = this.$widget.find(".broken-relations-list"); - this.$deletedNotesCount = this.$widget.find(".deleted-notes-count"); - this.$noNoteToDeleteWrapper = this.$widget.find(".no-note-to-delete-wrapper"); - this.$deleteNotesListWrapper = this.$widget.find(".delete-notes-list-wrapper"); - this.$brokenRelationsListWrapper = this.$widget.find(".broken-relations-wrapper"); - this.$brokenRelationsCount = this.$widget.find(".broke-relations-count"); - this.$deleteAllClones = this.$widget.find(".delete-all-clones"); - this.$eraseNotes = this.$widget.find(".erase-notes"); - - this.$widget.on("shown.bs.modal", () => this.$okButton.trigger("focus")); - - this.$cancelButton.on("click", () => { - closeActiveDialog(); - - this.resolve({ proceed: false }); - }); - - this.$okButton.on("click", () => { - closeActiveDialog(); - - this.resolve({ - proceed: true, - deleteAllClones: this.forceDeleteAllClones || this.isDeleteAllClonesChecked(), - eraseNotes: this.isEraseNotesChecked() - }); - }); - - this.$deleteAllClones.on("click", () => this.renderDeletePreview()); - } - - async renderDeletePreview() { - const response = await server.post("delete-notes-preview", { - branchIdsToDelete: this.branchIds, - deleteAllClones: this.forceDeleteAllClones || this.isDeleteAllClonesChecked() - }); - - this.$deleteNotesList.empty(); - this.$brokenRelationsList.empty(); - - this.$deleteNotesListWrapper.toggle(response.noteIdsToBeDeleted.length > 0); - this.$noNoteToDeleteWrapper.toggle(response.noteIdsToBeDeleted.length === 0); - - for (const note of await froca.getNotes(response.noteIdsToBeDeleted)) { - this.$deleteNotesList.append($("
  • ").append(await linkService.createLink(note.noteId, { showNotePath: true }))); - } - - this.$deletedNotesCount.text(response.noteIdsToBeDeleted.length); - - this.$brokenRelationsListWrapper.toggle(response.brokenRelations.length > 0); - this.$brokenRelationsCount.text(response.brokenRelations.length); - - await froca.getNotes(response.brokenRelations.map((br) => br.noteId)); - - for (const attr of response.brokenRelations) { - this.$brokenRelationsList.append( - $("
  • ").html( - t("delete_notes.deleted_relation_text", { - note: (await linkService.createLink(attr.value)).html(), - relation: `${attr.name}`, - source: (await linkService.createLink(attr.noteId)).html() - }) - ) - ); - } - } - - async showDeleteNotesDialogEvent({ branchIdsToDelete, callback, forceDeleteAllClones }: ShowDeleteNotesDialogOpts) { - this.branchIds = branchIdsToDelete; - this.forceDeleteAllClones = forceDeleteAllClones; - - await this.renderDeletePreview(); - - openDialog(this.$widget); - - this.$deleteAllClones.prop("checked", !!forceDeleteAllClones).prop("disabled", !!forceDeleteAllClones); - - this.$eraseNotes.prop("checked", false); - - this.resolve = callback; - } - - isDeleteAllClonesChecked() { - return this.$deleteAllClones.is(":checked"); - } - - isEraseNotesChecked() { - return this.$eraseNotes.is(":checked"); - } -} diff --git a/apps/client/src/widgets/dialogs/delete_notes.tsx b/apps/client/src/widgets/dialogs/delete_notes.tsx new file mode 100644 index 000000000..54afbb752 --- /dev/null +++ b/apps/client/src/widgets/dialogs/delete_notes.tsx @@ -0,0 +1,181 @@ +import { useRef, useState, useEffect } from "preact/hooks"; +import { t } from "../../services/i18n.js"; +import FormCheckbox from "../react/FormCheckbox.js"; +import Modal from "../react/Modal.js"; +import ReactBasicWidget from "../react/ReactBasicWidget.js"; +import type { DeleteNotesPreview } from "@triliumnext/commons"; +import server from "../../services/server.js"; +import froca from "../../services/froca.js"; +import FNote from "../../entities/fnote.js"; +import link from "../../services/link.js"; +import Button from "../react/Button.jsx"; +import Alert from "../react/Alert.jsx"; +import useTriliumEvent from "../react/hooks.jsx"; + +export interface ResolveOptions { + proceed: boolean; + deleteAllClones?: boolean; + eraseNotes?: boolean; +} + +interface ShowDeleteNotesDialogOpts { + branchIdsToDelete?: string[]; + callback?: (opts: ResolveOptions) => void; + forceDeleteAllClones?: boolean; +} + +interface BrokenRelationData { + note: string; + relation: string; + source: string; +} + +function DeleteNotesDialogComponent() { + const [ opts, setOpts ] = useState({}); + const [ deleteAllClones, setDeleteAllClones ] = useState(false); + const [ eraseNotes, setEraseNotes ] = useState(!!opts.forceDeleteAllClones); + const [ brokenRelations, setBrokenRelations ] = useState([]); + const [ noteIdsToBeDeleted, setNoteIdsToBeDeleted ] = useState([]); + const [ shown, setShown ] = useState(false); + const okButtonRef = useRef(null); + + useTriliumEvent("showDeleteNotesDialog", (opts) => { + setOpts(opts); + setShown(true); + }) + + useEffect(() => { + const { branchIdsToDelete, forceDeleteAllClones } = opts; + if (!branchIdsToDelete || branchIdsToDelete.length === 0) { + return; + } + + server.post("delete-notes-preview", { + branchIdsToDelete, + deleteAllClones: forceDeleteAllClones || deleteAllClones + }).then(response => { + setBrokenRelations(response.brokenRelations); + setNoteIdsToBeDeleted(response.noteIdsToBeDeleted); + }); + }, [ opts, deleteAllClones ]); + + return ( + okButtonRef.current?.focus()} + onHidden={() => { + opts.callback?.({ proceed: false }) + setShown(false); + }} + footer={<> + - -
    - - -
    - - -`; - -export default class ExportDialog extends BasicWidget { - - private taskId: string; - private branchId: string | null; - private modal?: Modal; - private $form!: JQuery; - private $noteTitle!: JQuery; - private $subtreeFormats!: JQuery; - private $singleFormats!: JQuery; - private $subtreeType!: JQuery; - private $singleType!: JQuery; - private $exportButton!: JQuery; - private $opmlVersions!: JQuery; - - constructor() { - super(); - - this.taskId = ""; - this.branchId = null; - } - - doRender() { - this.$widget = $(TPL); - this.modal = Modal.getOrCreateInstance(this.$widget[0]); - this.$form = this.$widget.find(".export-form"); - this.$noteTitle = this.$widget.find(".export-note-title"); - this.$subtreeFormats = this.$widget.find(".export-subtree-formats"); - this.$singleFormats = this.$widget.find(".export-single-formats"); - this.$subtreeType = this.$widget.find(".export-type-subtree"); - this.$singleType = this.$widget.find(".export-type-single"); - this.$exportButton = this.$widget.find(".export-button"); - this.$opmlVersions = this.$widget.find(".opml-versions"); - - this.$form.on("submit", () => { - this.modal?.hide(); - - const exportType = this.$widget.find("input[name='export-type']:checked").val(); - - if (!exportType) { - toastService.showError(t("export.choose_export_type")); - return; - } - - const exportFormat = exportType === "subtree" ? this.$widget.find("input[name=export-subtree-format]:checked").val() : this.$widget.find("input[name=export-single-format]:checked").val(); - - const exportVersion = exportFormat === "opml" ? this.$widget.find("input[name='opml-version']:checked").val() : "1.0"; - - if (this.branchId) { - this.exportBranch(this.branchId, String(exportType), String(exportFormat), String(exportVersion)); - } - - return false; - }); - - this.$widget.find("input[name=export-type]").on("change", (e) => { - if ((e.currentTarget as HTMLInputElement).value === "subtree") { - if (this.$widget.find("input[name=export-subtree-format]:checked").length === 0) { - this.$widget.find("input[name=export-subtree-format]:first").prop("checked", true); - } - - this.$subtreeFormats.slideDown(); - this.$singleFormats.slideUp(); - } else { - if (this.$widget.find("input[name=export-single-format]:checked").length === 0) { - this.$widget.find("input[name=export-single-format]:first").prop("checked", true); - } - - this.$subtreeFormats.slideUp(); - this.$singleFormats.slideDown(); - } - }); - - this.$widget.find("input[name=export-subtree-format]").on("change", (e) => { - if ((e.currentTarget as HTMLInputElement).value === "opml") { - this.$opmlVersions.slideDown(); - } else { - this.$opmlVersions.slideUp(); - } - }); - } - - async showExportDialogEvent({ notePath, defaultType }: EventData<"showExportDialog">) { - this.taskId = ""; - this.$exportButton.removeAttr("disabled"); - - if (defaultType === "subtree") { - this.$subtreeType.prop("checked", true).trigger("change"); - - this.$widget.find("input[name=export-subtree-format]:checked").trigger("change"); - } else if (defaultType === "single") { - this.$singleType.prop("checked", true).trigger("change"); - } else { - throw new Error(`Unrecognized type '${defaultType}'`); - } - - this.$widget.find(".opml-v2").prop("checked", true); // setting default - - openDialog(this.$widget); - - const { noteId, parentNoteId } = treeService.getNoteIdAndParentIdFromUrl(notePath); - - if (parentNoteId) { - this.branchId = await froca.getBranchId(parentNoteId, noteId); - } - if (noteId) { - this.$noteTitle.text(await treeService.getNoteTitle(noteId)); - } - } - - exportBranch(branchId: string, type: string, format: string, version: string) { - this.taskId = utils.randomString(10); - - const url = openService.getUrlForDownload(`api/branches/${branchId}/export/${type}/${format}/${version}/${this.taskId}`); - - openService.download(url); - } -} - -ws.subscribeToMessages(async (message) => { - function makeToast(id: string, message: string): ToastOptions { - return { - id: id, - title: t("export.export_status"), - message: message, - icon: "arrow-square-up-right" - }; - } - - if (message.taskType !== "export") { - return; - } - - if (message.type === "taskError") { - toastService.closePersistent(message.taskId); - toastService.showError(message.message); - } else if (message.type === "taskProgressCount") { - toastService.showPersistent(makeToast(message.taskId, t("export.export_in_progress", { progressCount: message.progressCount }))); - } else if (message.type === "taskSucceeded") { - const toast = makeToast(message.taskId, t("export.export_finished_successfully")); - toast.closeAfter = 5000; - - toastService.showPersistent(toast); - } -}); diff --git a/apps/client/src/widgets/dialogs/export.tsx b/apps/client/src/widgets/dialogs/export.tsx new file mode 100644 index 000000000..3543e3b35 --- /dev/null +++ b/apps/client/src/widgets/dialogs/export.tsx @@ -0,0 +1,167 @@ +import { useState } from "preact/hooks"; +import { t } from "../../services/i18n"; +import tree from "../../services/tree"; +import Button from "../react/Button"; +import FormRadioGroup from "../react/FormRadioGroup"; +import Modal from "../react/Modal"; +import ReactBasicWidget from "../react/ReactBasicWidget"; +import "./export.css"; +import ws from "../../services/ws"; +import toastService, { ToastOptions } from "../../services/toast"; +import utils from "../../services/utils"; +import open from "../../services/open"; +import froca from "../../services/froca"; +import useTriliumEvent from "../react/hooks"; + +interface ExportDialogProps { + branchId?: string | null; + noteTitle?: string; + defaultType?: "subtree" | "single"; +} + +function ExportDialogComponent() { + const [ opts, setOpts ] = useState(); + const [ exportType, setExportType ] = useState(opts?.defaultType ?? "subtree"); + const [ subtreeFormat, setSubtreeFormat ] = useState("html"); + const [ singleFormat, setSingleFormat ] = useState("html"); + const [ opmlVersion, setOpmlVersion ] = useState("2.0"); + const [ shown, setShown ] = useState(false); + + useTriliumEvent("showExportDialog", async ({ notePath, defaultType }) => { + const { noteId, parentNoteId } = tree.getNoteIdAndParentIdFromUrl(notePath); + if (!parentNoteId) { + return; + } + + const branchId = await froca.getBranchId(parentNoteId, noteId); + + setOpts({ + noteTitle: noteId && await tree.getNoteTitle(noteId), + defaultType, + branchId + }); + setShown(true); + }); + + return ( + { + if (!opts || !opts.branchId) { + return; + } + + const format = (exportType === "subtree" ? subtreeFormat : singleFormat); + const version = (format === "opml" ? opmlVersion : "1.0"); + exportBranch(opts.branchId, exportType, format, version); + setShown(false); + }} + onHidden={() => setShown(false)} + footer={ - - - - -`; - -export default class HelpDialog extends BasicWidget { - doRender() { - this.$widget = $(TPL); - } - - showCheatsheetEvent() { - openDialog(this.$widget); - } -} diff --git a/apps/client/src/widgets/dialogs/help.tsx b/apps/client/src/widgets/dialogs/help.tsx new file mode 100644 index 000000000..e4b72750d --- /dev/null +++ b/apps/client/src/widgets/dialogs/help.tsx @@ -0,0 +1,171 @@ +import ReactBasicWidget from "../react/ReactBasicWidget.js"; +import Modal from "../react/Modal.jsx"; +import { t } from "../../services/i18n.js"; +import { ComponentChildren } from "preact"; +import { CommandNames } from "../../components/app_context.js"; +import RawHtml from "../react/RawHtml.jsx"; +import { useEffect, useState } from "preact/hooks"; +import keyboard_actions from "../../services/keyboard_actions.js"; +import useTriliumEvent from "../react/hooks.jsx"; + +function HelpDialogComponent() { + const [ shown, setShown ] = useState(false); + useTriliumEvent("showCheatsheet", () => setShown(true)); + + return ( + setShown(false)} + show={shown} + > +
    + +
      + + + + + + + + +
    +
    + + +
      + + +
    + +
    {t("help.onlyInDesktop")}
    +
      + + + + +
    +
    + + +
      + + + +
    +
    + + +
      + + + + + + + + + +
    +
    + + +
      + + + + + + +
    +
    + + +
      +
    • +
    • +
    • +
    • +
    +
    + + +
      + + + +
    +
    + + +
      + + +
    +
    +
    +
    + ); +} + +function KeyboardShortcut({ commands, description }: { commands: CommandNames | CommandNames[], description: string }) { + const [ shortcuts, setShortcuts ] = useState([]); + + useEffect(() => { + (async () => { + const shortcuts: string[] = []; + for (const command of Array.isArray(commands) ? commands : [commands]) { + const action = await keyboard_actions.getAction(command); + if (action) { + shortcuts.push(...(action.effectiveShortcuts ?? [])); + } + } + + if (shortcuts.length === 0) { + shortcuts.push(t("help.notSet")); + } + + setShortcuts(shortcuts); + })(); + }, [commands]); + + return FixedKeyboardShortcut({ + keys: shortcuts, + description + }); +} + +function FixedKeyboardShortcut({ keys, description }: { keys?: string[], description: string }) { + return ( +
  • + {keys && keys.map((key, index) => + <> + {key} + {index < keys.length - 1 ? ", " : "" } + + )} - +
  • + ); +} + +function Card({ title, children }: { title: string, children: ComponentChildren }) { + return ( +
    +
    +
    {title}
    + +

    + {children} +

    +
    +
    + ) +} + +export default class HelpDialog extends ReactBasicWidget { + + get component() { + return ; + } + +} diff --git a/apps/client/src/widgets/dialogs/import.ts b/apps/client/src/widgets/dialogs/import.ts deleted file mode 100644 index c8cd66a27..000000000 --- a/apps/client/src/widgets/dialogs/import.ts +++ /dev/null @@ -1,180 +0,0 @@ -import { escapeQuotes } from "../../services/utils.js"; -import treeService from "../../services/tree.js"; -import importService, { type UploadFilesOptions } from "../../services/import.js"; -import options from "../../services/options.js"; -import BasicWidget from "../basic_widget.js"; -import { t } from "../../services/i18n.js"; -import { Modal, Tooltip } from "bootstrap"; -import type { EventData } from "../../components/app_context.js"; -import { openDialog } from "../../services/dialog.js"; - -const TPL = /*html*/` -`; - -export default class ImportDialog extends BasicWidget { - - private parentNoteId: string | null; - - private $form!: JQuery; - private $noteTitle!: JQuery; - private $fileUploadInput!: JQuery; - private $importButton!: JQuery; - private $safeImportCheckbox!: JQuery; - private $shrinkImagesCheckbox!: JQuery; - private $textImportedAsTextCheckbox!: JQuery; - private $codeImportedAsCodeCheckbox!: JQuery; - private $explodeArchivesCheckbox!: JQuery; - private $replaceUnderscoresWithSpacesCheckbox!: JQuery; - - constructor() { - super(); - - this.parentNoteId = null; - } - - doRender() { - this.$widget = $(TPL); - Modal.getOrCreateInstance(this.$widget[0]); - - this.$form = this.$widget.find(".import-form"); - this.$noteTitle = this.$widget.find(".import-note-title"); - this.$fileUploadInput = this.$widget.find(".import-file-upload-input"); - this.$importButton = this.$widget.find(".import-button"); - this.$safeImportCheckbox = this.$widget.find(".safe-import-checkbox"); - this.$shrinkImagesCheckbox = this.$widget.find(".shrink-images-checkbox"); - this.$textImportedAsTextCheckbox = this.$widget.find(".text-imported-as-text-checkbox"); - this.$codeImportedAsCodeCheckbox = this.$widget.find(".code-imported-as-code-checkbox"); - this.$explodeArchivesCheckbox = this.$widget.find(".explode-archives-checkbox"); - this.$replaceUnderscoresWithSpacesCheckbox = this.$widget.find(".replace-underscores-with-spaces-checkbox"); - - this.$form.on("submit", () => { - // disabling so that import is not triggered again. - this.$importButton.attr("disabled", "disabled"); - - if (this.parentNoteId) { - this.importIntoNote(this.parentNoteId); - } - - return false; - }); - - this.$fileUploadInput.on("change", () => { - if (this.$fileUploadInput.val()) { - this.$importButton.removeAttr("disabled"); - } else { - this.$importButton.attr("disabled", "disabled"); - } - }); - - let _ = [...this.$widget.find('[data-bs-toggle="tooltip"]')].forEach((element) => { - Tooltip.getOrCreateInstance(element, { - html: true - }); - }); - } - - async showImportDialogEvent({ noteId }: EventData<"showImportDialog">) { - this.parentNoteId = noteId; - - this.$fileUploadInput.val("").trigger("change"); // to trigger Import button disabling listener below - - this.$safeImportCheckbox.prop("checked", true); - this.$shrinkImagesCheckbox.prop("checked", options.is("compressImages")); - this.$textImportedAsTextCheckbox.prop("checked", true); - this.$codeImportedAsCodeCheckbox.prop("checked", true); - this.$explodeArchivesCheckbox.prop("checked", true); - this.$replaceUnderscoresWithSpacesCheckbox.prop("checked", true); - - this.$noteTitle.text(await treeService.getNoteTitle(this.parentNoteId)); - - openDialog(this.$widget); - } - - async importIntoNote(parentNoteId: string) { - const files = Array.from(this.$fileUploadInput[0].files ?? []); // shallow copy since we're resetting the upload button below - - const boolToString = ($el: JQuery) => ($el.is(":checked") ? "true" : "false"); - - const options: UploadFilesOptions = { - safeImport: boolToString(this.$safeImportCheckbox), - shrinkImages: boolToString(this.$shrinkImagesCheckbox), - textImportedAsText: boolToString(this.$textImportedAsTextCheckbox), - codeImportedAsCode: boolToString(this.$codeImportedAsCodeCheckbox), - explodeArchives: boolToString(this.$explodeArchivesCheckbox), - replaceUnderscoresWithSpaces: boolToString(this.$replaceUnderscoresWithSpacesCheckbox) - }; - - this.$widget.modal("hide"); - - await importService.uploadFiles("notes", parentNoteId, files, options); - } -} diff --git a/apps/client/src/widgets/dialogs/import.tsx b/apps/client/src/widgets/dialogs/import.tsx new file mode 100644 index 000000000..77b867219 --- /dev/null +++ b/apps/client/src/widgets/dialogs/import.tsx @@ -0,0 +1,102 @@ +import { useState } from "preact/hooks"; +import { t } from "../../services/i18n"; +import tree from "../../services/tree"; +import Button from "../react/Button"; +import FormCheckbox from "../react/FormCheckbox"; +import FormFileUpload from "../react/FormFileUpload"; +import FormGroup from "../react/FormGroup"; +import Modal from "../react/Modal"; +import RawHtml from "../react/RawHtml"; +import ReactBasicWidget from "../react/ReactBasicWidget"; +import importService, { UploadFilesOptions } from "../../services/import"; +import useTriliumEvent from "../react/hooks"; + +function ImportDialogComponent() { + const [ parentNoteId, setParentNoteId ] = useState(); + const [ noteTitle, setNoteTitle ] = useState(); + const [ files, setFiles ] = useState(null); + const [ safeImport, setSafeImport ] = useState(true); + const [ explodeArchives, setExplodeArchives ] = useState(true); + const [ shrinkImages, setShrinkImages ] = useState(true); + const [ textImportedAsText, setTextImportedAsText ] = useState(true); + const [ codeImportedAsCode, setCodeImportedAsCode ] = useState(true); + const [ replaceUnderscoresWithSpaces, setReplaceUnderscoresWithSpaces ] = useState(true); + const [ shown, setShown ] = useState(false); + + useTriliumEvent("showImportDialog", ({ noteId }) => { + setParentNoteId(noteId); + tree.getNoteTitle(noteId).then(setNoteTitle); + setShown(true); + }); + + return ( + { + if (!files || !parentNoteId) { + return; + } + + const options: UploadFilesOptions = { + safeImport: boolToString(safeImport), + shrinkImages: boolToString(shrinkImages), + textImportedAsText: boolToString(textImportedAsText), + codeImportedAsCode: boolToString(codeImportedAsCode), + explodeArchives: boolToString(explodeArchives), + replaceUnderscoresWithSpaces: boolToString(replaceUnderscoresWithSpaces) + }; + + setShown(false); + await importService.uploadFiles("notes", parentNoteId, Array.from(files), options); + }} + onHidden={() => setShown(false)} + footer={ - -
    - - -
    - - -`; - -export default class IncludeNoteDialog extends BasicWidget { - - private modal!: bootstrap.Modal; - private $form!: JQuery; - private $autoComplete!: JQuery; - private textTypeWidget?: EditableTextTypeWidget; - - doRender() { - this.$widget = $(TPL); - this.modal = Modal.getOrCreateInstance(this.$widget[0]); - this.$form = this.$widget.find(".include-note-form"); - this.$autoComplete = this.$widget.find(".include-note-autocomplete"); - this.$form.on("submit", () => { - const notePath = this.$autoComplete.getSelectedNotePath(); - - if (notePath) { - this.modal.hide(); - this.includeNote(notePath); - } else { - logError("No noteId to include."); - } - - return false; - }); - } - - async showIncludeNoteDialogEvent({ textTypeWidget }: EventData<"showIncludeDialog">) { - this.textTypeWidget = textTypeWidget; - await this.refresh(); - openDialog(this.$widget); - - this.$autoComplete.trigger("focus").trigger("select"); // to be able to quickly remove entered text - } - - async refresh() { - this.$autoComplete.val(""); - noteAutocompleteService.initNoteAutocomplete(this.$autoComplete, { - hideGoToSelectedNoteButton: true, - allowCreatingNotes: true - }); - noteAutocompleteService.showRecentNotes(this.$autoComplete); - } - - async includeNote(notePath: string) { - const noteId = treeService.getNoteIdFromUrl(notePath); - if (!noteId) { - return; - } - const note = await froca.getNote(noteId); - const boxSize = $("input[name='include-note-box-size']:checked").val() as string; - - if (["image", "canvas", "mermaid"].includes(note?.type ?? "")) { - // there's no benefit to use insert note functionlity for images, - // so we'll just add an IMG tag - this.textTypeWidget?.addImage(noteId); - } else { - this.textTypeWidget?.addIncludeNote(noteId, boxSize); - } - } -} diff --git a/apps/client/src/widgets/dialogs/include_note.tsx b/apps/client/src/widgets/dialogs/include_note.tsx new file mode 100644 index 000000000..70f99b7e5 --- /dev/null +++ b/apps/client/src/widgets/dialogs/include_note.tsx @@ -0,0 +1,95 @@ +import { useRef, useState } from "preact/hooks"; +import { t } from "../../services/i18n"; +import FormGroup from "../react/FormGroup"; +import FormRadioGroup from "../react/FormRadioGroup"; +import Modal from "../react/Modal"; +import NoteAutocomplete from "../react/NoteAutocomplete"; +import ReactBasicWidget from "../react/ReactBasicWidget"; +import Button from "../react/Button"; +import { Suggestion, triggerRecentNotes } from "../../services/note_autocomplete"; +import tree from "../../services/tree"; +import froca from "../../services/froca"; +import EditableTextTypeWidget from "../type_widgets/editable_text"; +import useTriliumEvent from "../react/hooks"; + +function IncludeNoteDialogComponent() { + const [textTypeWidget, setTextTypeWidget] = useState(); + const [suggestion, setSuggestion] = useState(null); + const [boxSize, setBoxSize] = useState("medium"); + const [shown, setShown] = useState(false); + + useTriliumEvent("showIncludeNoteDialog", ({ textTypeWidget }) => { + setTextTypeWidget(textTypeWidget); + setShown(true); + }); + + const autoCompleteRef = useRef(null); + + return ( + triggerRecentNotes(autoCompleteRef.current)} + onHidden={() => setShown(false)} + onSubmit={() => { + if (!suggestion?.notePath || !textTypeWidget) { + return; + } + + setShown(false); + includeNote(suggestion.notePath, textTypeWidget); + }} + footer={ - - - - - -`; - -export default class IncorrectCpuArchDialog extends BasicWidget { - private modal!: Modal; - private $downloadButton!: JQuery; - - doRender() { - this.$widget = $(TPL); - this.modal = Modal.getOrCreateInstance(this.$widget[0]); - this.$downloadButton = this.$widget.find(".download-correct-version-button"); - - this.$downloadButton.on("click", () => { - // Open the releases page where users can download the correct version - if (utils.isElectron()) { - const { shell } = utils.dynamicRequire("electron"); - shell.openExternal("https://github.com/TriliumNext/Trilium/releases/latest"); - } else { - window.open("https://github.com/TriliumNext/Trilium/releases/latest", "_blank"); - } - }); - - // Auto-focus the download button when shown - this.$widget.on("shown.bs.modal", () => { - this.$downloadButton.trigger("focus"); - }); - } - - showCpuArchWarningEvent() { - this.modal.show(); - } -} diff --git a/apps/client/src/widgets/dialogs/incorrect_cpu_arch.tsx b/apps/client/src/widgets/dialogs/incorrect_cpu_arch.tsx new file mode 100644 index 000000000..e0165b7d0 --- /dev/null +++ b/apps/client/src/widgets/dialogs/incorrect_cpu_arch.tsx @@ -0,0 +1,54 @@ +import { useRef } from "preact/hooks"; +import { t } from "../../services/i18n.js"; +import utils from "../../services/utils.js"; +import Button from "../react/Button.js"; +import Modal from "../react/Modal.js"; +import ReactBasicWidget from "../react/ReactBasicWidget.js"; +import { useState } from "preact/hooks"; +import useTriliumEvent from "../react/hooks.jsx"; + +function IncorrectCpuArchDialogComponent() { + const [ shown, setShown ] = useState(false); + const downloadButtonRef = useRef(null); + useTriliumEvent("showCpuArchWarning", () => setShown(true)); + + return ( + downloadButtonRef.current?.focus()} + footerAlignment="between" + footer={<> + - - - - - -`; - -export default class InfoDialog extends BasicWidget { - - private resolve: ConfirmDialogCallback | null; - private modal!: bootstrap.Modal; - private $originallyFocused!: JQuery | null; - private $infoContent!: JQuery; - private $okButton!: JQuery; - - constructor() { - super(); - - this.resolve = null; - this.$originallyFocused = null; // element focused before the dialog was opened, so we can return to it afterward - } - - doRender() { - this.$widget = $(TPL); - this.modal = Modal.getOrCreateInstance(this.$widget[0]); - this.$infoContent = this.$widget.find(".info-dialog-content"); - this.$okButton = this.$widget.find(".info-dialog-ok-button"); - - this.$widget.on("shown.bs.modal", () => this.$okButton.trigger("focus")); - - this.$widget.on("hidden.bs.modal", () => { - if (this.resolve) { - this.resolve(); - } - - if (this.$originallyFocused) { - this.$originallyFocused.trigger("focus"); - this.$originallyFocused = null; - } - }); - - this.$okButton.on("click", () => this.modal.hide()); - } - - showInfoDialogEvent({ message, callback }: EventData<"showInfoDialog">) { - this.$originallyFocused = $(":focus"); - - if (typeof message === "string") { - this.$infoContent.text(message); - } else if (Array.isArray(message)) { - this.$infoContent.html(message[0]); - } else { - this.$infoContent.html(message as HTMLElement); - } - - - openDialog(this.$widget); - - this.resolve = callback; - } -} diff --git a/apps/client/src/widgets/dialogs/info.tsx b/apps/client/src/widgets/dialogs/info.tsx new file mode 100644 index 000000000..9eaf81b50 --- /dev/null +++ b/apps/client/src/widgets/dialogs/info.tsx @@ -0,0 +1,47 @@ +import { EventData } from "../../components/app_context"; +import ReactBasicWidget from "../react/ReactBasicWidget"; +import Modal from "../react/Modal"; +import { t } from "../../services/i18n"; +import Button from "../react/Button"; +import { useRef, useState } from "preact/hooks"; +import { RawHtmlBlock } from "../react/RawHtml"; +import useTriliumEvent from "../react/hooks"; + +function ShowInfoDialogComponent() { + const [ opts, setOpts ] = useState>(); + const [ shown, setShown ] = useState(false); + const okButtonRef = useRef(null); + + useTriliumEvent("showInfoDialog", (opts) => { + setOpts(opts); + setShown(true); + }); + + return ( { + opts?.callback?.(); + setShown(false); + }} + onShown={() => okButtonRef.current?.focus?.()} + footer={ - - - - - -`; - -const KEEP_LAST_SEARCH_FOR_X_SECONDS = 120; - -export default class JumpToNoteDialog extends BasicWidget { - - private lastOpenedTs: number; - private modal!: bootstrap.Modal; - private $autoComplete!: JQuery; - private $results!: JQuery; - private $showInFullTextButton!: JQuery; - - constructor() { - super(); - - this.lastOpenedTs = 0; - } - - doRender() { - this.$widget = $(TPL); - this.modal = Modal.getOrCreateInstance(this.$widget[0]); - - this.$autoComplete = this.$widget.find(".jump-to-note-autocomplete"); - this.$results = this.$widget.find(".jump-to-note-results"); - this.$showInFullTextButton = this.$widget.find(".show-in-full-text-button"); - this.$showInFullTextButton.on("click", (e) => this.showInFullText(e)); - - shortcutService.bindElShortcut(this.$widget, "ctrl+return", (e) => this.showInFullText(e)); - } - - async jumpToNoteEvent() { - const dialogPromise = openDialog(this.$widget); - if (utils.isMobile()) { - dialogPromise.then(($dialog) => { - const el = $dialog.find(">.modal-dialog")[0]; - - function reposition() { - const offset = 100; - const modalHeight = (window.visualViewport?.height ?? 0) - offset; - const safeAreaInsetBottom = (window.visualViewport?.height ?? 0) - window.innerHeight; - el.style.height = `${modalHeight}px`; - el.style.bottom = `${(window.visualViewport?.height ?? 0) - modalHeight - safeAreaInsetBottom - offset}px`; - } - - this.$autoComplete.on("focus", () => { - reposition(); - }); - - window.visualViewport?.addEventListener("resize", () => { - reposition(); - }); - - reposition(); - }); - } - - // first open dialog, then refresh since refresh is doing focus which should be visible - this.refresh(); - - this.lastOpenedTs = Date.now(); - } - - async refresh() { - noteAutocompleteService - .initNoteAutocomplete(this.$autoComplete, { - allowCreatingNotes: true, - hideGoToSelectedNoteButton: true, - allowJumpToSearchNotes: true, - container: this.$results[0] - }) - // clear any event listener added in previous invocation of this function - .off("autocomplete:noteselected") - .on("autocomplete:noteselected", function (event, suggestion, dataset) { - if (!suggestion.notePath) { - return false; - } - - appContext.tabManager.getActiveContext()?.setNote(suggestion.notePath); - }); - - // if you open the Jump To dialog soon after using it previously, it can often mean that you - // actually want to search for the same thing (e.g., you opened the wrong note at first try) - // so we'll keep the content. - // if it's outside of this time limit, then we assume it's a completely new search and show recent notes instead. - if (Date.now() - this.lastOpenedTs > KEEP_LAST_SEARCH_FOR_X_SECONDS * 1000) { - noteAutocompleteService.showRecentNotes(this.$autoComplete); - } else { - this.$autoComplete - // hack, the actual search value is stored in
     element next to the search input
    -                // this is important because the search input value is replaced with the suggestion note's title
    -                .autocomplete("val", this.$autoComplete.next().text())
    -                .trigger("focus")
    -                .trigger("select");
    -        }
    -    }
    -
    -    showInFullText(e: JQuery.TriggeredEvent) {
    -        // stop from propagating upwards (dangerous, especially with ctrl+enter executable javascript notes)
    -        e.preventDefault();
    -        e.stopPropagation();
    -
    -        const searchString = String(this.$autoComplete.val());
    -
    -        this.triggerCommand("searchNotes", { searchString });
    -        this.modal.hide();
    -    }
    -}
    diff --git a/apps/client/src/widgets/dialogs/jump_to_note.tsx b/apps/client/src/widgets/dialogs/jump_to_note.tsx
    new file mode 100644
    index 000000000..3af1b1aba
    --- /dev/null
    +++ b/apps/client/src/widgets/dialogs/jump_to_note.tsx
    @@ -0,0 +1,125 @@
    +import ReactBasicWidget from "../react/ReactBasicWidget";
    +import Modal from "../react/Modal";
    +import Button from "../react/Button";
    +import NoteAutocomplete from "../react/NoteAutocomplete";
    +import { t } from "../../services/i18n";
    +import { useRef, useState } from "preact/hooks";
    +import note_autocomplete, { Suggestion } from "../../services/note_autocomplete";
    +import appContext from "../../components/app_context";
    +import commandRegistry from "../../services/command_registry";
    +import { refToJQuerySelector } from "../react/react_utils";
    +import useTriliumEvent from "../react/hooks";
    +
    +const KEEP_LAST_SEARCH_FOR_X_SECONDS = 120;
    +
    +type Mode = "last-search" | "recent-notes" | "commands";
    +
    +function JumpToNoteDialogComponent() {
    +    const [ mode, setMode ] = useState();
    +    const [ lastOpenedTs, setLastOpenedTs ] = useState(0);
    +    const containerRef = useRef(null);
    +    const autocompleteRef = useRef(null);
    +    const [ isCommandMode, setIsCommandMode ] = useState(mode === "commands");
    +    const [ initialText, setInitialText ] = useState(isCommandMode ? "> " : "");
    +    const actualText = useRef(initialText);
    +    const [ shown, setShown ] = useState(false);
    +    
    +    async function openDialog(commandMode: boolean) {        
    +        let newMode: Mode;
    +        let initialText: string = "";
    +
    +        if (commandMode) {
    +            newMode = "commands";
    +            initialText = ">";            
    +        } else if (Date.now() - lastOpenedTs <= KEEP_LAST_SEARCH_FOR_X_SECONDS * 1000 && actualText) {
    +            // if you open the Jump To dialog soon after using it previously, it can often mean that you
    +            // actually want to search for the same thing (e.g., you opened the wrong note at first try)
    +            // so we'll keep the content.
    +            // if it's outside of this time limit, then we assume it's a completely new search and show recent notes instead.
    +            newMode = "last-search";
    +            initialText = actualText.current;
    +        } else {
    +            newMode = "recent-notes";
    +        }
    +
    +        if (mode !== newMode) {
    +            setMode(newMode);
    +        }
    +
    +        setInitialText(initialText);
    +        setShown(true);
    +        setLastOpenedTs(Date.now());
    +    }
    +
    +    useTriliumEvent("jumpToNote", () => openDialog(false));
    +    useTriliumEvent("commandPalette", () => openDialog(true));
    +
    +    async function onItemSelected(suggestion?: Suggestion | null) {
    +        if (!suggestion) {
    +            return;
    +        }
    +        
    +        setShown(false);
    +        if (suggestion.notePath) {
    +            appContext.tabManager.getActiveContext()?.setNote(suggestion.notePath);
    +        } else if (suggestion.commandId) {
    +            await commandRegistry.executeCommand(suggestion.commandId);
    +        }
    +    }
    +
    +    function onShown() {
    +        const $autoComplete = refToJQuerySelector(autocompleteRef);
    +        switch (mode) {
    +            case "last-search":
    +                break;
    +            case "recent-notes":
    +                note_autocomplete.showRecentNotes($autoComplete);
    +                break;
    +            case "commands":
    +                note_autocomplete.showAllCommands($autoComplete);
    +                break;
    +        }
    +
    +        $autoComplete
    +            .trigger("focus")
    +            .trigger("select");
    +    }
    +
    +    return (
    +         {
    +                    actualText.current = text;
    +                    setIsCommandMode(text.startsWith(">"));
    +                }}
    +                onChange={onItemSelected}
    +                />}
    +            onShown={onShown}
    +            onHidden={() => setShown(false)}
    +            footer={!isCommandMode && 
    -            
    -            
    -            
    -        
    -    
    -`;
    -
    -interface RenderMarkdownResponse {
    -    htmlContent: string;
    -}
    -
    -export default class MarkdownImportDialog extends BasicWidget {
    -
    -    private lastOpenedTs: number;
    -    private modal!: bootstrap.Modal;
    -    private $importTextarea!: JQuery;
    -    private $importButton!: JQuery;
    -
    -    constructor() {
    -        super();
    -
    -        this.lastOpenedTs = 0;
    -    }
    -
    -    doRender() {
    -        this.$widget = $(TPL);
    -        this.modal = Modal.getOrCreateInstance(this.$widget[0]);
    -        this.$importTextarea = this.$widget.find(".markdown-import-textarea");
    -        this.$importButton = this.$widget.find(".markdown-import-button");
    -
    -        this.$importButton.on("click", () => this.sendForm());
    -
    -        this.$widget.on("shown.bs.modal", () => this.$importTextarea.trigger("focus"));
    -
    -        shortcutService.bindElShortcut(this.$widget, "ctrl+return", () => this.sendForm());
    -    }
    -
    -    async convertMarkdownToHtml(markdownContent: string) {
    -        const { htmlContent } = await server.post("other/render-markdown", { markdownContent });
    -
    -        const textEditor = await appContext.tabManager.getActiveContext()?.getTextEditor();
    -        if (!textEditor) {
    -            return;
    -        }
    -
    -        const viewFragment = textEditor.data.processor.toView(htmlContent);
    -        const modelFragment = textEditor.data.toModel(viewFragment);
    -
    -        textEditor.model.insertContent(modelFragment, textEditor.model.document.selection);
    -        textEditor.editing.view.focus();
    -
    -        toastService.showMessage(t("markdown_import.import_success"));
    -    }
    -
    -    async pasteMarkdownIntoTextEvent() {
    -        await this.importMarkdownInlineEvent(); // BC with keyboard shortcuts command
    -    }
    -
    -    async importMarkdownInlineEvent() {
    -        if (appContext.tabManager.getActiveContextNoteType() !== "text") {
    -            return;
    -        }
    -
    -        if (utils.isElectron()) {
    -            const { clipboard } = utils.dynamicRequire("electron");
    -            const text = clipboard.readText();
    -
    -            this.convertMarkdownToHtml(text);
    -        } else {
    -            openDialog(this.$widget);
    -        }
    -    }
    -
    -    async sendForm() {
    -        const text = String(this.$importTextarea.val());
    -
    -        this.modal.hide();
    -
    -        await this.convertMarkdownToHtml(text);
    -
    -        this.$importTextarea.val("");
    -    }
    -}
    diff --git a/apps/client/src/widgets/dialogs/markdown_import.tsx b/apps/client/src/widgets/dialogs/markdown_import.tsx
    new file mode 100644
    index 000000000..4f91278d9
    --- /dev/null
    +++ b/apps/client/src/widgets/dialogs/markdown_import.tsx
    @@ -0,0 +1,90 @@
    +import { useCallback, useRef, useState } from "preact/hooks";
    +import appContext from "../../components/app_context";
    +import { t } from "../../services/i18n";
    +import server from "../../services/server";
    +import toast from "../../services/toast";
    +import utils from "../../services/utils";
    +import Modal from "../react/Modal";
    +import ReactBasicWidget from "../react/ReactBasicWidget";
    +import Button from "../react/Button";
    +import useTriliumEvent from "../react/hooks";
    +
    +interface RenderMarkdownResponse {
    +    htmlContent: string;
    +}
    +
    +function MarkdownImportDialogComponent() {
    +    const markdownImportTextArea = useRef(null);
    +    let [ text, setText ] = useState("");
    +    let [ shown, setShown ] = useState(false);
    +
    +    const triggerImport = useCallback(() => {
    +        if (appContext.tabManager.getActiveContextNoteType() !== "text") {
    +            return;
    +        }
    +    
    +        if (utils.isElectron()) {
    +            const { clipboard } = utils.dynamicRequire("electron");
    +            const text = clipboard.readText();
    +    
    +            convertMarkdownToHtml(text);
    +        } else {
    +            setShown(true);
    +        }
    +    }, []);
    +
    +    useTriliumEvent("importMarkdownInline", triggerImport);
    +    useTriliumEvent("pasteMarkdownIntoText", triggerImport);
    +
    +    async function sendForm() {
    +        await convertMarkdownToHtml(text);
    +        setText("");
    +        setShown(false);
    +    }
    +
    +    return (
    +        }
    +            onShown={() => markdownImportTextArea.current?.focus()}
    +            onHidden={() => setShown(false) }
    +            show={shown}
    +        >
    +            

    {t("markdown_import.modal_body_text")}

    + +
    + ) +} + +export default class MarkdownImportDialog extends ReactBasicWidget { + + get component() { + return ; + } + +} + +async function convertMarkdownToHtml(markdownContent: string) { + const { htmlContent } = await server.post("other/render-markdown", { markdownContent }); + + const textEditor = await appContext.tabManager.getActiveContext()?.getTextEditor(); + if (!textEditor) { + return; + } + + const viewFragment = textEditor.data.processor.toView(htmlContent); + const modelFragment = textEditor.data.toModel(viewFragment); + + textEditor.model.insertContent(modelFragment, textEditor.model.document.selection); + textEditor.editing.view.focus(); + + toast.showMessage(t("markdown_import.import_success")); +} \ No newline at end of file diff --git a/apps/client/src/widgets/dialogs/move_to.ts b/apps/client/src/widgets/dialogs/move_to.ts deleted file mode 100644 index 49e016f15..000000000 --- a/apps/client/src/widgets/dialogs/move_to.ts +++ /dev/null @@ -1,120 +0,0 @@ -import noteAutocompleteService from "../../services/note_autocomplete.js"; -import toastService from "../../services/toast.js"; -import froca from "../../services/froca.js"; -import branchService from "../../services/branches.js"; -import treeService from "../../services/tree.js"; -import BasicWidget from "../basic_widget.js"; -import { t } from "../../services/i18n.js"; -import type { EventData } from "../../components/app_context.js"; -import { openDialog } from "../../services/dialog.js"; - -const TPL = /*html*/` -`; - -export default class MoveToDialog extends BasicWidget { - - private movedBranchIds: string[] | null; - private $form!: JQuery; - private $noteAutoComplete!: JQuery; - private $noteList!: JQuery; - - constructor() { - super(); - - this.movedBranchIds = null; - } - - doRender() { - this.$widget = $(TPL); - this.$form = this.$widget.find(".move-to-form"); - this.$noteAutoComplete = this.$widget.find(".move-to-note-autocomplete"); - this.$noteList = this.$widget.find(".move-to-note-list"); - - this.$form.on("submit", () => { - const notePath = this.$noteAutoComplete.getSelectedNotePath(); - - if (notePath) { - this.$widget.modal("hide"); - - const { noteId, parentNoteId } = treeService.getNoteIdAndParentIdFromUrl(notePath); - if (parentNoteId) { - froca.getBranchId(parentNoteId, noteId).then((branchId) => { - if (branchId) { - this.moveNotesTo(branchId); - } - }); - } - } else { - logError(t("move_to.error_no_path")); - } - - return false; - }); - } - - async moveBranchIdsToEvent({ branchIds }: EventData<"moveBranchIdsTo">) { - this.movedBranchIds = branchIds; - - openDialog(this.$widget); - - this.$noteAutoComplete.val("").trigger("focus"); - - this.$noteList.empty(); - - for (const branchId of this.movedBranchIds) { - const branch = froca.getBranch(branchId); - if (!branch) { - continue; - } - - const note = await froca.getNote(branch.noteId); - if (!note) { - continue; - } - - this.$noteList.append($("
  • ").text(note.title)); - } - - noteAutocompleteService.initNoteAutocomplete(this.$noteAutoComplete); - noteAutocompleteService.showRecentNotes(this.$noteAutoComplete); - } - - async moveNotesTo(parentBranchId: string) { - if (this.movedBranchIds) { - await branchService.moveToParentNote(this.movedBranchIds, parentBranchId); - } - - const parentBranch = froca.getBranch(parentBranchId); - const parentNote = await parentBranch?.getNote(); - - toastService.showMessage(`${t("move_to.move_success_message")} ${parentNote?.title}`); - } -} diff --git a/apps/client/src/widgets/dialogs/move_to.tsx b/apps/client/src/widgets/dialogs/move_to.tsx new file mode 100644 index 000000000..b4e53fc09 --- /dev/null +++ b/apps/client/src/widgets/dialogs/move_to.tsx @@ -0,0 +1,87 @@ +import ReactBasicWidget from "../react/ReactBasicWidget"; +import Modal from "../react/Modal"; +import { t } from "../../services/i18n"; +import NoteList from "../react/NoteList"; +import FormGroup from "../react/FormGroup"; +import NoteAutocomplete from "../react/NoteAutocomplete"; +import Button from "../react/Button"; +import { useRef, useState } from "preact/hooks"; +import { Suggestion, triggerRecentNotes } from "../../services/note_autocomplete"; +import tree from "../../services/tree"; +import froca from "../../services/froca"; +import branches from "../../services/branches"; +import toast from "../../services/toast"; +import useTriliumEvent from "../react/hooks"; + +function MoveToDialogComponent() { + const [ movedBranchIds, setMovedBranchIds ] = useState(); + const [ suggestion, setSuggestion ] = useState(null); + const [ shown, setShown ] = useState(false); + const autoCompleteRef = useRef(null); + + useTriliumEvent("moveBranchIdsTo", ({ branchIds }) => { + setMovedBranchIds(branchIds); + setShown(true); + }); + + async function onSubmit() { + const notePath = suggestion?.notePath; + if (!notePath) { + logError(t("move_to.error_no_path")); + return; + } + + setShown(false); + const { noteId, parentNoteId } = tree.getNoteIdAndParentIdFromUrl(notePath); + if (!parentNoteId) { + return; + } + + const branchId = await froca.getBranchId(parentNoteId, noteId); + if (branchId) { + moveNotesTo(movedBranchIds, branchId); + } + } + + return ( + } + onSubmit={onSubmit} + onShown={() => triggerRecentNotes(autoCompleteRef.current)} + onHidden={() => setShown(false)} + show={shown} + > +
    {t("move_to.notes_to_move")}
    + + + + + +
    + ) +} + +export default class MoveToDialog extends ReactBasicWidget { + + get component() { + return ; + } + +} + +async function moveNotesTo(movedBranchIds: string[] | undefined, parentBranchId: string) { + if (movedBranchIds) { + await branches.moveToParentNote(movedBranchIds, parentBranchId); + } + + const parentBranch = froca.getBranch(parentBranchId); + const parentNote = await parentBranch?.getNote(); + + toast.showMessage(`${t("move_to.move_success_message")} ${parentNote?.title}`); +} \ No newline at end of file diff --git a/apps/client/src/widgets/dialogs/note_type_chooser.ts b/apps/client/src/widgets/dialogs/note_type_chooser.ts deleted file mode 100644 index 34a89aff6..000000000 --- a/apps/client/src/widgets/dialogs/note_type_chooser.ts +++ /dev/null @@ -1,204 +0,0 @@ -import type { CommandNames } from "../../components/app_context.js"; -import type { MenuCommandItem } from "../../menus/context_menu.js"; -import { t } from "../../services/i18n.js"; -import noteTypesService from "../../services/note_types.js"; -import noteAutocompleteService from "../../services/note_autocomplete.js"; -import BasicWidget from "../basic_widget.js"; -import { Dropdown, Modal } from "bootstrap"; - -const TPL = /*html*/` -`; - -export interface ChooseNoteTypeResponse { - success: boolean; - noteType?: string; - templateNoteId?: string; - notePath?: string; -} - -type Callback = (data: ChooseNoteTypeResponse) => void; - -export default class NoteTypeChooserDialog extends BasicWidget { - private resolve: Callback | null; - private dropdown!: Dropdown; - private modal!: Modal; - private $noteTypeDropdown!: JQuery; - private $autoComplete!: JQuery; - private $originalFocused: JQuery | null; - private $originalDialog: JQuery | null; - - constructor() { - super(); - - this.resolve = null; - this.$originalFocused = null; // element focused before the dialog was opened, so we can return to it afterward - this.$originalDialog = null; - } - - doRender() { - this.$widget = $(TPL); - this.modal = Modal.getOrCreateInstance(this.$widget[0]); - - this.$autoComplete = this.$widget.find(".choose-note-path"); - this.$noteTypeDropdown = this.$widget.find(".note-type-dropdown"); - this.dropdown = Dropdown.getOrCreateInstance(this.$widget.find(".note-type-dropdown-trigger")[0]); - - this.$widget.on("hidden.bs.modal", () => { - if (this.resolve) { - this.resolve({ success: false }); - } - - if (this.$originalFocused) { - this.$originalFocused.trigger("focus"); - this.$originalFocused = null; - } - - glob.activeDialog = this.$originalDialog; - }); - - this.$noteTypeDropdown.on("click", ".dropdown-item", (e) => this.doResolve(e)); - - this.$noteTypeDropdown.on("focus", ".dropdown-item", (e) => { - this.$noteTypeDropdown.find(".dropdown-item").each((i, el) => { - $(el).toggleClass("active", el === e.target); - }); - }); - - this.$noteTypeDropdown.on("keydown", ".dropdown-item", (e) => { - if (e.key === "Enter") { - this.doResolve(e); - e.preventDefault(); - return false; - } - }); - - this.$noteTypeDropdown.parent().on("hide.bs.dropdown", (e) => { - // prevent closing dropdown by clicking outside - // TODO: Check if this actually works. - //@ts-ignore - if (e.clickEvent) { - e.preventDefault(); - } else { - this.modal.hide(); - } - }); - } - - async refresh() { - noteAutocompleteService - .initNoteAutocomplete(this.$autoComplete, { - allowCreatingNotes: false, - hideGoToSelectedNoteButton: true, - allowJumpToSearchNotes: false, - }) - } - - async chooseNoteTypeEvent({ callback }: { callback: Callback }) { - this.$originalFocused = $(":focus"); - - await this.refresh(); - - const noteTypes = await noteTypesService.getNoteTypeItems(); - - this.$noteTypeDropdown.empty(); - - for (const noteType of noteTypes) { - if (noteType.title === "----") { - this.$noteTypeDropdown.append($('