You've already forked joplin
mirror of
https://github.com/laurent22/joplin.git
synced 2026-03-03 09:27:01 +02:00
Compare commits
3 Commits
android-v3
...
transcribe
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a747996d8c | ||
|
|
0877d6e9cd | ||
|
|
66aa47a5ca |
@@ -12,95 +12,81 @@ reviews:
|
||||
auto_apply_labels: true
|
||||
labeling_instructions:
|
||||
- label: "accessibility"
|
||||
instructions: "Apply when the PR contains changes related to accessibility, screen readers, keyboard navigation, or ARIA attributes"
|
||||
instructions: "Apply when the PR contains changes related to accessibility, screen readers, keyboard navigation, or ARIA attributes."
|
||||
- label: "android"
|
||||
instructions: "Apply when the PR modifies files under packages/app-mobile/android/. Or when the PR modifies files under packages/app-mobile and the change is specific to Android only"
|
||||
instructions: "Apply when the PR contains changes specific to the Android platform or Android app."
|
||||
- label: "api"
|
||||
instructions: "Apply when the PR modifies files under packages/lib/services/rest/"
|
||||
instructions: "Apply when the PR modifies the Joplin API, REST endpoints, or API-related code."
|
||||
- label: "bug"
|
||||
instructions: "Apply when the PR fixes a bug or unexpected behaviour"
|
||||
instructions: "Apply when the PR fixes a bug or unexpected behaviour."
|
||||
- label: "ci"
|
||||
instructions: "Apply when the PR modifies files under .github/workflows/ or .circleci/"
|
||||
instructions: "Apply when the PR modifies CI/CD configuration, GitHub Actions workflows, or build pipelines."
|
||||
- label: "cli"
|
||||
instructions: "Apply when the PR modifies files under packages/app-cli/, except if all the modified files are under packages/app-cli/tests/"
|
||||
instructions: "Apply when the PR contains changes specific to the Joplin CLI (command-line) application."
|
||||
- label: "clipper"
|
||||
instructions: "Apply when the PR modifies files under packages/app-clipper/"
|
||||
instructions: "Apply when the PR contains changes to the Joplin Web Clipper browser extension."
|
||||
- label: "database"
|
||||
instructions: "Apply when the PR is mainly about modifying database schema, migrations, or database-related logic"
|
||||
instructions: "Apply when the PR modifies database schema, migrations, or database-related logic."
|
||||
- label: "desktop"
|
||||
instructions: "Apply when the PR modifies files under packages/app-desktop/"
|
||||
instructions: "Apply when the PR contains changes specific to the Joplin desktop (Electron) application."
|
||||
- label: "documentation"
|
||||
instructions: "Apply when the PR modifies files under readme/"
|
||||
instructions: "Apply when the PR adds or updates documentation, README files, or code comments."
|
||||
- label: "draw"
|
||||
instructions: "Apply when the PR modifies files under packages/default-plugins and relates to the JS-Draw drawing plugin"
|
||||
instructions: "Apply when the PR contains changes related to the drawing or sketching feature."
|
||||
- label: "editor"
|
||||
instructions: "Apply when the PR modifies files under packages/editor/ or packages/app-mobile/components/NoteEditor/"
|
||||
instructions: "Apply when the PR contains changes to the note editor (CodeMirror, TinyMCE, or the editor infrastructure)."
|
||||
- label: "enhancement"
|
||||
instructions: "Apply when the PR adds a new feature or improves existing functionality (not a bug fix)"
|
||||
instructions: "Apply when the PR adds a new feature or improves existing functionality (not a bug fix)."
|
||||
- label: "export"
|
||||
instructions: "Apply when the PR is mainly about changes to the export functionality (PDF, HTML, JEX, etc.)"
|
||||
instructions: "Apply when the PR contains changes to export functionality (PDF, HTML, JEX, etc.)."
|
||||
- label: "import"
|
||||
instructions: "Apply when the PR is mainly about changes to the import functionality (Evernote, Markdown, etc.)"
|
||||
instructions: "Apply when the PR contains changes to import functionality (Evernote, Markdown, etc.)."
|
||||
- label: "iOS"
|
||||
instructions: "Apply when the PR modifies files under packages/app-mobile/ios/. Or when the PR modifies files under packages/app-mobile and the change is specific to iOS only"
|
||||
instructions: "Apply when the PR contains changes specific to the iOS platform or iOS app."
|
||||
- label: "linux"
|
||||
instructions: "Apply when the PR is mainly about changes specific to Linux"
|
||||
instructions: "Apply when the PR contains changes specific to Linux."
|
||||
- label: "linux/wayland"
|
||||
instructions: "Apply when the PR is mainly about changes specific to Linux Wayland"
|
||||
instructions: "Apply when the PR contains changes specific to Linux Wayland."
|
||||
- label: "macOS"
|
||||
instructions: "Apply when the PR is mainly about changes specific to macOS"
|
||||
instructions: "Apply when the PR contains changes specific to macOS."
|
||||
- label: "markdown-editor"
|
||||
instructions: "Apply when the PR modifies files under packages/editor/CodeMirror"
|
||||
instructions: "Apply when the PR contains changes to the Markdown editor or Markdown rendering."
|
||||
- label: "mobile"
|
||||
instructions: "Apply when the PR modifies files under packages/app-mobile/"
|
||||
instructions: "Apply when the PR contains changes to the mobile app (iOS or Android)."
|
||||
- label: "multi-window"
|
||||
instructions: "Apply when the PR contains changes related to multi-window support."
|
||||
- label: "OCR"
|
||||
instructions: "Apply when the PR contains changes related to OCR (optical character recognition) functionality"
|
||||
instructions: "Apply when the PR contains changes related to OCR (optical character recognition) functionality."
|
||||
- label: "performance"
|
||||
instructions: "Apply when the PR improves performance, reduces memory usage, or optimises speed"
|
||||
instructions: "Apply when the PR improves performance, reduces memory usage, or optimises speed."
|
||||
- label: "plugins"
|
||||
instructions: "Apply when the PR modifies files under packages/lib/services/plugins/ or packages/plugin-repo-cli/"
|
||||
instructions: "Apply when the PR contains changes to the plugin system, plugin API, or specific plugins."
|
||||
- label: "Regression"
|
||||
instructions: "Apply when the linked issue, if any, has the Regression label"
|
||||
instructions: "Apply when the linked issue, if any, has the Regression label."
|
||||
- label: "renderer"
|
||||
instructions: "Apply when the PR modifies files under packages/renderer/ or packages/turndown/"
|
||||
instructions: "Apply when the PR contains changes to the note renderer or how notes are displayed."
|
||||
- label: "search"
|
||||
instructions: "Apply when the PR is mainly about changes to the search functionality"
|
||||
instructions: "Apply when the PR contains changes to search functionality."
|
||||
- label: "security"
|
||||
instructions: "Apply when the PR is mainly about addressing a security vulnerability or improving security"
|
||||
instructions: "Apply when the PR addresses a security vulnerability or improves security."
|
||||
- label: "server"
|
||||
instructions: "Apply when the PR modifies files under packages/server/"
|
||||
instructions: "Apply when the PR contains changes to Joplin Server."
|
||||
- label: "Sharing"
|
||||
instructions: "Apply when the PR is mainly about changes to the note or notebook/folder sharing features"
|
||||
instructions: "Apply when the PR contains changes to note or notebook sharing features."
|
||||
- label: "sync"
|
||||
instructions: "Apply when the PR modifies files under packages/lib/services/synchronizer/, packages/lib/Sync*.ts or packages/lib/services/e2ee/"
|
||||
instructions: "Apply when the PR contains changes to synchronisation logic or sync targets."
|
||||
- label: "tags"
|
||||
instructions: "Apply when the PR is mainly about changes to the tag management or tagging functionality"
|
||||
instructions: "Apply when the PR contains changes to tag management or tagging functionality."
|
||||
- label: "transcribe"
|
||||
instructions: "Apply when the PR modifies files under packages/transcribe"
|
||||
instructions: "Apply when the PR contains changes to audio transcription functionality."
|
||||
- label: "translation"
|
||||
instructions: "Apply when the PR modifies files under packages/tools/locales/ or **/locales/"
|
||||
instructions: "Apply when the PR adds or updates translations or localisation strings."
|
||||
- label: "Voice typing"
|
||||
instructions: "Apply when the PR is mainly about changes to the voice typing functionality"
|
||||
instructions: "Apply when the PR contains changes to voice typing functionality."
|
||||
- label: "web"
|
||||
instructions: "Apply when the PR modifies files under packages/app-web/. Or when the PR modifies files under packages/app-mobile and the change is specific to the web app only"
|
||||
instructions: "Apply when the PR contains changes to the Joplin web application or web-related features."
|
||||
- label: "windows"
|
||||
instructions: "Apply when the PR is mainly about changes specific to Windows"
|
||||
|
||||
pre_merge_checks:
|
||||
description:
|
||||
mode: "warning"
|
||||
custom_checks:
|
||||
- name: "PR Description Must Follow Guidelines"
|
||||
mode: "error"
|
||||
instructions: |
|
||||
Fail if the pull request description does not include clear sections for:
|
||||
- Problem or user-impact description
|
||||
- A high-level Solution explanation
|
||||
- Any Test Plan or verification steps
|
||||
|
||||
The description should align with our PR guidelines
|
||||
at https://github.com/joplin/gsoc/blob/master/pull_request_guidelines.md
|
||||
and should not just restate the diff or implementation details.
|
||||
instructions: "Apply when the PR contains changes specific to Windows."
|
||||
knowledge_base:
|
||||
code_guidelines:
|
||||
enabled: true
|
||||
|
||||
@@ -17,4 +17,3 @@ packages/server/db-*.sqlite
|
||||
packages/server/dist/
|
||||
packages/server/logs/
|
||||
packages/server/temp/
|
||||
packages/transcribe/.env
|
||||
|
||||
@@ -31,7 +31,6 @@
|
||||
# QUEUE_DATABASE_PASSWORD=transcribe
|
||||
# QUEUE_DATABASE_PORT=5431
|
||||
# HTR_CLI_IMAGES_FOLDER=/home/user/images_storage
|
||||
# HTR_CLI_MODELS_FOLDER=/home/user/transcribe_models
|
||||
|
||||
# =============================================================================
|
||||
# DEV CONFIG EXAMPLE
|
||||
|
||||
@@ -1,33 +1,35 @@
|
||||
# Joplin Transcribe Configuration
|
||||
#
|
||||
# Copy this file to .env-transcribe and update the values.
|
||||
|
||||
# =============================================================================
|
||||
# Required
|
||||
# -----------------------------------------------------------------------------
|
||||
# =============================================================================
|
||||
|
||||
# Set a secure API key for authentication
|
||||
API_KEY=changeme
|
||||
SERVER_PORT=4567
|
||||
|
||||
# =============================================================================
|
||||
# Optional (defaults are set in the Docker image)
|
||||
# =============================================================================
|
||||
API_KEY=random-string
|
||||
QUEUE_TTL=900000
|
||||
QUEUE_RETRY_COUNT=2
|
||||
QUEUE_MAINTENANCE_INTERVAL=30000
|
||||
IMAGE_MAX_DIMENSION=400
|
||||
|
||||
# Server port (default: 4567)
|
||||
# SERVER_PORT=4567
|
||||
HTR_CLI_DOCKER_IMAGE=joplin/htr-cli:latest
|
||||
# Fullpath to images folder e.g.:
|
||||
#HTR_CLI_IMAGES_FOLDER=/home/user/joplin/packages/transcribe/images
|
||||
HTR_CLI_IMAGES_FOLDER=
|
||||
|
||||
# Maximum image dimension for processing (default: 400)
|
||||
# IMAGE_MAX_DIMENSION=400
|
||||
|
||||
# Queue driver: sqlite (default) or pg
|
||||
QUEUE_DRIVER=pg
|
||||
# QUEUE_DRIVER=sqlite
|
||||
|
||||
# =============================================================================
|
||||
# PostgreSQL settings (only if QUEUE_DRIVER=pg)
|
||||
# =============================================================================
|
||||
FILE_STORAGE_MAINTENANCE_INTERVAL=3600000
|
||||
FILE_STORAGE_TTL=604800000 # one week
|
||||
|
||||
# QUEUE_DATABASE_NAME=transcribe
|
||||
# QUEUE_DATABASE_USER=transcribe
|
||||
# QUEUE_DATABASE_PASSWORD=transcribe
|
||||
# QUEUE_DATABASE_PORT=5432
|
||||
# QUEUE_DATABASE_HOST=localhost
|
||||
# =============================================================================
|
||||
# Queue driver
|
||||
# -----------------------------------------------------------------------------
|
||||
# =============================================================================
|
||||
#
|
||||
# QUEUE_DATABASE_NAME=./queue.sqlite3
|
||||
QUEUE_DATABASE_NAME=transcribe
|
||||
QUEUE_DATABASE_USER=transcribe
|
||||
QUEUE_DATABASE_PASSWORD=transcribe
|
||||
QUEUE_DATABASE_PORT=5432
|
||||
QUEUE_DATABASE_HOST=localhost
|
||||
@@ -169,7 +169,6 @@ packages/app-desktop/bridge.js
|
||||
packages/app-desktop/checkForUpdates.js
|
||||
packages/app-desktop/commands/copyDevCommand.js
|
||||
packages/app-desktop/commands/copyToClipboard.js
|
||||
packages/app-desktop/commands/createAccessibleDocument.js
|
||||
packages/app-desktop/commands/editProfileConfig.js
|
||||
packages/app-desktop/commands/emptyTrash.js
|
||||
packages/app-desktop/commands/exportDeletionLog.test.js
|
||||
@@ -695,6 +694,8 @@ packages/app-mobile/components/ExtendedWebView/index.js
|
||||
packages/app-mobile/components/ExtendedWebView/index.web.js
|
||||
packages/app-mobile/components/ExtendedWebView/types.js
|
||||
packages/app-mobile/components/ExtendedWebView/utils/useCss.js
|
||||
packages/app-mobile/components/FeedbackBanner.test.js
|
||||
packages/app-mobile/components/FeedbackBanner.js
|
||||
packages/app-mobile/components/FolderPicker.js
|
||||
packages/app-mobile/components/Icon.js
|
||||
packages/app-mobile/components/IconButton.js
|
||||
@@ -739,6 +740,7 @@ packages/app-mobile/components/ScreenHeader/Menu.js
|
||||
packages/app-mobile/components/ScreenHeader/WarningBanner.test.js
|
||||
packages/app-mobile/components/ScreenHeader/WarningBanner.js
|
||||
packages/app-mobile/components/ScreenHeader/WarningBox.js
|
||||
packages/app-mobile/components/ScreenHeader/WebBetaButton.js
|
||||
packages/app-mobile/components/ScreenHeader/index.js
|
||||
packages/app-mobile/components/SearchInput.js
|
||||
packages/app-mobile/components/SelectDateTimeDialog.js
|
||||
@@ -1263,7 +1265,6 @@ packages/lib/SyncTargetRegistry.js
|
||||
packages/lib/Synchronizer.js
|
||||
packages/lib/TaskQueue.js
|
||||
packages/lib/WebDavApi.js
|
||||
packages/lib/WelcomeUtils.test.js
|
||||
packages/lib/WelcomeUtils.js
|
||||
packages/lib/array.js
|
||||
packages/lib/callbackUrlUtils.test.js
|
||||
@@ -1292,7 +1293,6 @@ packages/lib/components/EncryptionConfigScreen/utils.test.js
|
||||
packages/lib/components/EncryptionConfigScreen/utils.js
|
||||
packages/lib/components/shared/NoteEditor/WarningBanner/onRichTextDismissLinkClick.js
|
||||
packages/lib/components/shared/NoteEditor/WarningBanner/onRichTextReadMoreLinkClick.js
|
||||
packages/lib/components/shared/NoteEditor/WarningBanner/useEditorTypeMigrationBanner.js
|
||||
packages/lib/components/shared/NoteList/getEmptyFolderMessage.js
|
||||
packages/lib/components/shared/NoteRevisionViewer/getHelpMessage.js
|
||||
packages/lib/components/shared/NoteRevisionViewer/useDeleteHistoryClick.js
|
||||
@@ -1431,7 +1431,6 @@ packages/lib/services/AlarmServiceDriverNode.js
|
||||
packages/lib/services/BaseService.js
|
||||
packages/lib/services/CommandService.test.js
|
||||
packages/lib/services/CommandService.js
|
||||
packages/lib/services/DecryptionWorker.test.js
|
||||
packages/lib/services/DecryptionWorker.js
|
||||
packages/lib/services/ExternalEditWatcher.js
|
||||
packages/lib/services/ExternalEditWatcher/utils.js
|
||||
@@ -1553,7 +1552,6 @@ packages/lib/services/ocr/OcrService.js
|
||||
packages/lib/services/ocr/drivers/OcrDriverTesseract.js
|
||||
packages/lib/services/ocr/drivers/OcrDriverTranscribe.test.js
|
||||
packages/lib/services/ocr/drivers/OcrDriverTranscribe.js
|
||||
packages/lib/services/ocr/utils/createAccessiblePdf.js
|
||||
packages/lib/services/ocr/utils/filterOcrText.test.js
|
||||
packages/lib/services/ocr/utils/filterOcrText.js
|
||||
packages/lib/services/ocr/utils/types.js
|
||||
@@ -1891,11 +1889,11 @@ packages/tools/fuzzer/utils/SeededRandom.js
|
||||
packages/tools/fuzzer/utils/diffSortedStringArrays.test.js
|
||||
packages/tools/fuzzer/utils/diffSortedStringArrays.js
|
||||
packages/tools/fuzzer/utils/extractResourceIds.js
|
||||
packages/tools/fuzzer/utils/getBinaryDiffDebugMessage.js
|
||||
packages/tools/fuzzer/utils/getNumberProperty.js
|
||||
packages/tools/fuzzer/utils/getProperty.js
|
||||
packages/tools/fuzzer/utils/getStringProperty.js
|
||||
packages/tools/fuzzer/utils/hangingIndent.js
|
||||
packages/tools/fuzzer/utils/logDiffDebug.js
|
||||
packages/tools/fuzzer/utils/openDebugSession.js
|
||||
packages/tools/fuzzer/utils/randomId.test.js
|
||||
packages/tools/fuzzer/utils/randomId.js
|
||||
@@ -1942,7 +1940,6 @@ packages/tools/update-readme-contributors.js
|
||||
packages/tools/update-readme-download.test.js
|
||||
packages/tools/update-readme-download.js
|
||||
packages/tools/update-readme-sponsors.js
|
||||
packages/tools/updateCanary.js
|
||||
packages/tools/updateMarkdownDoc.js
|
||||
packages/tools/utils/discourse.test.js
|
||||
packages/tools/utils/discourse.js
|
||||
|
||||
@@ -214,6 +214,7 @@ module.exports = {
|
||||
'packages/tools/**',
|
||||
'packages/app-mobile/tools/**',
|
||||
'packages/app-desktop/tools/**',
|
||||
'packages/transcribe/src/tools/**',
|
||||
],
|
||||
'rules': {
|
||||
'no-console': 'off',
|
||||
|
||||
10
.github/PULL_REQUEST_TEMPLATE
vendored
10
.github/PULL_REQUEST_TEMPLATE
vendored
@@ -1,12 +1,6 @@
|
||||
<!--
|
||||
|
||||
Before contributing, please read the contribution guidelines: https://github.com/laurent22/joplin/blob/dev/readme/dev/index.md
|
||||
|
||||
If this is a Google Summer of Code pull request, please read the [GSoC pull request guidelines](https://github.com/joplin/gsoc/blob/master/pull_request_guidelines.md).
|
||||
|
||||
---
|
||||
|
||||
**Pull request title**: Please prefix the title with the platform you are targetting.
|
||||
Please prefix the title with the platform you are targetting:
|
||||
|
||||
Here are some examples of good titles:
|
||||
|
||||
@@ -26,4 +20,6 @@ If it's not related to any platform (such as a translation, change to the docume
|
||||
|
||||
Then please append the issue that you've addressed or fixed. Use "Resolves #123" for new features or improvements and "Fixes #123" for bug fixes.
|
||||
|
||||
AND PLEASE READ THE GUIDE: https://github.com/laurent22/joplin/blob/dev/readme/dev/index.md
|
||||
|
||||
-->
|
||||
11
.gitignore
vendored
11
.gitignore
vendored
@@ -142,7 +142,6 @@ packages/app-desktop/bridge.js
|
||||
packages/app-desktop/checkForUpdates.js
|
||||
packages/app-desktop/commands/copyDevCommand.js
|
||||
packages/app-desktop/commands/copyToClipboard.js
|
||||
packages/app-desktop/commands/createAccessibleDocument.js
|
||||
packages/app-desktop/commands/editProfileConfig.js
|
||||
packages/app-desktop/commands/emptyTrash.js
|
||||
packages/app-desktop/commands/exportDeletionLog.test.js
|
||||
@@ -668,6 +667,8 @@ packages/app-mobile/components/ExtendedWebView/index.js
|
||||
packages/app-mobile/components/ExtendedWebView/index.web.js
|
||||
packages/app-mobile/components/ExtendedWebView/types.js
|
||||
packages/app-mobile/components/ExtendedWebView/utils/useCss.js
|
||||
packages/app-mobile/components/FeedbackBanner.test.js
|
||||
packages/app-mobile/components/FeedbackBanner.js
|
||||
packages/app-mobile/components/FolderPicker.js
|
||||
packages/app-mobile/components/Icon.js
|
||||
packages/app-mobile/components/IconButton.js
|
||||
@@ -712,6 +713,7 @@ packages/app-mobile/components/ScreenHeader/Menu.js
|
||||
packages/app-mobile/components/ScreenHeader/WarningBanner.test.js
|
||||
packages/app-mobile/components/ScreenHeader/WarningBanner.js
|
||||
packages/app-mobile/components/ScreenHeader/WarningBox.js
|
||||
packages/app-mobile/components/ScreenHeader/WebBetaButton.js
|
||||
packages/app-mobile/components/ScreenHeader/index.js
|
||||
packages/app-mobile/components/SearchInput.js
|
||||
packages/app-mobile/components/SelectDateTimeDialog.js
|
||||
@@ -1236,7 +1238,6 @@ packages/lib/SyncTargetRegistry.js
|
||||
packages/lib/Synchronizer.js
|
||||
packages/lib/TaskQueue.js
|
||||
packages/lib/WebDavApi.js
|
||||
packages/lib/WelcomeUtils.test.js
|
||||
packages/lib/WelcomeUtils.js
|
||||
packages/lib/array.js
|
||||
packages/lib/callbackUrlUtils.test.js
|
||||
@@ -1265,7 +1266,6 @@ packages/lib/components/EncryptionConfigScreen/utils.test.js
|
||||
packages/lib/components/EncryptionConfigScreen/utils.js
|
||||
packages/lib/components/shared/NoteEditor/WarningBanner/onRichTextDismissLinkClick.js
|
||||
packages/lib/components/shared/NoteEditor/WarningBanner/onRichTextReadMoreLinkClick.js
|
||||
packages/lib/components/shared/NoteEditor/WarningBanner/useEditorTypeMigrationBanner.js
|
||||
packages/lib/components/shared/NoteList/getEmptyFolderMessage.js
|
||||
packages/lib/components/shared/NoteRevisionViewer/getHelpMessage.js
|
||||
packages/lib/components/shared/NoteRevisionViewer/useDeleteHistoryClick.js
|
||||
@@ -1404,7 +1404,6 @@ packages/lib/services/AlarmServiceDriverNode.js
|
||||
packages/lib/services/BaseService.js
|
||||
packages/lib/services/CommandService.test.js
|
||||
packages/lib/services/CommandService.js
|
||||
packages/lib/services/DecryptionWorker.test.js
|
||||
packages/lib/services/DecryptionWorker.js
|
||||
packages/lib/services/ExternalEditWatcher.js
|
||||
packages/lib/services/ExternalEditWatcher/utils.js
|
||||
@@ -1526,7 +1525,6 @@ packages/lib/services/ocr/OcrService.js
|
||||
packages/lib/services/ocr/drivers/OcrDriverTesseract.js
|
||||
packages/lib/services/ocr/drivers/OcrDriverTranscribe.test.js
|
||||
packages/lib/services/ocr/drivers/OcrDriverTranscribe.js
|
||||
packages/lib/services/ocr/utils/createAccessiblePdf.js
|
||||
packages/lib/services/ocr/utils/filterOcrText.test.js
|
||||
packages/lib/services/ocr/utils/filterOcrText.js
|
||||
packages/lib/services/ocr/utils/types.js
|
||||
@@ -1864,11 +1862,11 @@ packages/tools/fuzzer/utils/SeededRandom.js
|
||||
packages/tools/fuzzer/utils/diffSortedStringArrays.test.js
|
||||
packages/tools/fuzzer/utils/diffSortedStringArrays.js
|
||||
packages/tools/fuzzer/utils/extractResourceIds.js
|
||||
packages/tools/fuzzer/utils/getBinaryDiffDebugMessage.js
|
||||
packages/tools/fuzzer/utils/getNumberProperty.js
|
||||
packages/tools/fuzzer/utils/getProperty.js
|
||||
packages/tools/fuzzer/utils/getStringProperty.js
|
||||
packages/tools/fuzzer/utils/hangingIndent.js
|
||||
packages/tools/fuzzer/utils/logDiffDebug.js
|
||||
packages/tools/fuzzer/utils/openDebugSession.js
|
||||
packages/tools/fuzzer/utils/randomId.test.js
|
||||
packages/tools/fuzzer/utils/randomId.js
|
||||
@@ -1915,7 +1913,6 @@ packages/tools/update-readme-contributors.js
|
||||
packages/tools/update-readme-download.test.js
|
||||
packages/tools/update-readme-download.js
|
||||
packages/tools/update-readme-sponsors.js
|
||||
packages/tools/updateCanary.js
|
||||
packages/tools/updateMarkdownDoc.js
|
||||
packages/tools/utils/discourse.test.js
|
||||
packages/tools/utils/discourse.js
|
||||
|
||||
@@ -1,24 +0,0 @@
|
||||
# Resolves an issue in which notes and attachments larger than 16 KB
|
||||
# could become corrupted during the upload process.
|
||||
# See https://github.com/laurent22/joplin/issues/14343
|
||||
diff --git a/src/parsers/JSON.js b/src/parsers/JSON.js
|
||||
index 9a096c25778c7c68be1ddd9dd78faa85bd1d8ec3..6d6bfd2d3789313a7adc8966ab8e58c3d3167356 100644
|
||||
--- a/src/parsers/JSON.js
|
||||
+++ b/src/parsers/JSON.js
|
||||
@@ -12,13 +12,14 @@ class JSONParser extends Transform {
|
||||
}
|
||||
|
||||
_transform(chunk, encoding, callback) {
|
||||
- this.chunks.push(String(chunk)); // todo consider using a string decoder
|
||||
+ this.chunks.push(chunk); // type: Uint8Array
|
||||
callback();
|
||||
}
|
||||
|
||||
_flush(callback) {
|
||||
try {
|
||||
- const fields = JSON.parse(this.chunks.join(''));
|
||||
+ const data = Buffer.concat(this.chunks);
|
||||
+ const fields = JSON.parse(data.toString('utf-8'));
|
||||
Object.keys(fields).forEach((key) => {
|
||||
const value = fields[key];
|
||||
this.push({ key, value });
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 8.0 KiB |
@@ -1,24 +1,4 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?><rss xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:atom="http://www.w3.org/2005/Atom" version="2.0"><channel><title><![CDATA[Joplin]]></title><description><![CDATA[Joplin, the open source note-taking application]]></description><link>https://joplinapp.org</link><generator>RSS for Node</generator><lastBuildDate>Mon, 23 Feb 2026 00:00:00 GMT</lastBuildDate><atom:link href="https://joplinapp.org/rss.xml" rel="self" type="application/rss+xml"/><pubDate>Mon, 23 Feb 2026 00:00:00 GMT</pubDate><item><title><![CDATA[Introducing our Warrant Canary]]></title><description><![CDATA[<p>We have introduced a publicly signed warrant canary for Joplin.</p>
|
||||
<p>A warrant canary is a regularly updated statement confirming that, as of the stated date, the project has not received secret legal orders, gag orders, or demands requiring the introduction of backdoors into the software or its infrastructure.</p>
|
||||
<p>The canary is:</p>
|
||||
<ul>
|
||||
<li>
|
||||
<p>Cryptographically signed using a dedicated OpenPGP key</p>
|
||||
</li>
|
||||
<li>
|
||||
<p>Updated every 60 days</p>
|
||||
</li>
|
||||
<li>
|
||||
<p>Published in plain text for independent verification</p>
|
||||
</li>
|
||||
</ul>
|
||||
<p>If the canary is not updated within its stated validity window, it should be considered expired.</p>
|
||||
<p>You can view and verify the current canary here:</p>
|
||||
<p><a href="https://raw.githubusercontent.com/laurent22/joplin/refs/heads/dev/readme/canary.txt">https://raw.githubusercontent.com/laurent22/joplin/refs/heads/dev/readme/canary.txt</a></p>
|
||||
<p>With additional information on how it is generated and managed there:</p>
|
||||
<p><a href="https://github.com/laurent22/joplin/blob/dev/readme/canary.md">https://github.com/laurent22/joplin/blob/dev/readme/canary.md</a></p>
|
||||
<p>This measure is intended to improve transparency and provide an additional signal to the community. It does not prevent legal orders, but it helps ensure that any material change in our legal status cannot occur silently.</p>
|
||||
]]></description><link>https://joplinapp.org/news/20260223-warrant-canary</link><guid isPermaLink="false">20260223-warrant-canary</guid><pubDate>Mon, 23 Feb 2026 00:00:00 GMT</pubDate><twitter-text></twitter-text></item><item><title><![CDATA[Joplin will come preloaded on the HMD Terra M]]></title><description><![CDATA[<div style="overflow: auto;">
|
||||
<?xml version="1.0" encoding="UTF-8"?><rss xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:atom="http://www.w3.org/2005/Atom" version="2.0"><channel><title><![CDATA[Joplin]]></title><description><![CDATA[Joplin, the open source note-taking application]]></description><link>https://joplinapp.org</link><generator>RSS for Node</generator><lastBuildDate>Tue, 10 Feb 2026 00:00:00 GMT</lastBuildDate><atom:link href="https://joplinapp.org/rss.xml" rel="self" type="application/rss+xml"/><pubDate>Tue, 10 Feb 2026 00:00:00 GMT</pubDate><item><title><![CDATA[Joplin will come preloaded on the HMD Terra M]]></title><description><![CDATA[<div style="overflow: auto;">
|
||||
<img src="https://raw.githubusercontent.com/laurent22/joplin/dev/Assets/WebsiteAssets/images/news/20260210-hmd-joplin-logo.png" width="200px" style="float: left; margin-right: 16px; margin-bottom: 16px;"/>
|
||||
<p>We’re happy to announce a collaboration with <a href="https://www.hmdsecure.com/">HMD Secure</a>, who will preload Joplin on their upcoming device, the HMD Terra M.</p>
|
||||
<p>This partnership brings Joplin to a new class of rugged, professional devices built for instant reliable communication, and reflects a shared focus on reliability, security, and long-term use.</p>
|
||||
@@ -528,4 +508,15 @@ sys 0m38.013s</p>
|
||||
]]></description><link>https://joplinapp.org/news/20230508-release-2-10</link><guid isPermaLink="false">20230508-release-2-10</guid><pubDate>Wed, 10 May 2023 12:00:00 GMT</pubDate><twitter-text>What's new in Joplin 2.10</twitter-text></item><item><title><![CDATA[Joplin will participate in JdLL 2023!]]></title><description><![CDATA[<p>On 1 and 2 April 2023, we will have a stand for Joplin at the <a href="https://www.jdll.org/">Journées du Logiciel Libre</a> in Lyon, France. The JdLL has been taking place in Lyon for 24 years and is a popular open source conference in France. We had a stand in 2020 and 2021 but that was cancelled due to Covid, so this year is a first for Joplin!</p>
|
||||
<p>Admission is free, so don't hesitate to come and meet us, exchange ideas and learn more about Joplin!</p>
|
||||
<p><img src="https://raw.githubusercontent.com/laurent22/joplin/dev/Assets/WebsiteAssets/images/news/20230202-jdll.jpg" alt="Joplin at JdLL 2023"></p>
|
||||
]]></description><link>https://joplinapp.org/news/20230302-jdll-2023</link><guid isPermaLink="false">20230302-jdll-2023</guid><pubDate>Thu, 02 Mar 2023 00:00:00 GMT</pubDate><twitter-text></twitter-text></item></channel></rss>
|
||||
]]></description><link>https://joplinapp.org/news/20230302-jdll-2023</link><guid isPermaLink="false">20230302-jdll-2023</guid><pubDate>Thu, 02 Mar 2023 00:00:00 GMT</pubDate><twitter-text></twitter-text></item><item><title><![CDATA[Introducing the "GitHub Actions Raw Log Viewer" extension for Chrome]]></title><description><![CDATA[<p>If you've ever used GitHub Actions, you will find that they provide by default a nice coloured output for the log. It looks good and it's even interactive! (You can click to collapse/expand blocks of text) But unfortunately it doesn't scale to large workflows, like we have for Joplin - the log can freeze and it will take forever to search for something. Indeed searching is done in "real time"... which mostly means it will freeze for a minute or two for each letter you type in the search box. Not great.</p>
|
||||
<p>Thankfully GitHub provides an alternative access: the raw logs. This is much better because they will open as plain text, without any styling or JS magic, which means you can use the browser native search and it will be fast.</p>
|
||||
<p>But now the problem is that raw logs look like this:</p>
|
||||
<p><img src="https://raw.githubusercontent.com/laurent22/joplin/dev/Assets/WebsiteAssets/images/news/20230116-ga-raw-log.png" alt="Raw log without extension"></p>
|
||||
<p>While it's not impossible to read, all colours that would display nicely in a terminal are gone and replaced by <a href="https://en.wikipedia.org/wiki/ANSI_escape_code">ANSI codes</a>. You can find what you need in there but it's not particularly easy.</p>
|
||||
<p>This is where the new <strong>GitHub Action Raw Log Viewer</strong> extension for Chrome can help. It will parse your raw log and convert the ANSI codes to proper colours. This results in a much more readable rendering:</p>
|
||||
<p><img src="https://raw.githubusercontent.com/laurent22/joplin/dev/Assets/WebsiteAssets/images/news/20230116-ga-raw-log-colored.png" alt="Raw log with extension"></p>
|
||||
<p>The extension is fast even for very large logs and it's of course easy to search for text since it simply works with your browser built-in search.</p>
|
||||
<p>The extension is open source, with the code available here: <a href="https://github.com/laurent22/github-actions-logs-extension">https://github.com/laurent22/github-actions-logs-extension</a></p>
|
||||
<p>And to install it, follow this link:</p>
|
||||
<p><a href="https://chrome.google.com/webstore/detail/github-action-raw-log-vie/lgejlnoopmcdglhfjblaeldbcfnmjddf"><img src="https://raw.githubusercontent.com/laurent22/joplin/dev/Assets/WebsiteAssets/images/news/20230116-extension-get-it-now.png" alt="Download GitHub Action Raw Log Viewer extension"></a></p>
|
||||
]]></description><link>https://joplinapp.org/news/20230116-github-actions-log-viewer</link><guid isPermaLink="false">20230116-github-actions-log-viewer</guid><pubDate>Mon, 16 Jan 2023 00:00:00 GMT</pubDate><twitter-text>Introducing the "GitHub Action Raw Log Viewer" extension for Chrome</twitter-text></item></channel></rss>
|
||||
@@ -1,14 +0,0 @@
|
||||
-----BEGIN PGP PUBLIC KEY BLOCK-----
|
||||
|
||||
mDMEaZWFlBYJKwYBBAHaRw8BAQdAIh3xQbjaS0EC+8WuKXNPjVF/ayq0/2GZlheR
|
||||
qj1G3Qe0RUpvcGxpbiBDYW5hcnkgU2lnbmluZyBLZXkgKFdhcnJhbnQgQ2FuYXJ5
|
||||
IEtleSkgPGNhbmFyeUBqb3BsaW5hcHAub3JnPoiZBBMWCgBBFiEE+CD4MG3QBaEC
|
||||
0YzVlGrp+lkV71MFAmmVhZQCGwMFCQPCZwAFCwkIBwICIgIGFQoJCAsCBBYCAwEC
|
||||
HgcCF4AACgkQlGrp+lkV71MZtwD/Ufd4OAcgkl5T6MSB+WDFg8BXvpaBZfNnZkoo
|
||||
LrOoqNAA/iqGiiBRoarlus2ATOiWhyXaEpRUQcEeaRhhqHW0BGcCuDgEaZWFlBIK
|
||||
KwYBBAGXVQEFAQEHQFORKWRLp4hDYzR8Q5IRyF9AIjoziR+sj4icUdvZx4Z6AwEI
|
||||
B4h+BBgWCgAmFiEE+CD4MG3QBaEC0YzVlGrp+lkV71MFAmmVhZQCGwwFCQPCZwAA
|
||||
CgkQlGrp+lkV71Nu+AD9Gw4qEmL8WNCNs7idc8CRpGpS2DhasNTV398lbKYzco0B
|
||||
ANlMrGlMc0w1KhuFxdU4fF3s/ktUUnjJwosxK94l5/MJ
|
||||
=C9VN
|
||||
-----END PGP PUBLIC KEY BLOCK-----
|
||||
15
CLAUDE.md
15
CLAUDE.md
@@ -1,15 +0,0 @@
|
||||
# Joplin Guidelines
|
||||
|
||||
## Quick Reference
|
||||
|
||||
- Tabs for indentation
|
||||
- Single quotes for strings
|
||||
- Proper TypeScript types (avoid `any`)
|
||||
- Comments should be only with `//` and should not contain jsdoc syntax
|
||||
- If you duplicate a substantial block of code, add a comment above it noting the duplication and referencing the original location.
|
||||
- When creating Jest tests, there should be only one `describe()` statement in the file.
|
||||
|
||||
## Full Documentation
|
||||
|
||||
- Coding style: [readme/dev/coding_style.md](readme/dev/coding_style.md)
|
||||
- Contributing: [CONTRIBUTING.md](CONTRIBUTING.md)
|
||||
@@ -1,25 +1,26 @@
|
||||
FROM node:24-bookworm
|
||||
FROM node:24-bullseye
|
||||
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y \
|
||||
ca-certificates curl wget unzip \
|
||||
python3 tini \
|
||||
ca-certificates curl \
|
||||
python3 tini
|
||||
|
||||
## install docker
|
||||
RUN install -m 0755 -d /etc/apt/keyrings
|
||||
RUN curl -fsSL https://download.docker.com/linux/ubuntu/gpg -o /etc/apt/keyrings/docker.asc
|
||||
RUN chmod a+r /etc/apt/keyrings/docker.asc
|
||||
RUN echo \
|
||||
"deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.asc] https://download.docker.com/linux/debian \
|
||||
$(. /etc/os-release && echo bullseye) stable" | \
|
||||
tee /etc/apt/sources.list.d/docker.list > /dev/null
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
ENV NODE_ENV=production
|
||||
|
||||
RUN corepack enable
|
||||
|
||||
# Download llama.cpp binary
|
||||
WORKDIR /opt/llama
|
||||
RUN wget -q https://github.com/ggml-org/llama.cpp/releases/download/b5449/llama-b5449-bin-ubuntu-x64.zip \
|
||||
&& unzip llama-b5449-bin-ubuntu-x64.zip \
|
||||
&& rm llama-b5449-bin-ubuntu-x64.zip \
|
||||
&& chmod +x /opt/llama/build/bin/llama-mtmd-cli
|
||||
|
||||
# Create non-root user for security
|
||||
RUN groupadd -r transcribe && useradd -r -g transcribe -m transcribe
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY .yarn/releases ./.yarn/releases
|
||||
@@ -43,21 +44,7 @@ RUN BUILD_SEQUENCIAL=1 yarn install --inline-builds \
|
||||
&& yarn cache clean \
|
||||
&& rm -rf .yarn/berry
|
||||
|
||||
# Create data directory and set permissions
|
||||
RUN mkdir -p /data/images \
|
||||
&& chown -R transcribe:transcribe /data
|
||||
|
||||
WORKDIR /app/packages/transcribe
|
||||
|
||||
# Switch to non-root user
|
||||
USER transcribe
|
||||
|
||||
# Set environment variables
|
||||
ENV HTR_CLI_BINARY_PATH=/opt/llama/build/bin/llama-mtmd-cli
|
||||
ENV LD_LIBRARY_PATH=/opt/llama/build/bin
|
||||
ENV DATA_DIR=/data
|
||||
ENV QUEUE_DRIVER=sqlite
|
||||
|
||||
# Start the Node.js application
|
||||
ENTRYPOINT ["/usr/bin/tini", "--"]
|
||||
CMD ["yarn", "start"]
|
||||
|
||||
16
README.md
16
README.md
@@ -31,7 +31,7 @@ Please see the [donation page](https://github.com/laurent22/joplin/blob/dev/read
|
||||
# Sponsors
|
||||
|
||||
<!-- SPONSORS-ORG -->
|
||||
<a href="https://seirei.ne.jp"><img title="Serei Network" width="256" src="https://joplinapp.org/images/sponsors/SeireiNetwork.png"/></a> <a href="https://citricsheep.com"><img title="Citric Sheep" width="256" src="https://joplinapp.org/images/sponsors/CitricSheep.png"/></a> <a href="https://sorted.travel/?utm_source=joplinapp"><img title="Sorted Travel" width="256" src="https://joplinapp.org/images/sponsors/SortedTravel.png"/></a> <a href="https://celebian.com"><img title="Celebian" width="256" src="https://joplinapp.org/images/sponsors/Celebian.png"/></a> <a href="https://bestkru.com"><img title="BestKru" width="256" src="https://joplinapp.org/images/sponsors/BestKru.png"/></a> <a href="https://www.socialfollowers.uk/buy-tiktok-followers/"><img title="Social Followers" width="256" src="https://joplinapp.org/images/sponsors/SocialFollowers.png"/></a> <a href="https://stormlikes.com/"><img title="Stormlikes" width="256" src="https://joplinapp.org/images/sponsors/Stormlikes.png"/></a> <a href="https://route4me.com"><img title="Route4Me" width="256" src="https://joplinapp.org/images/sponsors/Route4Me.png"/></a> <a href="https://topagency.webflow.io"><img title="WebDesignAgency" width="256" src="https://joplinapp.org/images/sponsors/WebDesignAgency.png" alt="topagency"/></a> <a href="https://essayshark.com"><img title="EssayShark - essay writers for hire" width="256" src="https://joplinapp.org/images/sponsors/EssayShark.png" alt="EssayShark - essay writers for hire"/></a> <a href="https://pokieslab1.com/real-money-pokies/"><img title="Australian Real Money Pokies" width="256" src="https://joplinapp.org/images/sponsors/PokiesLab.png" alt="Australian Real Money Pokies"/></a> <a href="https://pokiesman1.net/real-money-pokies/"><img title="Australian Real Money Pokies" width="256" src="https://joplinapp.org/images/sponsors/Pokiesman.png" alt="Australian Real Money Pokies"/></a> <a href="https://socialkings.online"><img title="Boost your reach and buy real followers" width="256" src="https://joplinapp.org/images/sponsors/SocialKings.png" alt="Boost your reach and buy real followers"/></a> <a href="https://essayservice.com/"><img title="For those in need of immediate academic assistance, EssayService offers a fast and reliable service to write my essay for me now, ensuring high-quality results within tight deadlines" width="256" src="https://joplinapp.org/images/sponsors/EssayService.png" alt="For those in need of immediate academic assistance, EssayService offers a fast and reliable service to write my essay for me now, ensuring high-quality results within tight deadlines"/></a> <a href="https://thenationonlineng.net/casino-en-ligne/casino-en-ligne-payant-au-canada/"><img title="casino en ligne le plus payant" width="256" src="https://joplinapp.org/images/sponsors/TheNationOnline.jpg" alt="casino en ligne le plus payant"/></a>
|
||||
<a href="https://seirei.ne.jp"><img title="Serei Network" width="256" src="https://joplinapp.org/images/sponsors/SeireiNetwork.png"/></a> <a href="https://citricsheep.com"><img title="Citric Sheep" width="256" src="https://joplinapp.org/images/sponsors/CitricSheep.png"/></a> <a href="https://sorted.travel/?utm_source=joplinapp"><img title="Sorted Travel" width="256" src="https://joplinapp.org/images/sponsors/SortedTravel.png"/></a> <a href="https://celebian.com"><img title="Celebian" width="256" src="https://joplinapp.org/images/sponsors/Celebian.png"/></a> <a href="https://bestkru.com"><img title="BestKru" width="256" src="https://joplinapp.org/images/sponsors/BestKru.png"/></a> <a href="https://www.socialfollowers.uk/buy-tiktok-followers/"><img title="Social Followers" width="256" src="https://joplinapp.org/images/sponsors/SocialFollowers.png"/></a> <a href="https://stormlikes.com/"><img title="Stormlikes" width="256" src="https://joplinapp.org/images/sponsors/Stormlikes.png"/></a> <a href="https://route4me.com"><img title="Route4Me" width="256" src="https://joplinapp.org/images/sponsors/Route4Me.png"/></a> <a href="https://topagency.webflow.io"><img title="WebDesignAgency" width="256" src="https://joplinapp.org/images/sponsors/WebDesignAgency.png" alt="topagency"/></a> <a href="https://www.slotozilla.com/nz/no-deposit-bonus"><img title="casino without making any upfront cost" width="256" src="https://joplinapp.org/images/sponsors/Slotozilla.png" alt="casino without making any upfront cost"/></a> <a href="https://writepaper.com/"><img title="best service to write my paper for me" width="256" src="https://joplinapp.org/images/sponsors/WritePaper.png" alt="best service to write my paper for me"/></a> <a href="https://paperwriter.com/"><img title="high-quality paper writing service PaperWriter" width="256" src="https://joplinapp.org/images/sponsors/PaperWriter.png" alt="high-quality paper writing service PaperWriter"/></a> <a href="https://www.bestetf.net/"><img title="BestETF" width="256" src="https://joplinapp.org/images/sponsors/BestEtf.png" alt="BestETF"/></a> <a href="https://freespinny.io/free-spins-no-deposit/"><img title="Freespinny.io Free Spins Bonus site" width="256" src="https://joplinapp.org/images/sponsors/Freespinny.png" alt="Freespinny.io Free Spins Bonus site"/></a> <a href="https://essayshark.com"><img title="EssayShark - essay writers for hire" width="256" src="https://joplinapp.org/images/sponsors/EssayShark.png" alt="EssayShark - essay writers for hire"/></a> <a href="https://pokieslab1.com/real-money-pokies/"><img title="Australian Real Money Pokies" width="256" src="https://joplinapp.org/images/sponsors/PokiesLab.png" alt="Australian Real Money Pokies"/></a> <a href="https://pokiesman1.net/real-money-pokies/"><img title="Australian Real Money Pokies" width="256" src="https://joplinapp.org/images/sponsors/Pokiesman.png" alt="Australian Real Money Pokies"/></a> <a href="https://domyessay.com"><img title="Essay writers DoMyEssay are dedicated to providing top-notch, custom-written papers that meet your academic requirements" width="256" src="https://joplinapp.org/images/sponsors/DoMyEssay.png" alt="DoMyEssay"/></a> <a href="https://essaypro.com/"><img title="best essay writing service" width="256" src="https://joplinapp.org/images/sponsors/EssayPro.png" alt="best essay writing service"/></a> <a href="https://socialkings.online"><img title="Boost your reach and buy real followers" width="256" src="https://joplinapp.org/images/sponsors/SocialKings.png" alt="Boost your reach and buy real followers"/></a> <a href="https://uk.notgamstop.com/bonuses/free-spins-no-deposit-no-gamstop/"><img title="free spins no deposit at NotGamstop" width="256" src="https://joplinapp.org/images/sponsors/NotGamStop.jpg" alt="free spins no deposit at NotGamstop"/></a> <a href="https://www.writemyessay.com/"><img title="writing service for students WriteMyEssay" width="256" src="https://joplinapp.org/images/sponsors/WriteMyEssay.png" alt="writing service for students WriteMyEssay"/></a> <a href="https://essayservice.com/"><img title="For those in need of immediate academic assistance, EssayService offers a fast and reliable service to write my essay for me now, ensuring high-quality results within tight deadlines" width="256" src="https://joplinapp.org/images/sponsors/EssayService.png" alt="For those in need of immediate academic assistance, EssayService offers a fast and reliable service to write my essay for me now, ensuring high-quality results within tight deadlines"/></a>
|
||||
<!-- SPONSORS-ORG -->
|
||||
|
||||
* * *
|
||||
@@ -39,9 +39,9 @@ Please see the [donation page](https://github.com/laurent22/joplin/blob/dev/read
|
||||
<!-- SPONSORS-GITHUB -->
|
||||
| | | | |
|
||||
| :---: | :---: | :---: | :---: |
|
||||
| <img width="50" src="https://avatars2.githubusercontent.com/u/552452?s=96&v=4"/></br>[andypiper](https://github.com/andypiper) | <img width="50" src="https://avatars2.githubusercontent.com/u/215668?s=96&v=4"/></br>[avanderberg](https://github.com/avanderberg) | <img width="50" src="https://avatars2.githubusercontent.com/u/67130?s=96&v=4"/></br>[chr15m](https://github.com/chr15m) | <img width="50" src="https://avatars2.githubusercontent.com/u/1177810?s=96&v=4"/></br>[felixstorm](https://github.com/felixstorm) |
|
||||
| <img width="50" src="https://avatars2.githubusercontent.com/u/8030470?s=96&v=4"/></br>[Galliver7](https://github.com/Galliver7) | <img width="50" src="https://avatars2.githubusercontent.com/u/4721118?s=96&v=4"/></br>[GPrimola](https://github.com/GPrimola) | <img width="50" src="https://avatars2.githubusercontent.com/u/64712218?s=96&v=4"/></br>[Hegghammer](https://github.com/Hegghammer) | <img width="50" src="https://avatars2.githubusercontent.com/u/42319182?s=96&v=4"/></br>[marcdw1289](https://github.com/marcdw1289) |
|
||||
| <img width="50" src="https://avatars2.githubusercontent.com/u/668977?s=96&v=4"/></br>[ugoertz](https://github.com/ugoertz) | | | |
|
||||
| <img width="50" src="https://avatars2.githubusercontent.com/u/97193607?s=96&v=4"/></br>[Akhil-CM](https://github.com/Akhil-CM) | <img width="50" src="https://avatars2.githubusercontent.com/u/552452?s=96&v=4"/></br>[andypiper](https://github.com/andypiper) | <img width="50" src="https://avatars2.githubusercontent.com/u/215668?s=96&v=4"/></br>[avanderberg](https://github.com/avanderberg) | <img width="50" src="https://avatars2.githubusercontent.com/u/67130?s=96&v=4"/></br>[chr15m](https://github.com/chr15m) |
|
||||
| <img width="50" src="https://avatars2.githubusercontent.com/u/1177810?s=96&v=4"/></br>[felixstorm](https://github.com/felixstorm) | <img width="50" src="https://avatars2.githubusercontent.com/u/11947658?s=96&v=4"/></br>[KentBrockman](https://github.com/KentBrockman) | <img width="50" src="https://avatars2.githubusercontent.com/u/42319182?s=96&v=4"/></br>[marcdw1289](https://github.com/marcdw1289) | <img width="50" src="https://avatars2.githubusercontent.com/u/668977?s=96&v=4"/></br>[ugoertz](https://github.com/ugoertz) |
|
||||
| | | | |
|
||||
<!-- SPONSORS-GITHUB -->
|
||||
|
||||
# Community
|
||||
@@ -61,14 +61,6 @@ Name | Description
|
||||
|
||||
Please see the guide for information on how to contribute to the development of Joplin: https://github.com/laurent22/joplin/blob/dev/readme/dev/index.md
|
||||
|
||||
## Warrant Canary Signing Key
|
||||
|
||||
Fingerprint:
|
||||
|
||||
F820 F830 6DD0 05A1 02D1 8CD5 946A E9FA 5915 EF53
|
||||
|
||||
Public key: https://github.com/laurent22/joplin/raw/dev/Assets/keys/joplin-canary-signing-key.asc
|
||||
|
||||
# Contributors
|
||||
|
||||
Thank you to everyone who've contributed to Joplin's source code!
|
||||
|
||||
@@ -63,7 +63,6 @@
|
||||
"/readme/_i18n",
|
||||
"/readme/about/changelog/desktop.md",
|
||||
"/readme/licenses.md",
|
||||
"/readme/canary.txt",
|
||||
"/readme/i18n",
|
||||
"cspell.json",
|
||||
"node_modules"
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
"vips.dev": {
|
||||
"platforms": ["aarch64-darwin"],
|
||||
},
|
||||
"nodejs": "24.10.0",
|
||||
"nodejs": "24.9.0",
|
||||
"pkg-config": "latest",
|
||||
"python": "3.13.3",
|
||||
"bat": "latest",
|
||||
|
||||
@@ -84,8 +84,8 @@ services:
|
||||
profiles:
|
||||
- full
|
||||
volumes:
|
||||
- /var/run/docker.sock:/var/run/docker.sock
|
||||
- ${HTR_CLI_IMAGES_FOLDER}:/app/packages/transcribe/images
|
||||
- ${HTR_CLI_MODELS_FOLDER}:/opt/models:ro
|
||||
depends_on:
|
||||
- transcribe-db
|
||||
ports:
|
||||
@@ -94,16 +94,6 @@ services:
|
||||
- transcribe-network
|
||||
- shared-network
|
||||
restart: unless-stopped
|
||||
# Security: limit resources to prevent runaway processes
|
||||
deploy:
|
||||
resources:
|
||||
limits:
|
||||
memory: 16G
|
||||
cpus: '4'
|
||||
# Security: read-only root filesystem with only images folder writable
|
||||
read_only: true
|
||||
tmpfs:
|
||||
- /tmp
|
||||
environment:
|
||||
- APP_PORT=4567
|
||||
- DB_CLIENT=pg
|
||||
@@ -113,6 +103,5 @@ services:
|
||||
- QUEUE_DATABASE_PORT=${QUEUE_DATABASE_PORT}
|
||||
- QUEUE_DATABASE_HOST=transcribe-db
|
||||
- API_KEY=${TRANSCRIBE_API_KEY}
|
||||
- HTR_CLI_IMAGES_FOLDER=/app/packages/transcribe/images
|
||||
- HTR_CLI_MODELS_FOLDER=/opt/models
|
||||
- HTR_CLI_IMAGES_FOLDER=${HTR_CLI_IMAGES_FOLDER}
|
||||
|
||||
|
||||
@@ -1,44 +0,0 @@
|
||||
# Standalone docker-compose for Joplin Transcribe
|
||||
#
|
||||
# Uses SQLite for the queue (no external database needed).
|
||||
# Data is stored in a named volume for proper permissions.
|
||||
#
|
||||
# Usage:
|
||||
#
|
||||
# 1. Download models:
|
||||
# mkdir -p ./data/models
|
||||
# wget -O ./data/models/Model-7.6B-Q4_K_M.gguf https://huggingface.co/openbmb/MiniCPM-o-2_6-gguf/resolve/main/Model-7.6B-Q4_K_M.gguf
|
||||
# wget -O ./data/models/mmproj-model-f16.gguf https://huggingface.co/openbmb/MiniCPM-o-2_6-gguf/resolve/main/mmproj-model-f16.gguf
|
||||
#
|
||||
# 2. Configure:
|
||||
# cp .env-transcribe-sample .env
|
||||
# # Edit .env and set API_KEY
|
||||
#
|
||||
# 3. Run:
|
||||
# docker compose -f docker-compose.transcribe.yml up
|
||||
|
||||
volumes:
|
||||
transcribe-data:
|
||||
|
||||
services:
|
||||
transcribe:
|
||||
image: joplin/transcribe:amd64-latest
|
||||
ports:
|
||||
- "4567:4567"
|
||||
volumes:
|
||||
- transcribe-data:/data
|
||||
- ./data/models:/data/models:ro
|
||||
restart: unless-stopped
|
||||
# Security: limit resources to prevent runaway processes
|
||||
deploy:
|
||||
resources:
|
||||
limits:
|
||||
memory: 16G
|
||||
cpus: '4'
|
||||
# Security: read-only root filesystem
|
||||
read_only: true
|
||||
tmpfs:
|
||||
- /tmp
|
||||
- /home/transcribe/.cache
|
||||
env_file:
|
||||
- .env
|
||||
@@ -60,7 +60,6 @@
|
||||
"test": "yarn workspaces foreach --worktree --parallel --verbose --interlaced --jobs 2 run test",
|
||||
"tsc": "yarn workspaces foreach --worktree --parallel --verbose --interlaced run tsc",
|
||||
"updateIgnored": "node packages/tools/gulp/tasks/updateIgnoredTypeScriptBuildRun.js",
|
||||
"updateCanary": "node ./packages/tools/updateCanary",
|
||||
"updateMarkdownDoc": "node ./packages/tools/updateMarkdownDoc",
|
||||
"updateNews": "node ./packages/tools/website/updateNews",
|
||||
"updatePluginTypes": "./packages/generator-joplin/updateTypes.sh",
|
||||
@@ -124,7 +123,6 @@
|
||||
"depd@npm:~1.1.2": "patch:depd@npm%3A2.0.0#~/.yarn/patches/depd-npm-2.0.0-b6c51a4b43.patch",
|
||||
"depd@npm:2.0.0": "patch:depd@npm%3A2.0.0#~/.yarn/patches/depd-npm-2.0.0-b6c51a4b43.patch",
|
||||
"depd@npm:^1.1.2": "patch:depd@npm%3A2.0.0#~/.yarn/patches/depd-npm-2.0.0-b6c51a4b43.patch",
|
||||
"depd@npm:^1.1.0": "patch:depd@npm%3A2.0.0#~/.yarn/patches/depd-npm-2.0.0-b6c51a4b43.patch",
|
||||
"formidable@npm:^2.0.1": "patch:formidable@npm%3A2.1.2#~/.yarn/patches/formidable-npm-2.1.2-40ba18d67f.patch"
|
||||
"depd@npm:^1.1.0": "patch:depd@npm%3A2.0.0#~/.yarn/patches/depd-npm-2.0.0-b6c51a4b43.patch"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -638,7 +638,6 @@ class Application extends BaseApplication {
|
||||
|
||||
if (Setting.value('env') === 'dev') {
|
||||
void AlarmService.updateAllNotifications();
|
||||
RevisionService.instance().runInBackground();
|
||||
} else {
|
||||
// eslint-disable-next-line promise/prefer-await-to-then -- Old code before rule was applied
|
||||
void reg.scheduleSync(1000).then(() => {
|
||||
@@ -647,11 +646,10 @@ class Application extends BaseApplication {
|
||||
void AlarmService.updateAllNotifications();
|
||||
|
||||
void DecryptionWorker.instance().scheduleStart();
|
||||
|
||||
RevisionService.instance().runInBackground();
|
||||
});
|
||||
}
|
||||
|
||||
RevisionService.instance().runInBackground();
|
||||
this.startRotatingLogMaintenance(Setting.value('profileDir'));
|
||||
});
|
||||
|
||||
|
||||
@@ -13,8 +13,7 @@ export const runtime = (): CommandRuntime => {
|
||||
return {
|
||||
execute: async () => {
|
||||
const appPath = app.getPath('exe');
|
||||
// Quote the path so it works when it contains spaces (e.g. "C:\Program Files\Joplin\Joplin.exe" on Windows)
|
||||
const cmd = `"${appPath}" --env dev`;
|
||||
const cmd = `${appPath} --env dev`;
|
||||
clipboard.writeText(cmd);
|
||||
await shim.showMessageBox(`The dev mode command has been copied to clipboard:\n\n${cmd}`, { type: MessageBoxType.Info });
|
||||
},
|
||||
|
||||
@@ -1,79 +0,0 @@
|
||||
import { CommandRuntime, CommandDeclaration } from '@joplin/lib/services/CommandService';
|
||||
import Resource from '@joplin/lib/models/Resource';
|
||||
import Setting from '@joplin/lib/models/Setting';
|
||||
import shim from '@joplin/lib/shim';
|
||||
import { _ } from '@joplin/lib/locale';
|
||||
import { ResourceOcrStatus } from '@joplin/lib/services/database/types';
|
||||
import bridge from '../services/bridge';
|
||||
import Logger from '@joplin/utils/Logger';
|
||||
|
||||
const logger = Logger.create('createAccessibleDocument');
|
||||
|
||||
export const declaration: CommandDeclaration = {
|
||||
name: 'createAccessibleDocument',
|
||||
};
|
||||
|
||||
export const runtime = (): CommandRuntime => {
|
||||
return {
|
||||
execute: async (_context: unknown, resourceId: string) => {
|
||||
const resource = await Resource.load(resourceId);
|
||||
if (!resource) {
|
||||
bridge().showErrorMessageBox(_('Resource not found'));
|
||||
return;
|
||||
}
|
||||
|
||||
const resourcePath = Resource.fullPath(resource);
|
||||
|
||||
if (resource.mime !== 'application/pdf') {
|
||||
bridge().showInfoMessageBox(_('This feature is only available for PDF files.'));
|
||||
return;
|
||||
}
|
||||
|
||||
if (resource.ocr_status !== ResourceOcrStatus.Done) {
|
||||
bridge().showInfoMessageBox(_('OCR is not complete. Please wait for OCR to finish before creating an accessible document.'));
|
||||
return;
|
||||
}
|
||||
|
||||
const ocrDetails = resource.ocr_details;
|
||||
|
||||
// If ocr_details is missing (legacy PDF processed before this feature),
|
||||
// automatically re-run OCR to get the coordinate data
|
||||
if (!ocrDetails) {
|
||||
const result = await bridge().showMessageBox(_('OCR needs to run to generate an accessible document. This may take a moment. Would you like to continue?'), {
|
||||
buttons: [_('Run OCR'), _('Cancel')],
|
||||
});
|
||||
|
||||
if (result === 1) return; // User cancelled
|
||||
|
||||
// Trigger OCR re-run with TodoAccessible status to request full OCR details
|
||||
await Resource.save({
|
||||
id: resource.id,
|
||||
ocr_status: ResourceOcrStatus.TodoAccessible,
|
||||
ocr_details: '',
|
||||
ocr_error: '',
|
||||
ocr_text: '',
|
||||
});
|
||||
|
||||
bridge().showInfoMessageBox(_('OCR has been queued. Please wait for it to complete and then try again.'));
|
||||
return;
|
||||
}
|
||||
|
||||
// Show save dialog
|
||||
const defaultFilename = `${(resource.filename || resource.title || resource.id).replace(/\.pdf$/i, '')}_accessible.pdf`;
|
||||
const outputPath = await bridge().showSaveDialog({
|
||||
defaultPath: defaultFilename,
|
||||
filters: [{ name: 'PDF', extensions: ['pdf'] }],
|
||||
});
|
||||
|
||||
if (!outputPath) return;
|
||||
|
||||
try {
|
||||
await shim.createAccessiblePdf(resourcePath, ocrDetails, outputPath, Setting.value('tempDir'));
|
||||
await bridge().openItem(outputPath);
|
||||
} catch (error) {
|
||||
logger.error(error);
|
||||
bridge().showErrorMessageBox(_('Failed to create accessible document: %s', error.message));
|
||||
}
|
||||
},
|
||||
};
|
||||
};
|
||||
@@ -1,7 +1,6 @@
|
||||
// AUTO-GENERATED using `gulp buildScriptIndexes`
|
||||
import * as copyDevCommand from './copyDevCommand';
|
||||
import * as copyToClipboard from './copyToClipboard';
|
||||
import * as createAccessibleDocument from './createAccessibleDocument';
|
||||
import * as editProfileConfig from './editProfileConfig';
|
||||
import * as emptyTrash from './emptyTrash';
|
||||
import * as exportDeletionLog from './exportDeletionLog';
|
||||
@@ -28,7 +27,6 @@ import * as toggleTabMovesFocus from './toggleTabMovesFocus';
|
||||
const index: any[] = [
|
||||
copyDevCommand,
|
||||
copyToClipboard,
|
||||
createAccessibleDocument,
|
||||
editProfileConfig,
|
||||
emptyTrash,
|
||||
exportDeletionLog,
|
||||
|
||||
@@ -4,38 +4,62 @@ describe('useContextMenu', () => {
|
||||
const resourceId = 'a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4';
|
||||
const resourceId2 = 'b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5';
|
||||
|
||||
it('should return type=image when cursor is inside markdown image', () => {
|
||||
it('should return resource ID when cursor is inside markdown image', () => {
|
||||
const line = ``;
|
||||
expect(getResourceIdFromMarkup(line, 15)).toEqual({ resourceId, type: 'image' });
|
||||
expect(getResourceIdFromMarkup(line, 0)).toBe(resourceId);
|
||||
expect(getResourceIdFromMarkup(line, 15)).toBe(resourceId);
|
||||
expect(getResourceIdFromMarkup(line, line.length - 1)).toBe(resourceId);
|
||||
});
|
||||
|
||||
it('should return type=file when cursor is inside markdown link', () => {
|
||||
const line = `[document.pdf](:/${resourceId})`;
|
||||
expect(getResourceIdFromMarkup(line, 15)).toEqual({ resourceId, type: 'file' });
|
||||
});
|
||||
|
||||
it('should return null when cursor is outside markup', () => {
|
||||
it('should return null when cursor is outside markdown image', () => {
|
||||
const line = `Some text  more text`;
|
||||
expect(getResourceIdFromMarkup(line, 5)).toBeNull();
|
||||
expect(getResourceIdFromMarkup(line, line.length - 5)).toBeNull();
|
||||
});
|
||||
|
||||
it('should correctly distinguish between image and file on same line', () => {
|
||||
const line = ` [file](:/${resourceId2})`;
|
||||
expect(getResourceIdFromMarkup(line, 10)).toEqual({ resourceId, type: 'image' });
|
||||
expect(getResourceIdFromMarkup(line, 48)).toEqual({ resourceId: resourceId2, type: 'file' });
|
||||
it('should handle markdown image without alt text', () => {
|
||||
const line = ``;
|
||||
expect(getResourceIdFromMarkup(line, 5)).toBe(resourceId);
|
||||
});
|
||||
|
||||
it('should return resource ID when cursor is inside HTML img tag', () => {
|
||||
const line = `<img src=":/${resourceId}" />`;
|
||||
expect(getResourceIdFromMarkup(line, 10)).toBe(resourceId);
|
||||
});
|
||||
|
||||
it('should handle HTML img tag with additional attributes', () => {
|
||||
const line = `<img alt="test" src=":/${resourceId}" width="100" />`;
|
||||
expect(getResourceIdFromMarkup(line, 25)).toBe(resourceId);
|
||||
});
|
||||
|
||||
it('should return null when cursor is outside HTML img tag', () => {
|
||||
const line = `text <img src=":/${resourceId}" /> more`;
|
||||
expect(getResourceIdFromMarkup(line, 2)).toBeNull();
|
||||
expect(getResourceIdFromMarkup(line, line.length - 2)).toBeNull();
|
||||
});
|
||||
|
||||
it('should return correct resource ID when multiple images on same line', () => {
|
||||
const line = ` `;
|
||||
expect(getResourceIdFromMarkup(line, 10)).toBe(resourceId);
|
||||
expect(getResourceIdFromMarkup(line, 50)).toBe(resourceId2);
|
||||
});
|
||||
|
||||
it('should return null for empty line', () => {
|
||||
expect(getResourceIdFromMarkup('', 0)).toBeNull();
|
||||
});
|
||||
|
||||
it('should return null for line without resources', () => {
|
||||
it('should return null for line without images', () => {
|
||||
expect(getResourceIdFromMarkup('Just some regular text', 10)).toBeNull();
|
||||
});
|
||||
|
||||
it('should return null for non-resource URLs', () => {
|
||||
expect(getResourceIdFromMarkup('', 10)).toBeNull();
|
||||
expect(getResourceIdFromMarkup('[link](https://example.com)', 10)).toBeNull();
|
||||
it('should return null for non-resource links', () => {
|
||||
const line = '';
|
||||
expect(getResourceIdFromMarkup(line, 10)).toBeNull();
|
||||
});
|
||||
|
||||
it('should handle cursor at exact boundaries of image markup', () => {
|
||||
const line = ``;
|
||||
expect(getResourceIdFromMarkup(line, 0)).toBe(resourceId);
|
||||
expect(getResourceIdFromMarkup(line, line.length)).toBe(resourceId);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -17,16 +17,9 @@ import isItemId from '@joplin/lib/models/utils/isItemId';
|
||||
import { extractResourceUrls } from '@joplin/lib/urlUtils';
|
||||
import { WindowIdContext } from '../../../../NewWindowOrIFrame';
|
||||
|
||||
export type ResourceMarkupType = 'image' | 'file';
|
||||
|
||||
export interface ResourceMarkupInfo {
|
||||
resourceId: string;
|
||||
type: ResourceMarkupType;
|
||||
}
|
||||
|
||||
// Extract resource ID from resource markup (images or file attachments) at a given cursor position within a line.
|
||||
// Returns the resource ID and its type if the cursor is within a resource markup, null otherwise.
|
||||
export const getResourceIdFromMarkup = (lineContent: string, cursorPosInLine: number): ResourceMarkupInfo | null => {
|
||||
// Extract resource ID from image markup at a given cursor position within a line.
|
||||
// Returns the resource ID if the cursor is within an image markup, null otherwise.
|
||||
export const getResourceIdFromMarkup = (lineContent: string, cursorPosInLine: number): string | null => {
|
||||
const resourceUrls = extractResourceUrls(lineContent);
|
||||
if (!resourceUrls.length) return null;
|
||||
|
||||
@@ -34,38 +27,16 @@ export const getResourceIdFromMarkup = (lineContent: string, cursorPosInLine: nu
|
||||
const resourcePattern = new RegExp(`[:](/?${resourceInfo.itemId})`, 'g');
|
||||
let match;
|
||||
while ((match = resourcePattern.exec(lineContent)) !== null) {
|
||||
// Look backwards for ![, [, <img, or <a
|
||||
const imageMarkupStart = lineContent.lastIndexOf('![', match.index);
|
||||
const linkMarkupStart = lineContent.lastIndexOf('[', match.index);
|
||||
// Look backwards for ![ or <img
|
||||
let markupStart = lineContent.lastIndexOf('![', match.index);
|
||||
const imgTagStart = lineContent.lastIndexOf('<img', match.index);
|
||||
const aTagStart = lineContent.lastIndexOf('<a', match.index);
|
||||
|
||||
// Find the closest markup start and determine type
|
||||
let markupStart = -1;
|
||||
let markupType: ResourceMarkupType = 'file';
|
||||
|
||||
if (imageMarkupStart !== -1 && imageMarkupStart > markupStart) {
|
||||
markupStart = imageMarkupStart;
|
||||
markupType = 'image';
|
||||
}
|
||||
if (linkMarkupStart !== -1 && linkMarkupStart > markupStart && lineContent[linkMarkupStart - 1] !== '!') {
|
||||
markupStart = linkMarkupStart;
|
||||
markupType = 'file';
|
||||
}
|
||||
if (imgTagStart !== -1 && imgTagStart > markupStart) {
|
||||
markupStart = imgTagStart;
|
||||
markupType = 'image';
|
||||
}
|
||||
if (aTagStart !== -1 && aTagStart > markupStart) {
|
||||
markupStart = aTagStart;
|
||||
markupType = 'file';
|
||||
}
|
||||
if (imgTagStart > markupStart) markupStart = imgTagStart;
|
||||
|
||||
if (markupStart === -1) continue;
|
||||
|
||||
// Find the end of the markup
|
||||
let markupEnd: number;
|
||||
if (lineContent[markupStart] === '!' || lineContent[markupStart] === '[') {
|
||||
if (lineContent[markupStart] === '!') {
|
||||
markupEnd = lineContent.indexOf(')', match.index);
|
||||
if (markupEnd !== -1) markupEnd += 1;
|
||||
} else {
|
||||
@@ -74,7 +45,7 @@ export const getResourceIdFromMarkup = (lineContent: string, cursorPosInLine: nu
|
||||
}
|
||||
|
||||
if (markupEnd !== -1 && cursorPosInLine >= markupStart && cursorPosInLine <= markupEnd) {
|
||||
return { resourceId: resourceInfo.itemId, type: markupType };
|
||||
return resourceInfo.itemId;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -161,8 +132,8 @@ const useContextMenu = (props: ContextMenuProps) => {
|
||||
return clickedElement?.closest(`.${imageClassName}`) as HTMLElement | null;
|
||||
};
|
||||
|
||||
// Get resource info from markup at click position (not cursor position)
|
||||
const getResourceInfoAtClickPos = (params: ContextMenuParams): ResourceMarkupInfo | null => {
|
||||
// Get resource ID from image markup at click position (not cursor position)
|
||||
const getResourceIdAtClickPos = (params: ContextMenuParams): string | null => {
|
||||
if (!editorRef.current) return null;
|
||||
|
||||
const editor = editorRef.current.editor;
|
||||
@@ -181,10 +152,10 @@ const useContextMenu = (props: ContextMenuProps) => {
|
||||
|
||||
const targetWindow = bridge().windowById(windowId);
|
||||
|
||||
const showResourceContextMenu = async (resourceId: string, type: ResourceMarkupType) => {
|
||||
const showImageContextMenu = async (resourceId: string) => {
|
||||
const menu = new Menu();
|
||||
const contextMenuOptions: ContextMenuOptions = {
|
||||
itemType: type === 'image' ? ContextMenuItemType.Image : ContextMenuItemType.Resource,
|
||||
itemType: ContextMenuItemType.Image,
|
||||
resourceId,
|
||||
filename: null,
|
||||
mime: null,
|
||||
@@ -199,8 +170,8 @@ const useContextMenu = (props: ContextMenuProps) => {
|
||||
mdToHtml: null,
|
||||
};
|
||||
|
||||
const resourceMenuItems = await buildMenuItems(menuItems(props.dispatch), contextMenuOptions);
|
||||
for (const item of resourceMenuItems) {
|
||||
const imageMenuItems = await buildMenuItems(menuItems(props.dispatch), contextMenuOptions);
|
||||
for (const item of imageMenuItems) {
|
||||
menu.append(item);
|
||||
}
|
||||
|
||||
@@ -235,17 +206,17 @@ const useContextMenu = (props: ContextMenuProps) => {
|
||||
if (resourceId) {
|
||||
event.preventDefault();
|
||||
moveCursorToImageLine(imageContainer);
|
||||
await showResourceContextMenu(resourceId, 'image');
|
||||
await showImageContextMenu(resourceId);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check if right-clicking on resource markup text (images or file attachments)
|
||||
const markupResourceInfo = getResourceInfoAtClickPos(params);
|
||||
if (markupResourceInfo && pointerInsideEditor(params)) {
|
||||
// Check if right-clicking on image markup text
|
||||
const markupResourceId = getResourceIdAtClickPos(params);
|
||||
if (markupResourceId && pointerInsideEditor(params)) {
|
||||
event.preventDefault();
|
||||
await showResourceContextMenu(markupResourceInfo.resourceId, markupResourceInfo.type);
|
||||
await showImageContextMenu(markupResourceId);
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -646,7 +646,6 @@ function CodeMirror(props: NoteBodyEditorProps, ref: ForwardedRef<NoteBodyEditor
|
||||
useCustomPdfViewer: props.useCustomPdfViewer,
|
||||
noteId: props.noteId,
|
||||
vendorDir: bridge().vendorDir(),
|
||||
showNoteLinkIcon: props.showNoteLinkIcon,
|
||||
}));
|
||||
|
||||
if (cancelled) return;
|
||||
@@ -667,7 +666,7 @@ function CodeMirror(props: NoteBodyEditorProps, ref: ForwardedRef<NoteBodyEditor
|
||||
shim.clearTimeout(timeoutId);
|
||||
};
|
||||
// eslint-disable-next-line @seiyab/react-hooks/exhaustive-deps -- Old code before rule was applied
|
||||
}, [props.content, props.contentKey, renderedBodyContentKey, props.contentMarkupLanguage, props.visiblePanes, props.resourceInfos, props.markupToHtml, props.showNoteLinkIcon]);
|
||||
}, [props.content, props.contentKey, renderedBodyContentKey, props.contentMarkupLanguage, props.visiblePanes, props.resourceInfos, props.markupToHtml]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!webviewReady) return;
|
||||
|
||||
@@ -222,7 +222,6 @@ const CodeMirror = (props: NoteBodyEditorProps, ref: ForwardedRef<NoteBodyEditor
|
||||
noteId: props.noteId,
|
||||
vendorDir: bridge().vendorDir(),
|
||||
globalSettings: getGlobalSettings(Setting),
|
||||
showNoteLinkIcon: props.showNoteLinkIcon,
|
||||
}));
|
||||
|
||||
if (cancelled) return;
|
||||
@@ -245,7 +244,7 @@ const CodeMirror = (props: NoteBodyEditorProps, ref: ForwardedRef<NoteBodyEditor
|
||||
}, [
|
||||
props.content, props.contentKey, renderedBodyContentKey, props.contentMarkupLanguage,
|
||||
props.visiblePanes, props.resourceInfos, props.markupToHtml, props.contentMaxWidth,
|
||||
props.noteId, props.useCustomPdfViewer, props.showNoteLinkIcon,
|
||||
props.noteId, props.useCustomPdfViewer,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
|
||||
@@ -14,7 +14,7 @@ import useFormNote, { OnLoadEvent, OnSetFormNote } from './utils/useFormNote';
|
||||
import useEffectiveNoteId from './utils/useEffectiveNoteId';
|
||||
import useFolder from './utils/useFolder';
|
||||
import styles_ from './styles';
|
||||
import { NoteEditorProps, FormNote, OnChangeEvent, AllAssetsOptions, NoteBodyEditorRef, NoteBodyEditorPropsAndRef, NoteBodyEditorType } from './utils/types';
|
||||
import { NoteEditorProps, FormNote, OnChangeEvent, AllAssetsOptions, NoteBodyEditorRef, NoteBodyEditorPropsAndRef } from './utils/types';
|
||||
import CommandService from '@joplin/lib/services/CommandService';
|
||||
import Button, { ButtonLevel } from '../Button/Button';
|
||||
import eventManager, { EventName } from '@joplin/lib/eventManager';
|
||||
@@ -474,7 +474,6 @@ function NoteEditorContent(props: NoteEditorProps) {
|
||||
noteId: props.noteId,
|
||||
watchedNoteFiles: props.watchedNoteFiles,
|
||||
enableHtmlToMarkdownBanner: props.enableHtmlToMarkdownBanner,
|
||||
showNoteLinkIcon: props.showNoteLinkIcon,
|
||||
};
|
||||
|
||||
let editor = null;
|
||||
@@ -716,11 +715,11 @@ const mapStateToProps = (state: AppState, ownProps: ConnectProps) => {
|
||||
const windowState = stateUtils.windowStateById(state, ownProps.windowId);
|
||||
const noteId = stateUtils.selectedNoteId(windowState);
|
||||
|
||||
let bodyEditor = windowState.editorCodeView ? NoteBodyEditorType.CodeMirror6 : NoteBodyEditorType.TinyMce;
|
||||
let bodyEditor = windowState.editorCodeView ? 'CodeMirror6' : 'TinyMCE';
|
||||
if (state.settings.isSafeMode) {
|
||||
bodyEditor = NoteBodyEditorType.PlainText;
|
||||
bodyEditor = 'PlainText';
|
||||
} else if (windowState.editorCodeView && state.settings['editor.legacyMarkdown']) {
|
||||
bodyEditor = NoteBodyEditorType.CodeMirror5;
|
||||
bodyEditor = 'CodeMirror5';
|
||||
}
|
||||
|
||||
const mainWindowState = stateUtils.windowStateById(state, defaultWindowId);
|
||||
@@ -767,8 +766,6 @@ const mapStateToProps = (state: AppState, ownProps: ConnectProps) => {
|
||||
shareCacheSetting: state.settings['sync.shareCache'],
|
||||
searchResults: state.searchResults,
|
||||
enableHtmlToMarkdownBanner: state.settings['editor.enableHtmlToMarkdownBanner'],
|
||||
enableInEditorRendering: state.settings['editor.inlineRendering'],
|
||||
showNoteLinkIcon: state.settings['notes.showNoteLinkIcon'],
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
@@ -6,7 +6,6 @@ interface Props {
|
||||
acceptMessage: string;
|
||||
onAccept: ()=> void;
|
||||
onDismiss?: ()=> void;
|
||||
dismissMessage?: string;
|
||||
visible: boolean;
|
||||
}
|
||||
|
||||
@@ -18,7 +17,7 @@ const BannerContent: React.FC<Props> = props => {
|
||||
return <div className='warning-banner'>
|
||||
{props.children}
|
||||
<a onClick={props.onAccept} className='warning-banner-link' href="#">[ {props.acceptMessage} ]</a>
|
||||
{ props.onDismiss ? <a onClick={props.onDismiss} className='warning-banner-link' href="#">[ {props.dismissMessage ?? _('Dismiss')} ]</a> : null }
|
||||
{ props.onDismiss ? <a onClick={props.onDismiss} className='warning-banner-link' href="#">[ {_('Dismiss')} ]</a> : null }
|
||||
</div>;
|
||||
};
|
||||
|
||||
|
||||
@@ -6,17 +6,13 @@ import BannerContent from './BannerContent';
|
||||
import { _ } from '@joplin/lib/locale';
|
||||
import onRichTextReadMoreLinkClick from '@joplin/lib/components/shared/NoteEditor/WarningBanner/onRichTextReadMoreLinkClick';
|
||||
import onRichTextDismissLinkClick from '@joplin/lib/components/shared/NoteEditor/WarningBanner/onRichTextDismissLinkClick';
|
||||
import useEditorTypeMigrationBanner from '@joplin/lib/components/shared/NoteEditor/WarningBanner/useEditorTypeMigrationBanner';
|
||||
import { useMemo } from 'react';
|
||||
import { PluginStates } from '@joplin/lib/services/plugins/reducer';
|
||||
import PluginService from '@joplin/lib/services/plugins/PluginService';
|
||||
import { NoteBodyEditorType } from '../utils/types';
|
||||
|
||||
interface Props {
|
||||
bodyEditor: NoteBodyEditorType;
|
||||
editorMigrationVersion: number;
|
||||
bodyEditor: string;
|
||||
richTextBannerDismissed: boolean;
|
||||
inEditorRenderingEnabled: boolean;
|
||||
pluginCompatibilityBannerDismissedFor: string[];
|
||||
plugins: PluginStates;
|
||||
}
|
||||
@@ -39,22 +35,6 @@ const incompatiblePluginIds = [
|
||||
];
|
||||
|
||||
const WarningBanner: React.FC<Props> = props => {
|
||||
|
||||
const editorMigrationMessage = useEditorTypeMigrationBanner({
|
||||
markdownEditorEnabled: props.bodyEditor === 'CodeMirror6',
|
||||
editorMigrationVersion: props.editorMigrationVersion,
|
||||
inEditorRenderingEnabled: props.inEditorRenderingEnabled,
|
||||
});
|
||||
const editorMigrationBanner = (
|
||||
<BannerContent
|
||||
visible={editorMigrationMessage.enabled}
|
||||
acceptMessage={editorMigrationMessage.keepEnabled.label}
|
||||
onAccept={editorMigrationMessage.keepEnabled.onPress}
|
||||
onDismiss={editorMigrationMessage.disable.onPress}
|
||||
dismissMessage={editorMigrationMessage.disable.label}
|
||||
>{editorMigrationMessage.label}</BannerContent>
|
||||
);
|
||||
|
||||
const wysiwygBanner = (
|
||||
<BannerContent
|
||||
acceptMessage={_('Read more about it')}
|
||||
@@ -103,7 +83,6 @@ const WarningBanner: React.FC<Props> = props => {
|
||||
return <>
|
||||
{wysiwygBanner}
|
||||
{markdownPluginBanner}
|
||||
{editorMigrationBanner}
|
||||
</>;
|
||||
};
|
||||
|
||||
@@ -112,7 +91,5 @@ export default connect((state: AppState) => {
|
||||
richTextBannerDismissed: state.settings.richTextBannerDismissed,
|
||||
pluginCompatibilityBannerDismissedFor: state.settings['editor.pluginCompatibilityBannerDismissedFor'],
|
||||
plugins: state.pluginService.plugins,
|
||||
editorMigrationVersion: state.settings['editor.migration'],
|
||||
inEditorRenderingEnabled: state.settings['editor.inlineRendering'],
|
||||
};
|
||||
})(WarningBanner);
|
||||
|
||||
@@ -207,16 +207,6 @@ export function menuItems(dispatch: Function): ContextMenuItems {
|
||||
return itemType === ContextMenuItemType.Resource || (itemType === ContextMenuItemType.Image && options.resourceId);
|
||||
},
|
||||
},
|
||||
createAccessibleDocument: {
|
||||
label: _('Create accessible document'),
|
||||
onAction: async (options: ContextMenuOptions) => {
|
||||
const { resource } = await resourceInfo(options);
|
||||
await CommandService.instance().execute('createAccessibleDocument', resource.id);
|
||||
},
|
||||
isActive: (itemType: ContextMenuItemType, options: ContextMenuOptions) => {
|
||||
return itemType === ContextMenuItemType.Resource || (itemType === ContextMenuItemType.Image && options.resourceId);
|
||||
},
|
||||
},
|
||||
separator3: makeSeparator(),
|
||||
copyPathToClipboard: {
|
||||
label: _('Copy path to clipboard'),
|
||||
|
||||
@@ -26,13 +26,6 @@ export interface ToolbarButtonInfos {
|
||||
[key: string]: ToolbarButtonInfo;
|
||||
}
|
||||
|
||||
export enum NoteBodyEditorType {
|
||||
CodeMirror6 = 'CodeMirror6',
|
||||
CodeMirror5 = 'CodeMirror5',
|
||||
TinyMce = 'TinyMCE',
|
||||
PlainText = 'PlainText',
|
||||
}
|
||||
|
||||
export interface NoteEditorProps {
|
||||
noteId: string;
|
||||
themeId: number;
|
||||
@@ -72,10 +65,9 @@ export interface NoteEditorProps {
|
||||
searchResults: ProcessResultsRow[];
|
||||
pluginHtmlContents: PluginHtmlContents;
|
||||
onTitleChange?: (title: string)=> void;
|
||||
bodyEditor: NoteBodyEditorType;
|
||||
bodyEditor: string;
|
||||
startupPluginsLoaded: boolean;
|
||||
enableHtmlToMarkdownBanner: boolean;
|
||||
showNoteLinkIcon: boolean;
|
||||
}
|
||||
|
||||
export interface NoteBodyEditorRef {
|
||||
@@ -157,7 +149,6 @@ export interface NoteBodyEditorProps {
|
||||
useCustomPdfViewer: boolean;
|
||||
watchedNoteFiles: string[];
|
||||
enableHtmlToMarkdownBanner: boolean;
|
||||
showNoteLinkIcon: boolean;
|
||||
}
|
||||
|
||||
export interface NoteBodyEditorPropsAndRef extends NoteBodyEditorProps {
|
||||
|
||||
@@ -95,18 +95,12 @@ const useConnectToEditorPlugin = ({
|
||||
}, [activeEditorView, editorPluginHandler]);
|
||||
|
||||
const formNoteBody = formNote.body;
|
||||
const formNoteId = formNote.id;
|
||||
useEffect(() => {
|
||||
// Don't emit updates when formNote hasn't loaded the current note yet.
|
||||
// This can happen during note navigation when effectiveNoteId updates
|
||||
// immediately but formNote still contains the previous note's data.
|
||||
if (formNoteId !== effectiveNoteId) return;
|
||||
|
||||
editorPluginHandler.emitUpdate({
|
||||
noteId: effectiveNoteId,
|
||||
newBody: formNoteBody,
|
||||
}, shownEditorViewIds);
|
||||
}, [effectiveNoteId, formNoteId, formNoteBody, editorPluginHandler, shownEditorViewIds]);
|
||||
}, [effectiveNoteId, formNoteBody, editorPluginHandler, shownEditorViewIds]);
|
||||
};
|
||||
|
||||
export default useConnectToEditorPlugin;
|
||||
|
||||
@@ -35,7 +35,6 @@ interface Props {
|
||||
customCss: string;
|
||||
scrollbarSize: ScrollbarSize;
|
||||
fontFamily: string;
|
||||
showNoteLinkIcon: boolean;
|
||||
}
|
||||
|
||||
const useNoteContent = (
|
||||
@@ -46,7 +45,6 @@ const useNoteContent = (
|
||||
customCss: string,
|
||||
scrollbarSize: ScrollbarSize,
|
||||
fontFamily: string,
|
||||
showNoteLinkIcon: boolean,
|
||||
) => {
|
||||
const [note, setNote] = useState<NoteEntity>(null);
|
||||
|
||||
@@ -77,18 +75,17 @@ const useNoteContent = (
|
||||
resources: await shared.attachedResources(noteBody),
|
||||
whiteBackgroundNoteRendering: markupLanguage === MarkupLanguage.Html,
|
||||
globalSettings: getGlobalSettings(Setting),
|
||||
showNoteLinkIcon,
|
||||
});
|
||||
|
||||
viewerRef.current.setHtml(result.html, {
|
||||
pluginAssets: result.pluginAssets,
|
||||
});
|
||||
}, [note, viewerRef, markupToHtml, showNoteLinkIcon]);
|
||||
}, [note, viewerRef]);
|
||||
|
||||
return note;
|
||||
};
|
||||
|
||||
const NoteRevisionViewerComponent: React.FC<Props> = ({ themeId, noteId, onBack, customCss, scrollbarSize, fontFamily, showNoteLinkIcon }) => {
|
||||
const NoteRevisionViewerComponent: React.FC<Props> = ({ themeId, noteId, onBack, customCss, scrollbarSize, fontFamily }) => {
|
||||
const helpButton_onClick = useCallback(() => {}, []);
|
||||
const viewerRef = useRef<NoteViewerControl|null>(null);
|
||||
const revisionListRef = useRef<HTMLSelectElement|null>(null);
|
||||
@@ -99,7 +96,7 @@ const NoteRevisionViewerComponent: React.FC<Props> = ({ themeId, noteId, onBack,
|
||||
const [deleting, setDeleting] = useState(false);
|
||||
|
||||
const note = useNoteContent(
|
||||
viewerRef, currentRevId, revisions, themeId, customCss, scrollbarSize, fontFamily, showNoteLinkIcon,
|
||||
viewerRef, currentRevId, revisions, themeId, customCss, scrollbarSize, fontFamily,
|
||||
);
|
||||
|
||||
const viewer_domReady = useCallback(async () => {
|
||||
@@ -232,7 +229,6 @@ const mapStateToProps = (state: AppState) => {
|
||||
themeId: state.settings.theme,
|
||||
scrollbarSize: state.settings['style.scrollbarSize'],
|
||||
fontFamily: state.settings['style.viewer.fontFamily'],
|
||||
showNoteLinkIcon: state.settings['notes.showNoteLinkIcon'],
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
@@ -28,19 +28,12 @@ const useScrollToSelectionHandler = (
|
||||
return lastSelectedItemKey.current;
|
||||
}
|
||||
}, [listItems, selectedIndex]);
|
||||
lastSelectedItemKey.current = selectedItemKey;
|
||||
|
||||
const selectedIndexRef = useRef(selectedIndex);
|
||||
selectedIndexRef.current = selectedIndex;
|
||||
|
||||
useEffect(() => {
|
||||
// Skip scrolling if the selected item hasn't actually changed. When a folder is
|
||||
// expanded or collapsed the selected item's index may shift, but its key stays
|
||||
// the same — in that case we don't want to scroll the view.
|
||||
if (selectedItemKey === lastSelectedItemKey.current) {
|
||||
return;
|
||||
}
|
||||
lastSelectedItemKey.current = selectedItemKey;
|
||||
|
||||
if (!itemListRef.current || !selectedItemKey) return;
|
||||
|
||||
const hasFocus = !!itemListRef.current.container.contains(document.activeElement);
|
||||
|
||||
@@ -54,42 +54,12 @@ type ItemContextMenuListener = MouseEventHandler<HTMLElement>;
|
||||
|
||||
const menuUtils = new MenuUtils(CommandService.instance());
|
||||
|
||||
// Checks whether an element is at least partially visible within a scrollable
|
||||
// container by comparing their bounding rectangles.
|
||||
const isElementVisibleInContainer = (element: HTMLElement, container: HTMLElement) => {
|
||||
const elementRect = element.getBoundingClientRect();
|
||||
const containerRect = container.getBoundingClientRect();
|
||||
|
||||
return elementRect.bottom > containerRect.top && elementRect.top < containerRect.bottom;
|
||||
};
|
||||
|
||||
const focusListItem = (item: HTMLElement|null) => {
|
||||
if (item) {
|
||||
const activeElement = document.activeElement instanceof HTMLElement ? document.activeElement : null;
|
||||
const itemList = item.closest('.item-list');
|
||||
const activeTreeItem = activeElement?.closest('[role="treeitem"]');
|
||||
const focusWasLost = activeElement === document.body;
|
||||
|
||||
// If the currently focused element is a tree item inside the same list,
|
||||
// the user is navigating with the keyboard — always allow focus to move
|
||||
// to the newly selected item so arrow-key scrolling is not interrupted.
|
||||
const isKeyboardNavigating = !!activeTreeItem && itemList?.contains(activeTreeItem);
|
||||
|
||||
// Avoid disturbing scroll while user is manually scrolling through the list.
|
||||
// However, if focus was lost (activeElement -> <body>), or the user is
|
||||
// navigating with the keyboard, allow re-focusing even when the selected
|
||||
// item is currently out of view.
|
||||
if (itemList instanceof HTMLElement && !isElementVisibleInContainer(item, itemList) && !focusWasLost && !isKeyboardNavigating) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Move focus only if needed: either focus was lost, or selection changed
|
||||
// to a different tree item.
|
||||
if (focusWasLost || activeTreeItem !== item) {
|
||||
// preventScroll: true avoids a secondary scroll caused by the focus() call
|
||||
// itself when the item is near the edge of the visible area.
|
||||
focus('useOnRenderItem', item, { preventScroll: true });
|
||||
}
|
||||
// Avoid scrolling to the selected item when refocusing the note list. Such a refocus
|
||||
// can happen if the note list rerenders and the selection is scrolled out of view and
|
||||
// can cause scroll to change unexpectedly.
|
||||
focus('useOnRenderItem', item, { preventScroll: true });
|
||||
}
|
||||
};
|
||||
|
||||
@@ -393,12 +363,7 @@ const useOnRenderItem = (props: Props) => {
|
||||
multipleItemsSelected: props.selectedIndexes.length > 1,
|
||||
};
|
||||
|
||||
const sidebarContainsFocus = props.containerRef.current?.contains(document.activeElement);
|
||||
// Focus moves to <body> when the previously-focused element is removed
|
||||
// from the DOM (e.g. scrolled out of the virtualized list). We still
|
||||
// want to restore focus to the newly-selected item in that case.
|
||||
const focusLostFromDom = document.activeElement === document.body;
|
||||
const focusInList = document.hasFocus() && (sidebarContainsFocus || focusLostFromDom);
|
||||
const focusInList = document.hasFocus() && props.containerRef.current?.contains(document.activeElement);
|
||||
const anchorRef = (focusInList && primarySelected) ? focusListItem : noFocusListItem;
|
||||
|
||||
if (item.kind === ListItemType.Tag) {
|
||||
|
||||
@@ -35,10 +35,6 @@
|
||||
ul ul, ul ol, ol ul, ol ol {
|
||||
margin-bottom: 0px;
|
||||
}
|
||||
::highlight(search-results) {
|
||||
background-color: #f7d26e;
|
||||
color: black;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
|
||||
@@ -507,7 +503,7 @@
|
||||
if (!options) options = {};
|
||||
|
||||
// TODO: Add support for scriptType on mobile and CLI
|
||||
CSS.highlights.clear();
|
||||
|
||||
if (!mark_) {
|
||||
mark_ = new Mark(document.getElementById('joplin-container-content'), {
|
||||
exclude: ['img'],
|
||||
@@ -523,8 +519,7 @@
|
||||
|
||||
let selectedElement = null;
|
||||
let elementIndex = 0;
|
||||
|
||||
const allRanges = [];
|
||||
|
||||
const markKeywordOptions = {};
|
||||
|
||||
if ('separateWordSearch' in options) markKeywordOptions.separateWordSearch = options.separateWordSearch;
|
||||
@@ -532,17 +527,9 @@
|
||||
try {
|
||||
for (const keyword of keywords) {
|
||||
markJsUtils.markKeyword(mark_, keyword, {
|
||||
pregQuote: pregQuote,
|
||||
replaceRegexDiacritics: replaceRegexDiacritics,
|
||||
}, {
|
||||
...markKeywordOptions,
|
||||
element: 'mark-ghost',
|
||||
each: (node) => {
|
||||
const range = new Range();
|
||||
range.selectNodeContents(node);
|
||||
allRanges.push(range);
|
||||
}
|
||||
});
|
||||
pregQuote: pregQuote,
|
||||
replaceRegexDiacritics: replaceRegexDiacritics,
|
||||
}, markKeywordOptions);
|
||||
}
|
||||
} catch (error) {
|
||||
if (error.name !== 'SyntaxError') {
|
||||
@@ -553,10 +540,6 @@
|
||||
// https://github.com/laurent22/joplin/issues/7634
|
||||
console.error('Error while trying to highlight words from search: ', error);
|
||||
}
|
||||
if (allRanges.length > 0) {
|
||||
const searchResultsHighlight = new Highlight(...allRanges);
|
||||
CSS.highlights.set("search-results", searchResultsHighlight);
|
||||
}
|
||||
}
|
||||
|
||||
let markLoader_ = { state: 'idle', whenDone: null };
|
||||
@@ -826,37 +809,6 @@
|
||||
);
|
||||
}));
|
||||
|
||||
// By default, Chromium inlines body styles (e.g. theme background color) into the clipboard HTML.
|
||||
// Intercept the copy event and write only the selected content to bypass this behaviour.
|
||||
document.addEventListener('copy', webviewLib.logEnabledEventHandler(e => {
|
||||
const selection = window.getSelection();
|
||||
if (!selection || selection.isCollapsed) return;
|
||||
if (selection.rangeCount === 0 || !e.clipboardData) return;
|
||||
|
||||
const range = selection.getRangeAt(0);
|
||||
const wrapper = document.createElement('div');
|
||||
wrapper.appendChild(range.cloneContents());
|
||||
|
||||
wrapper.querySelectorAll('style').forEach(s => s.remove());
|
||||
|
||||
const inlineTags = new Set(['STRONG', 'EM', 'CODE', 'S', 'DEL', 'INS', 'MARK', 'SUP', 'SUB', 'U', 'SPAN', 'A']);
|
||||
let node = range.commonAncestorContainer;
|
||||
if (node.nodeType === Node.TEXT_NODE) node = node.parentElement;
|
||||
|
||||
while (node && node !== document.body && node.id !== 'rendered-md' && node.id !== 'joplin-container-content') {
|
||||
if (inlineTags.has(node.tagName)) {
|
||||
const el = node.cloneNode(false);
|
||||
while (wrapper.firstChild) el.appendChild(wrapper.firstChild);
|
||||
wrapper.appendChild(el);
|
||||
}
|
||||
node = node.parentElement;
|
||||
}
|
||||
|
||||
e.clipboardData.setData('text/html', wrapper.innerHTML);
|
||||
e.clipboardData.setData('text/plain', selection.toString());
|
||||
e.preventDefault();
|
||||
}));
|
||||
|
||||
let lastClientWidth_ = NaN, lastClientHeight_ = NaN, lastScrollTop_ = NaN;
|
||||
|
||||
window.addEventListener('resize', webviewLib.logEnabledEventHandler(() => {
|
||||
|
||||
@@ -161,7 +161,7 @@ test.describe('markdownEditor', () => {
|
||||
const viewer = noteEditor.getNoteViewerFrameLocator();
|
||||
await expect(viewer.locator('h1')).toHaveText('Testing');
|
||||
|
||||
const matches = viewer.locator('mark-ghost');
|
||||
const matches = viewer.locator('mark');
|
||||
await expect(matches).toHaveCount(0);
|
||||
|
||||
await mainWindow.keyboard.press(process.platform === 'darwin' ? 'Meta+f' : 'Control+f');
|
||||
@@ -276,12 +276,8 @@ test.describe('markdownEditor', () => {
|
||||
expect(imageSize[1]).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
test('ctrl-clicking on note links should open the linked note (when the viewer is hidden)', async ({ mainWindow, electronApp }) => {
|
||||
const mainScreen = await new MainScreen(mainWindow);
|
||||
// Workaround: Required for extracting content accurately from the Markdown editor
|
||||
await mainScreen.noteEditor.disableInlineRendering(electronApp);
|
||||
|
||||
await mainScreen.setup();
|
||||
test('ctrl-clicking on note links should open the linked note (when the viewer is hidden)', async ({ mainWindow }) => {
|
||||
const mainScreen = await new MainScreen(mainWindow).setup();
|
||||
await mainScreen.createNewNote('Original');
|
||||
const noteEditor = mainScreen.noteEditor;
|
||||
await noteEditor.hideViewer();
|
||||
@@ -404,35 +400,5 @@ test.describe('markdownEditor', () => {
|
||||
await activateMainMenuItem(electronApp, 'Redo');
|
||||
await noteEditor.expectToHaveText('A');
|
||||
});
|
||||
|
||||
test('copying from the preview pane should not include theme background color and should preserve bold formatting', async ({ mainWindow, electronApp }) => {
|
||||
// Set dark theme so background-color would be present in clipboard without the fix
|
||||
await setSettingValue(electronApp, mainWindow, 'theme', 2);
|
||||
|
||||
const mainScreen = await new MainScreen(mainWindow).setup();
|
||||
await mainScreen.waitFor();
|
||||
|
||||
await mainScreen.createNewNote('Test copy formatting');
|
||||
const noteEditor = mainScreen.noteEditor;
|
||||
await noteEditor.focusCodeMirrorEditor();
|
||||
await mainWindow.keyboard.type('**hello**');
|
||||
|
||||
const viewerFrame = noteEditor.getNoteViewerFrameLocator();
|
||||
await expect(viewerFrame.locator('strong')).toHaveText('hello');
|
||||
|
||||
// Double-click selects the text node inside <strong>, not <strong> itself.
|
||||
// Without the ancestor re-wrapping fix, <strong> would be dropped.
|
||||
await viewerFrame.locator('strong').dblclick();
|
||||
const modifier = process.platform === 'darwin' ? 'Meta' : 'Control';
|
||||
await mainWindow.keyboard.press(`${modifier}+c`);
|
||||
|
||||
const clipboardHtml = await mainWindow.evaluate(() => {
|
||||
const { clipboard } = require('electron');
|
||||
return clipboard.readHTML();
|
||||
});
|
||||
|
||||
expect(clipboardHtml).toContain('hello');
|
||||
expect(clipboardHtml).not.toMatch(/background-color\s*:/i);
|
||||
expect(clipboardHtml).toContain('<strong>');
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -2,7 +2,6 @@ import { ElectronApplication, Locator, Page } from '@playwright/test';
|
||||
import { expect } from '../util/test';
|
||||
import activateMainMenuItem from '../util/activateMainMenuItem';
|
||||
import EditorCodeDialog from './EditorCodeDialog';
|
||||
import setSettingValue from '../util/setSettingValue';
|
||||
|
||||
export default class NoteEditorPage {
|
||||
public readonly codeMirrorEditor: Locator;
|
||||
@@ -25,8 +24,8 @@ export default class NoteEditorPage {
|
||||
|
||||
private readonly containerLocator: Locator;
|
||||
|
||||
public constructor(private page_: Page) {
|
||||
this.containerLocator = page_.locator('.rli-editor');
|
||||
public constructor(page: Page) {
|
||||
this.containerLocator = page.locator('.rli-editor');
|
||||
this.codeMirrorEditor = this.containerLocator.locator('.cm-editor');
|
||||
this.richTextEditor = this.containerLocator.locator('iframe[title="Rich Text Area"]');
|
||||
this.editorPluginFrame = this.containerLocator.locator('iframe[id^="plugin-view-"]');
|
||||
@@ -42,7 +41,7 @@ export default class NoteEditorPage {
|
||||
this.disableTabNavigationButton = this.containerLocator.getByRole('button', { name: 'Tab moves focus' });
|
||||
this.toggleEditorPluginButton = this.containerLocator.getByRole('button', { name: 'Toggle editor plugin' });
|
||||
|
||||
this.richTextCodeEditor = new EditorCodeDialog(page_);
|
||||
this.richTextCodeEditor = new EditorCodeDialog(page);
|
||||
}
|
||||
|
||||
public toolbarButtonLocator(title: string) {
|
||||
@@ -66,10 +65,6 @@ export default class NoteEditorPage {
|
||||
}
|
||||
}
|
||||
|
||||
public async disableInlineRendering(electronApp: ElectronApplication) {
|
||||
await setSettingValue(electronApp, this.page_, 'editor.inlineRendering', false);
|
||||
}
|
||||
|
||||
public async expectToHaveText(expected: string|RegExp) {
|
||||
// expect(...).toHaveText can fail in the Rich Text Editor (perhaps due to frame locators).
|
||||
// Using expect.poll refreshes the locator on each attempt, which seems to prevent flakiness.
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
import { test, expect } from './util/test';
|
||||
import MainScreen from './models/MainScreen';
|
||||
import { msleep, Second } from '@joplin/utils/time';
|
||||
import setSettingValue from './util/setSettingValue';
|
||||
|
||||
test.describe('pluginApi', () => {
|
||||
test('the editor.setText command should update the current note (use RTE: false)', async ({ startAppWithPlugins }) => {
|
||||
@@ -81,30 +80,6 @@ test.describe('pluginApi', () => {
|
||||
await expectVisible(false);
|
||||
});
|
||||
|
||||
// Regression tests for #13718
|
||||
for (const method of ['Cancel button', 'Escape key'] as const) {
|
||||
test(`should dismiss a plugin dialog via ${method} with isolated iframes`, async ({ startAppWithPlugins }) => {
|
||||
const { app, mainWindow } = await startAppWithPlugins(['resources/test-plugins/dialogs.js']);
|
||||
const mainScreen = await new MainScreen(mainWindow).setup();
|
||||
await mainScreen.createNewNote('Test note');
|
||||
|
||||
// WebView isolation is currently behind a feature flag:
|
||||
await setSettingValue(app, mainWindow, 'featureFlag.plugins.isolatePluginWebViews', true);
|
||||
|
||||
await mainScreen.goToAnything.runCommand(app, 'showTestDialogWithDismiss');
|
||||
const dialogContent = mainScreen.dialog.locator('iframe').contentFrame();
|
||||
await dialogContent.locator('p').waitFor();
|
||||
|
||||
if (method === 'Cancel button') {
|
||||
await mainScreen.dialog.getByRole('button', { name: 'Cancel' }).click();
|
||||
} else {
|
||||
await mainWindow.keyboard.press('Escape');
|
||||
}
|
||||
|
||||
await expect(mainScreen.dialog).toBeHidden();
|
||||
});
|
||||
}
|
||||
|
||||
test('should be possible to create multiple toasts with the same text from a plugin', async ({ startAppWithPlugins }) => {
|
||||
const { app, mainWindow } = await startAppWithPlugins(['resources/test-plugins/showToast.js']);
|
||||
const mainScreen = await new MainScreen(mainWindow).setup();
|
||||
|
||||
@@ -48,21 +48,6 @@ joplin.plugins.register({
|
||||
},
|
||||
});
|
||||
|
||||
const dismissDialogHandle = await dialogs.create('test-dialog-with-dismiss');
|
||||
await dialogs.setHtml(dismissDialogHandle, '<p>Press Escape to dismiss</p>');
|
||||
await dialogs.setButtons(dismissDialogHandle, [
|
||||
{ id: 'ok', title: 'Okay' },
|
||||
{ id: 'cancel', title: 'Cancel' },
|
||||
]);
|
||||
await joplin.commands.register({
|
||||
name: 'showTestDialogWithDismiss',
|
||||
label: 'showTestDialogWithDismiss',
|
||||
execute: async () => {
|
||||
const result = await joplin.views.dialogs.open(dismissDialogHandle);
|
||||
await joplin.commands.execute('editor.setText', result.id);
|
||||
},
|
||||
});
|
||||
|
||||
await joplin.commands.register({
|
||||
name: 'getTestDialogVisibility',
|
||||
label: 'Returns the dialog visibility state',
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@joplin/app-desktop",
|
||||
"version": "3.6.3",
|
||||
"version": "3.6.2",
|
||||
"description": "Joplin for Desktop",
|
||||
"main": "main.bundle.js",
|
||||
"private": true,
|
||||
@@ -140,7 +140,7 @@
|
||||
"homepage": "https://github.com/laurent22/joplin#readme",
|
||||
"devDependencies": {
|
||||
"7zip-bin": "5.2.0",
|
||||
"@axe-core/playwright": "4.11.0",
|
||||
"@axe-core/playwright": "4.10.2",
|
||||
"@electron/notarize": "2.5.0",
|
||||
"@electron/rebuild": "3.7.2",
|
||||
"@fortawesome/fontawesome-free": "5.15.4",
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import * as React from 'react';
|
||||
import { useRef, useCallback, useMemo } from 'react';
|
||||
import { useRef, useCallback } from 'react';
|
||||
import { ButtonSpec, DialogResult } from '@joplin/lib/services/plugins/api/types';
|
||||
import PluginService from '@joplin/lib/services/plugins/PluginService';
|
||||
import WebviewController from '@joplin/lib/services/plugins/WebviewController';
|
||||
@@ -76,16 +76,8 @@ export default function UserWebviewDialog(props: Props) {
|
||||
if (webviewRef.current) focus('UserWebviewDialog', webviewRef.current);
|
||||
}, []);
|
||||
|
||||
// When the iframe is isolated (security setting enabled), keyboard events
|
||||
// like Escape don't reach the iframe content. We let the native <dialog>
|
||||
// handle Escape by passing onCancel, but only when there's a dismiss button.
|
||||
// https://github.com/laurent22/joplin/issues/13718
|
||||
const onCancel = useMemo(() => {
|
||||
return findDismissButton(buttons) ? onDismiss : undefined;
|
||||
}, [buttons, onDismiss]);
|
||||
|
||||
return (
|
||||
<Dialog className={`user-webview-dialog ${props.fitToContent ? '-fit' : ''}`} onCancel={onCancel}>
|
||||
<Dialog className={`user-webview-dialog ${props.fitToContent ? '-fit' : ''}`}>
|
||||
<div className='user-dialog-wrapper'>
|
||||
<UserWebview
|
||||
ref={webviewRef}
|
||||
|
||||
@@ -83,8 +83,8 @@ android {
|
||||
applicationId "net.cozic.joplin"
|
||||
minSdkVersion rootProject.ext.minSdkVersion
|
||||
targetSdkVersion rootProject.ext.targetSdkVersion
|
||||
versionCode 2097801
|
||||
versionName "3.6.13"
|
||||
versionCode 2097800
|
||||
versionName "3.6.12"
|
||||
|
||||
buildConfigField "String", "REACT_NATIVE_RELEASE_LEVEL", "\"${findProperty('reactNativeReleaseLevel') ?: 'stable'}\""
|
||||
|
||||
|
||||
@@ -57,7 +57,6 @@ const useStyles = (themeId: number, containerStyle: ViewStyle, size: DialogVaria
|
||||
},
|
||||
closeButton: {
|
||||
margin: 0,
|
||||
marginRight: -8,
|
||||
},
|
||||
// Ensure that the close button is aligned with the center of the header:
|
||||
// Make its container smaller and center it.
|
||||
@@ -92,8 +91,8 @@ const useStyles = (themeId: number, containerStyle: ViewStyle, size: DialogVaria
|
||||
dialogSurface: {
|
||||
borderRadius: 24,
|
||||
backgroundColor: theme.backgroundColor,
|
||||
paddingHorizontal: theme.margin,
|
||||
paddingVertical: theme.margin,
|
||||
paddingHorizontal: 16,
|
||||
paddingVertical: 24,
|
||||
...dialogSizing,
|
||||
},
|
||||
});
|
||||
|
||||
134
packages/app-mobile/components/FeedbackBanner.test.tsx
Normal file
134
packages/app-mobile/components/FeedbackBanner.test.tsx
Normal file
@@ -0,0 +1,134 @@
|
||||
import * as React from 'react';
|
||||
import { Store } from 'redux';
|
||||
import { AppState } from '../utils/types';
|
||||
import TestProviderStack from './testing/TestProviderStack';
|
||||
import { switchClient, setupDatabase, mockMobilePlatform, mockFetch } from '@joplin/lib/testing/test-utils';
|
||||
import waitFor from '@joplin/lib/testing/waitFor';
|
||||
import createMockReduxStore from '../utils/testing/createMockReduxStore';
|
||||
import setupGlobalStore from '../utils/testing/setupGlobalStore';
|
||||
import { act, fireEvent, render, screen } from '@testing-library/react-native';
|
||||
import FeedbackBanner from './FeedbackBanner';
|
||||
import { MobilePlatform } from '@joplin/lib/shim';
|
||||
|
||||
interface WrapperProps { }
|
||||
|
||||
let store: Store<AppState>;
|
||||
const WrappedFeedbackBanner: React.FC<WrapperProps> = () => {
|
||||
return <TestProviderStack store={store}>
|
||||
<FeedbackBanner/>
|
||||
</TestProviderStack>;
|
||||
};
|
||||
|
||||
const getFeedbackButton = (positive: boolean) => {
|
||||
return screen.getByRole('button', { name: positive ? 'Useful' : 'Not useful' });
|
||||
};
|
||||
|
||||
const getSurveyLink = () => {
|
||||
return screen.getByRole('button', { name: 'Take survey' });
|
||||
};
|
||||
|
||||
const mockFeedbackServer = (surveyName = 'web-app-test') => {
|
||||
let helpfulCount = 0;
|
||||
let unhelpfulCount = 0;
|
||||
|
||||
const { reset } = mockFetch((request) => {
|
||||
const surveyBaseUrls = [
|
||||
'https://objects.joplinusercontent.com/',
|
||||
'http://localhost:3430/',
|
||||
];
|
||||
const isSurveyRequest = surveyBaseUrls.some(url => request.url.startsWith(url));
|
||||
if (!isSurveyRequest) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const url = new URL(request.url);
|
||||
|
||||
if (url.pathname === `/r/survey--${surveyName}--helpful`) {
|
||||
helpfulCount ++;
|
||||
} else if (url.pathname === `/r/survey--${surveyName}--unhelpful`) {
|
||||
unhelpfulCount ++;
|
||||
} else {
|
||||
return new Response('Not found', { status: 404 });
|
||||
}
|
||||
|
||||
// The feedback server always redirects to another URL after a
|
||||
// successful request. Mock this by always redirecting to the
|
||||
// same URL.
|
||||
return new Response('', {
|
||||
// See https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Status/302
|
||||
status: 302,
|
||||
statusText: 'Found',
|
||||
headers: [
|
||||
['location', 'https://joplinapp.org'],
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
return {
|
||||
reset,
|
||||
get helpfulCount() {
|
||||
return helpfulCount;
|
||||
},
|
||||
get unhelpfulCount() {
|
||||
return unhelpfulCount;
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
describe('FeedbackBanner', () => {
|
||||
const resetMobilePlatform = ()=>{};
|
||||
|
||||
beforeEach(async () => {
|
||||
await setupDatabase(0);
|
||||
await switchClient(0);
|
||||
|
||||
store = createMockReduxStore();
|
||||
setupGlobalStore(store);
|
||||
|
||||
jest.useFakeTimers({ advanceTimers: true });
|
||||
mockMobilePlatform(MobilePlatform.Web);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
screen.unmount();
|
||||
resetMobilePlatform();
|
||||
});
|
||||
|
||||
test.each([
|
||||
{ platform: MobilePlatform.Android, shouldShow: false },
|
||||
{ platform: MobilePlatform.Web, shouldShow: true },
|
||||
{ platform: MobilePlatform.Ios, shouldShow: false },
|
||||
])('should correctly show/hide the feedback banner on %s', ({ platform, shouldShow }) => {
|
||||
mockMobilePlatform(platform);
|
||||
|
||||
render(<WrappedFeedbackBanner />);
|
||||
|
||||
const header = screen.queryByRole('header', { name: 'Feedback' });
|
||||
if (shouldShow) {
|
||||
expect(header).toBeVisible();
|
||||
} else {
|
||||
expect(header).toBeNull();
|
||||
}
|
||||
});
|
||||
|
||||
test('clicking the "Useful" button should submit the response and show the "take survey" link', async () => {
|
||||
const feedbackServerMock = mockFeedbackServer();
|
||||
render(<WrappedFeedbackBanner />);
|
||||
|
||||
try {
|
||||
const usefulButton = getFeedbackButton(true);
|
||||
fireEvent.press(usefulButton);
|
||||
|
||||
await act(() => waitFor(async () => {
|
||||
expect(getSurveyLink()).toBeVisible();
|
||||
}));
|
||||
|
||||
expect(feedbackServerMock).toMatchObject({
|
||||
helpfulCount: 1,
|
||||
unhelpfulCount: 0,
|
||||
});
|
||||
} finally {
|
||||
feedbackServerMock.reset();
|
||||
}
|
||||
});
|
||||
});
|
||||
216
packages/app-mobile/components/FeedbackBanner.tsx
Normal file
216
packages/app-mobile/components/FeedbackBanner.tsx
Normal file
@@ -0,0 +1,216 @@
|
||||
import { _ } from '@joplin/lib/locale';
|
||||
import * as React from 'react';
|
||||
import { View, StyleSheet, useWindowDimensions, TextStyle, Linking } from 'react-native';
|
||||
import { Portal, Text } from 'react-native-paper';
|
||||
import IconButton from './IconButton';
|
||||
import { useCallback, useMemo } from 'react';
|
||||
import shim from '@joplin/lib/shim';
|
||||
import { Dispatch } from 'redux';
|
||||
import { themeStyle } from './global-style';
|
||||
import { AppState } from '../utils/types';
|
||||
import { connect } from 'react-redux';
|
||||
import Setting from '@joplin/lib/models/Setting';
|
||||
import { LinkButton } from './buttons';
|
||||
import Logger from '@joplin/utils/Logger';
|
||||
import { SurveyProgress } from '@joplin/lib/models/settings/builtInMetadata';
|
||||
|
||||
const logger = Logger.create('FeedbackBanner');
|
||||
|
||||
interface Props {
|
||||
dispatch: Dispatch;
|
||||
progress: SurveyProgress;
|
||||
surveyKey: string;
|
||||
themeId: number;
|
||||
}
|
||||
|
||||
const useStyles = (themeId: number, sentFeedback: boolean) => {
|
||||
const { width: windowWidth } = useWindowDimensions();
|
||||
return useMemo(() => {
|
||||
const theme = themeStyle(themeId);
|
||||
|
||||
const iconBaseStyle: TextStyle = {
|
||||
fontSize: 24,
|
||||
color: theme.color3,
|
||||
};
|
||||
|
||||
return StyleSheet.create({
|
||||
container: {
|
||||
backgroundColor: theme.backgroundColor3,
|
||||
borderTopRightRadius: 16,
|
||||
display: 'flex',
|
||||
flexGrow: 1,
|
||||
flexWrap: 'wrap',
|
||||
flexDirection: 'row',
|
||||
position: 'absolute',
|
||||
bottom: 0,
|
||||
left: 0,
|
||||
maxWidth: windowWidth - 50,
|
||||
gap: 18,
|
||||
padding: 12,
|
||||
},
|
||||
contentRight: {
|
||||
display: sentFeedback ? 'none' : 'flex',
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: 16,
|
||||
},
|
||||
header: {
|
||||
fontWeight: 'bold',
|
||||
},
|
||||
iconUseful: {
|
||||
...iconBaseStyle,
|
||||
color: theme.colorCorrect,
|
||||
},
|
||||
iconNotUseful: {
|
||||
...iconBaseStyle,
|
||||
color: theme.colorWarn,
|
||||
},
|
||||
dismissButtonIcon: {
|
||||
fontSize: 16,
|
||||
color: theme.color2,
|
||||
marginLeft: 'auto',
|
||||
marginRight: 'auto',
|
||||
},
|
||||
dismissButton: {
|
||||
backgroundColor: theme.backgroundColor2,
|
||||
borderColor: theme.backgroundColor,
|
||||
borderWidth: 2,
|
||||
width: 29,
|
||||
height: 29,
|
||||
borderRadius: 14,
|
||||
position: 'absolute',
|
||||
top: -16,
|
||||
right: -16,
|
||||
justifyContent: 'center',
|
||||
},
|
||||
dismissButtonContent: {
|
||||
flexShrink: 1,
|
||||
},
|
||||
});
|
||||
}, [themeId, windowWidth, sentFeedback]);
|
||||
};
|
||||
|
||||
const useSurveyUrl = (surveyKey: string) => {
|
||||
return useMemo(() => {
|
||||
let baseUrl = 'https://objects.joplinusercontent.com/';
|
||||
|
||||
// For testing with a locally-hosted server:
|
||||
const useLocalServer = false;
|
||||
if (Setting.value('env') === 'dev' && useLocalServer) {
|
||||
baseUrl = 'http://localhost:3430/';
|
||||
}
|
||||
|
||||
return `${baseUrl}r/survey--${encodeURIComponent(surveyKey)}`;
|
||||
}, [surveyKey]);
|
||||
};
|
||||
|
||||
const setProgress = (progress: SurveyProgress) => {
|
||||
Setting.setValue('survey.webClientEval2025.progress', progress);
|
||||
};
|
||||
|
||||
const onDismiss = () => {
|
||||
setProgress(SurveyProgress.Dismissed);
|
||||
};
|
||||
|
||||
const FeedbackBanner: React.FC<Props> = props => {
|
||||
const surveyUrl = useSurveyUrl(props.surveyKey);
|
||||
const sentFeedback = props.progress === SurveyProgress.Started;
|
||||
|
||||
const sendSurveyResponse = useCallback(async (surveyResponse: string) => {
|
||||
const fetchUrl = `${surveyUrl}--${encodeURIComponent(surveyResponse)}`;
|
||||
logger.debug('sending response to', fetchUrl);
|
||||
const showError = (message: string) => {
|
||||
logger.error('Error', message);
|
||||
void shim.showErrorDialog(
|
||||
_('An error occurred while sending the response. This can happen if the app is offline or cannot connect to the server.\nError: %s', message),
|
||||
);
|
||||
};
|
||||
|
||||
try {
|
||||
const response = await shim.fetch(fetchUrl);
|
||||
// The server currently redirects (status 302) in response
|
||||
// to many survey-related requests. This may be returned by
|
||||
// the web app service worker as a 200 OK response, however. Support both:
|
||||
if (response.ok || response.status === 302) {
|
||||
setProgress(SurveyProgress.Started);
|
||||
} else {
|
||||
const body = await response.text();
|
||||
showError(`Server error: ${response.status} ${body}`);
|
||||
}
|
||||
} catch (error) {
|
||||
showError(error);
|
||||
}
|
||||
}, [surveyUrl]);
|
||||
|
||||
const onSurveyLinkClick = useCallback(() => {
|
||||
void Linking.openURL(surveyUrl);
|
||||
onDismiss();
|
||||
}, [surveyUrl]);
|
||||
|
||||
const onNotUsefulClick = useCallback(() => {
|
||||
void sendSurveyResponse('unhelpful');
|
||||
}, [sendSurveyResponse]);
|
||||
|
||||
const onUsefulClick = useCallback(() => {
|
||||
void sendSurveyResponse('helpful');
|
||||
}, [sendSurveyResponse]);
|
||||
|
||||
const styles = useStyles(props.themeId, sentFeedback);
|
||||
|
||||
const renderStatusMessage = () => {
|
||||
if (sentFeedback) {
|
||||
return <View>
|
||||
<Text>{_('Thank you for the feedback!\nDo you have time to complete a short survey?')}</Text>
|
||||
<LinkButton onPress={onSurveyLinkClick}>{_('Take survey')}</LinkButton>
|
||||
</View>;
|
||||
} else {
|
||||
return <Text>{_('Do you find the Joplin web app useful?')}</Text>;
|
||||
}
|
||||
};
|
||||
|
||||
if (shim.mobilePlatform() !== 'web' || props.progress === SurveyProgress.Dismissed) return null;
|
||||
|
||||
return <Portal>
|
||||
<View style={styles.container} role='complementary'>
|
||||
<View>
|
||||
<Text
|
||||
accessibilityRole='header'
|
||||
variant='titleMedium'
|
||||
style={styles.header}
|
||||
>{_('Feedback')}</Text>
|
||||
<Text>{renderStatusMessage()}</Text>
|
||||
</View>
|
||||
<View style={styles.contentRight}>
|
||||
<IconButton
|
||||
iconName='fas times'
|
||||
themeId={props.themeId}
|
||||
onPress={onNotUsefulClick}
|
||||
description={_('Not useful')}
|
||||
iconStyle={styles.iconNotUseful}
|
||||
/>
|
||||
<IconButton
|
||||
iconName='fas check'
|
||||
themeId={props.themeId}
|
||||
onPress={onUsefulClick}
|
||||
description={_('Useful')}
|
||||
iconStyle={styles.iconUseful}
|
||||
/>
|
||||
</View>
|
||||
<IconButton
|
||||
iconName='fas times'
|
||||
themeId={props.themeId}
|
||||
onPress={onDismiss}
|
||||
description={_('Dismiss')}
|
||||
iconStyle={styles.dismissButtonIcon}
|
||||
contentWrapperStyle={styles.dismissButtonContent}
|
||||
containerStyle={styles.dismissButton}
|
||||
/>
|
||||
</View>
|
||||
</Portal>;
|
||||
};
|
||||
|
||||
export default connect((state: AppState) => ({
|
||||
themeId: state.settings.theme,
|
||||
surveyKey: 'web-app-test',
|
||||
progress: state.settings['survey.webClientEval2025.progress'],
|
||||
}))(FeedbackBanner);
|
||||
@@ -34,7 +34,6 @@ interface Props {
|
||||
onScroll: OnScrollCallback;
|
||||
onLoadEnd?: ()=> void;
|
||||
pluginStates: PluginStates;
|
||||
showNoteLinkIcon: boolean;
|
||||
}
|
||||
|
||||
const onJoplinLinkClick = async (message: string) => {
|
||||
@@ -85,7 +84,6 @@ function NoteBodyViewer(props: Props) {
|
||||
initialScrollPercent: props.initialScrollPercent,
|
||||
|
||||
paddingBottom: props.paddingBottom,
|
||||
showNoteLinkIcon: props.showNoteLinkIcon,
|
||||
});
|
||||
|
||||
const onLoadEnd = useCallback(() => {
|
||||
@@ -117,5 +115,4 @@ export default connect((state: AppState) => ({
|
||||
themeId: state.settings.theme,
|
||||
fontSize: state.settings['style.viewer.fontSize'],
|
||||
pluginStates: state.pluginService.plugins,
|
||||
showNoteLinkIcon: state.settings['notes.showNoteLinkIcon'],
|
||||
}))(NoteBodyViewer);
|
||||
|
||||
@@ -27,7 +27,6 @@ interface Props {
|
||||
initialScrollPercent: number|undefined;
|
||||
|
||||
paddingBottom: number;
|
||||
showNoteLinkIcon: boolean;
|
||||
}
|
||||
|
||||
const onlyCheckboxHasChangedHack = (previousBody: string, newBody: string) => {
|
||||
@@ -101,7 +100,7 @@ const useRerenderHandler = (props: Props) => {
|
||||
const effectDependencies = [
|
||||
props.noteBody, props.noteMarkupLanguage, props.renderer, props.highlightedKeywords,
|
||||
props.noteHash, props.noteResources, props.themeId, props.paddingBottom, resourceDownloadRerenderCounter,
|
||||
props.fontSize, props.showNoteLinkIcon,
|
||||
props.fontSize,
|
||||
];
|
||||
const previousDeps = usePrevious(effectDependencies, []);
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
|
||||
@@ -139,7 +138,6 @@ const useRerenderHandler = (props: Props) => {
|
||||
// instead.
|
||||
initialScrollPercent: (previousHash && hashChanged) ? undefined : props.initialScrollPercent,
|
||||
noteHash: props.noteHash,
|
||||
showNoteLinkIcon: props.showNoteLinkIcon,
|
||||
};
|
||||
|
||||
try {
|
||||
|
||||
@@ -16,7 +16,7 @@ import setupGlobalStore from '../../utils/testing/setupGlobalStore';
|
||||
import { Store } from 'redux';
|
||||
import { AppState } from '../../utils/types';
|
||||
import { MarkupLanguage } from '@joplin/renderer';
|
||||
import { EditorControl, EditorType } from './types';
|
||||
import { EditorType } from './types';
|
||||
|
||||
let store: Store<AppState>;
|
||||
let registeredRuntime: RegisteredRuntime;
|
||||
@@ -65,27 +65,6 @@ describe('NoteEditor', () => {
|
||||
registeredRuntime.deregister();
|
||||
});
|
||||
|
||||
it('should provide an editor ref', () => {
|
||||
let editorRef: EditorControl;
|
||||
const onSetEditorRef = (ref: EditorControl) => {
|
||||
editorRef = ref;
|
||||
};
|
||||
|
||||
const wrappedNoteEditor = render(
|
||||
<TestProviderStack store={store}>
|
||||
<NoteEditor
|
||||
ref={onSetEditorRef}
|
||||
{...defaultEditorProps}
|
||||
mode={EditorType.RichText}
|
||||
/>
|
||||
</TestProviderStack>,
|
||||
);
|
||||
|
||||
expect(editorRef).toBeTruthy();
|
||||
|
||||
wrappedNoteEditor.unmount();
|
||||
});
|
||||
|
||||
it('should hide the markdown toolbar when the window is small', async () => {
|
||||
const wrappedNoteEditor = render(
|
||||
<TestProviderStack store={store}>
|
||||
|
||||
@@ -34,8 +34,6 @@ import { MarkupLanguage } from '@joplin/renderer';
|
||||
import WarningBanner from './WarningBanner';
|
||||
import useIsScreenReaderEnabled from '../../utils/hooks/useIsScreenReaderEnabled';
|
||||
import Logger from '@joplin/utils/Logger';
|
||||
import { AppState } from '../../utils/types';
|
||||
import { connect } from 'react-redux';
|
||||
import { Second } from '@joplin/utils/time';
|
||||
import useDebounced from '../../utils/hooks/useDebounced';
|
||||
|
||||
@@ -64,8 +62,6 @@ interface Props {
|
||||
readOnly: boolean;
|
||||
plugins: PluginStates;
|
||||
noteResources: ResourceInfos;
|
||||
editorImageRendering: boolean;
|
||||
editorInlineRendering: boolean;
|
||||
|
||||
onScroll: OnScroll;
|
||||
onChange: OnChange;
|
||||
@@ -73,7 +69,6 @@ interface Props {
|
||||
onSelectionChange: OnSelectionChange;
|
||||
onUndoRedoDepthChange: OnUndoRedoDepthChange;
|
||||
onAttach: OnAttach;
|
||||
refreshKey?: number;
|
||||
}
|
||||
|
||||
function fontFamilyFromSettings() {
|
||||
@@ -261,48 +256,12 @@ const useEditorControl = (
|
||||
}, [webviewRef, editorRef, setLinkDialogVisible, setSearchState]);
|
||||
};
|
||||
|
||||
const useEditorSettings = (props: Props) => {
|
||||
const useHighlightActiveLine = () => {
|
||||
const screenReaderEnabled = useIsScreenReaderEnabled();
|
||||
// Guess whether highlighting the active line can be enabled without triggering
|
||||
// https://github.com/codemirror/dev/issues/1559.
|
||||
const canHighlight = Platform.OS !== 'ios' || !screenReaderEnabled;
|
||||
const highlightActiveLine = canHighlight && Setting.value('editor.highlightActiveLine');
|
||||
|
||||
// Also disable inline rendering. As of January 2026, inline rendering
|
||||
// seems to cause screen readers to behave strangely (e.g. sometimes not announce full
|
||||
// line content, reading "image" when not in an image, etc.)
|
||||
// However, `screenReaderEnabled` is always `true` on web (likely due to the lack of an API
|
||||
// to reliably detect whether the user is using a screen reader), so also allow inline rendering
|
||||
// to be enabled on web:
|
||||
const inlineRenderingEnabled = props.editorInlineRendering && (!screenReaderEnabled || Platform.OS === 'web');
|
||||
|
||||
const editorSettings: EditorSettings = useMemo(() => ({
|
||||
themeData: editorTheme(props.themeId),
|
||||
markdownMarkEnabled: Setting.value('markdown.plugin.mark'),
|
||||
katexEnabled: Setting.value('markdown.plugin.katex'),
|
||||
spellcheckEnabled: Setting.value('editor.mobile.spellcheckEnabled'),
|
||||
inlineRenderingEnabled,
|
||||
imageRenderingEnabled: props.editorImageRendering,
|
||||
language: props.markupLanguage === MarkupLanguage.Html ? EditorLanguageType.Html : EditorLanguageType.Markdown,
|
||||
useExternalSearch: true,
|
||||
readOnly: props.readOnly,
|
||||
highlightActiveLine,
|
||||
|
||||
keymap: EditorKeymap.Default,
|
||||
preferMacShortcuts: shim.mobilePlatform() === 'ios',
|
||||
|
||||
automatchBraces: false,
|
||||
ignoreModifiers: false,
|
||||
autocompleteMarkup: Setting.value('editor.autocompleteMarkup'),
|
||||
|
||||
// For now, mobile CodeMirror uses its built-in focus toggle shortcut.
|
||||
tabMovesFocus: false,
|
||||
indentWithTabs: true,
|
||||
|
||||
editorLabel: _('Markdown editor'),
|
||||
}), [props.themeId, props.readOnly, props.markupLanguage, highlightActiveLine, inlineRenderingEnabled, props.editorImageRendering]);
|
||||
|
||||
return editorSettings;
|
||||
return canHighlight && Setting.value('editor.highlightActiveLine');
|
||||
};
|
||||
|
||||
const useHasSpaceForToolbar = () => {
|
||||
@@ -321,7 +280,32 @@ const useHasSpaceForToolbar = () => {
|
||||
function NoteEditor(props: Props) {
|
||||
const webviewRef = useRef<WebViewControl>(null);
|
||||
|
||||
const editorSettings = useEditorSettings(props);
|
||||
const highlightActiveLine = useHighlightActiveLine();
|
||||
const editorSettings: EditorSettings = useMemo(() => ({
|
||||
themeData: editorTheme(props.themeId),
|
||||
markdownMarkEnabled: Setting.value('markdown.plugin.mark'),
|
||||
katexEnabled: Setting.value('markdown.plugin.katex'),
|
||||
spellcheckEnabled: Setting.value('editor.mobile.spellcheckEnabled'),
|
||||
inlineRenderingEnabled: Setting.value('editor.inlineRendering'),
|
||||
imageRenderingEnabled: Setting.value('editor.imageRendering'),
|
||||
language: props.markupLanguage === MarkupLanguage.Html ? EditorLanguageType.Html : EditorLanguageType.Markdown,
|
||||
useExternalSearch: true,
|
||||
readOnly: props.readOnly,
|
||||
highlightActiveLine,
|
||||
|
||||
keymap: EditorKeymap.Default,
|
||||
preferMacShortcuts: shim.mobilePlatform() === 'ios',
|
||||
|
||||
automatchBraces: false,
|
||||
ignoreModifiers: false,
|
||||
autocompleteMarkup: Setting.value('editor.autocompleteMarkup'),
|
||||
|
||||
// For now, mobile CodeMirror uses its built-in focus toggle shortcut.
|
||||
tabMovesFocus: false,
|
||||
indentWithTabs: true,
|
||||
|
||||
editorLabel: _('Markdown editor'),
|
||||
}), [props.themeId, props.readOnly, props.markupLanguage, highlightActiveLine]);
|
||||
|
||||
const [selectionState, setSelectionState] = useState<SelectionFormatting>(defaultSelectionFormatting);
|
||||
const [linkDialogVisible, setLinkDialogVisible] = useState(false);
|
||||
@@ -472,7 +456,6 @@ function NoteEditor(props: Props) {
|
||||
minHeight: '30%',
|
||||
}}>
|
||||
<EditorComponent
|
||||
key={props.refreshKey}
|
||||
editorRef={editorRef}
|
||||
webviewRef={webviewRef}
|
||||
themeId={props.themeId}
|
||||
@@ -490,11 +473,7 @@ function NoteEditor(props: Props) {
|
||||
/>
|
||||
</View>
|
||||
|
||||
<WarningBanner
|
||||
editorType={props.mode}
|
||||
markupLanguage={props.markupLanguage}
|
||||
inEditorRendering={editorSettings.inlineRenderingEnabled}
|
||||
/>
|
||||
<WarningBanner editorType={props.mode}/>
|
||||
|
||||
<SearchPanel
|
||||
editorSettings={editorSettings}
|
||||
@@ -507,10 +486,4 @@ function NoteEditor(props: Props) {
|
||||
);
|
||||
}
|
||||
|
||||
export default connect((state: AppState) => {
|
||||
return {
|
||||
themeId: state.settings.theme,
|
||||
editorInlineRendering: state.settings['editor.inlineRendering'],
|
||||
editorImageRendering: state.settings['editor.imageRendering'],
|
||||
};
|
||||
}, null, null, { forwardRef: true })(NoteEditor);
|
||||
export default NoteEditor;
|
||||
|
||||
@@ -7,76 +7,35 @@ import { AppState } from '../../utils/types';
|
||||
import { EditorType } from './types';
|
||||
import { Banner } from 'react-native-paper';
|
||||
import { useMemo } from 'react';
|
||||
import useEditorTypeMigrationBanner from '@joplin/lib/components/shared/NoteEditor/WarningBanner/useEditorTypeMigrationBanner';
|
||||
import { MarkupLanguage } from '@joplin/renderer/types';
|
||||
|
||||
interface Props {
|
||||
editorType: EditorType;
|
||||
richTextBannerDismissed: boolean;
|
||||
editorMigrationVersion: number;
|
||||
inEditorRendering: boolean;
|
||||
|
||||
markupLanguage: MarkupLanguage;
|
||||
}
|
||||
|
||||
const useBanner = ({
|
||||
editorType,
|
||||
richTextBannerDismissed,
|
||||
editorMigrationVersion,
|
||||
inEditorRendering,
|
||||
}: Props) => {
|
||||
const editorMigrationBanner = useEditorTypeMigrationBanner({
|
||||
markdownEditorEnabled: editorType === EditorType.Markdown,
|
||||
editorMigrationVersion: editorMigrationVersion,
|
||||
inEditorRenderingEnabled: inEditorRendering,
|
||||
});
|
||||
|
||||
return useMemo(() => {
|
||||
if (editorType === EditorType.RichText && !richTextBannerDismissed) {
|
||||
return {
|
||||
label: _('This Rich Text editor has a number of limitations and it is recommended to be aware of them before using it.'),
|
||||
warning: true,
|
||||
actions: [
|
||||
{
|
||||
label: _('Read more'),
|
||||
onPress: onRichTextReadMoreLinkClick,
|
||||
},
|
||||
{
|
||||
label: _('Dismiss'),
|
||||
accessibilityHint: _('Hides warning'),
|
||||
onPress: onRichTextDismissLinkClick,
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
if (editorMigrationBanner.enabled) {
|
||||
return {
|
||||
label: editorMigrationBanner.label,
|
||||
actions: [
|
||||
editorMigrationBanner.keepEnabled,
|
||||
editorMigrationBanner.disable,
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
}, [editorType, richTextBannerDismissed, editorMigrationBanner]);
|
||||
};
|
||||
|
||||
const WarningBanner: React.FC<Props> = props => {
|
||||
const banner = useBanner(props);
|
||||
const actions = useMemo(() => [
|
||||
{
|
||||
label: _('Read more'),
|
||||
onPress: onRichTextReadMoreLinkClick,
|
||||
},
|
||||
{
|
||||
label: _('Dismiss'),
|
||||
accessibilityHint: _('Hides warning'),
|
||||
onPress: onRichTextDismissLinkClick,
|
||||
},
|
||||
], []);
|
||||
|
||||
if (!banner) return null;
|
||||
if (props.editorType !== EditorType.RichText || props.richTextBannerDismissed) return null;
|
||||
return (
|
||||
<Banner
|
||||
icon={banner.warning ? 'alert-outline' : 'information-outline'}
|
||||
actions={banner.actions}
|
||||
icon='alert-outline'
|
||||
actions={actions}
|
||||
// Avoid hiding with react-native-paper's "visible" prop to avoid potential accessibility issues
|
||||
// related to how react-native-paper hides the banner.
|
||||
visible={true}
|
||||
>
|
||||
{banner.label}
|
||||
{_('This Rich Text editor has a number of limitations and it is recommended to be aware of them before using it.')}
|
||||
</Banner>
|
||||
);
|
||||
};
|
||||
@@ -84,6 +43,5 @@ const WarningBanner: React.FC<Props> = props => {
|
||||
export default connect((state: AppState) => {
|
||||
return {
|
||||
richTextBannerDismissed: state.settings.richTextBannerDismissed,
|
||||
editorMigrationVersion: state.settings['editor.migration'],
|
||||
};
|
||||
})(WarningBanner);
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
import * as React from 'react';
|
||||
import { Linking, StyleSheet, TextStyle, View, ViewStyle } from 'react-native';
|
||||
import { Text } from 'react-native-paper';
|
||||
import IconButton from '../IconButton';
|
||||
import { _ } from '@joplin/lib/locale';
|
||||
import { useCallback, useState } from 'react';
|
||||
import DismissibleDialog, { DialogVariant } from '../DismissibleDialog';
|
||||
import { LinkButton, PrimaryButton } from '../buttons';
|
||||
import makeDiscourseDebugUrl from '@joplin/lib/makeDiscourseDebugUrl';
|
||||
import getPackageInfo from '../../utils/getPackageInfo';
|
||||
import PluginService from '@joplin/lib/services/plugins/PluginService';
|
||||
import Setting from '@joplin/lib/models/Setting';
|
||||
|
||||
interface Props {
|
||||
wrapperStyle: ViewStyle;
|
||||
iconStyle: TextStyle;
|
||||
themeId: number;
|
||||
}
|
||||
|
||||
const onLeaveFeedback = () => {
|
||||
void Linking.openURL('https://forms.gle/B5YGDNzsUYBnoPx19');
|
||||
};
|
||||
|
||||
const onReportBug = () => {
|
||||
void Linking.openURL(
|
||||
makeDiscourseDebugUrl('', '', [], getPackageInfo(), PluginService.instance(), Setting.value('plugins.states')),
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
feedbackContainer: {
|
||||
flexGrow: 1,
|
||||
flexDirection: 'row',
|
||||
gap: 16,
|
||||
justifyContent: 'flex-end',
|
||||
flexWrap: 'wrap',
|
||||
},
|
||||
paragraph: {
|
||||
paddingBottom: 7,
|
||||
},
|
||||
});
|
||||
|
||||
const WebBetaButton: React.FC<Props> = props => {
|
||||
const [dialogVisible, setDialogVisible] = useState(false);
|
||||
|
||||
const onShowDialog = useCallback(() => {
|
||||
setDialogVisible(true);
|
||||
}, []);
|
||||
|
||||
const onHideDialog = useCallback(() => {
|
||||
setDialogVisible(false);
|
||||
}, []);
|
||||
|
||||
const renderParagraph = (content: string) => {
|
||||
return <Text variant='bodyLarge' style={styles.paragraph}>{content}</Text>;
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<IconButton
|
||||
onPress={onShowDialog}
|
||||
description={_('Beta')}
|
||||
themeId={props.themeId}
|
||||
contentWrapperStyle={props.wrapperStyle}
|
||||
|
||||
iconName="material beta"
|
||||
iconStyle={props.iconStyle}
|
||||
/>
|
||||
<DismissibleDialog
|
||||
heading={_('Beta')}
|
||||
size={DialogVariant.SmallResize}
|
||||
themeId={props.themeId}
|
||||
visible={dialogVisible}
|
||||
onDismiss={onHideDialog}
|
||||
>
|
||||
{renderParagraph('Welcome to the beta version of the Joplin Web App!')}
|
||||
{renderParagraph('Thank you for participating in the beta version of the Joplin Web App.')}
|
||||
{renderParagraph('The Joplin Web App is available for a limited time in open beta and may later join the Joplin Cloud plans.')}
|
||||
{renderParagraph('Feel free to use it and let us know if have any questions or notice any issues!')}
|
||||
<View style={styles.feedbackContainer}>
|
||||
<LinkButton onPress={onReportBug}>{'Report bug'}</LinkButton>
|
||||
<PrimaryButton onPress={onLeaveFeedback}>{'Give feedback'}</PrimaryButton>
|
||||
</View>
|
||||
</DismissibleDialog>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default WebBetaButton;
|
||||
@@ -1,7 +1,7 @@
|
||||
import * as React from 'react';
|
||||
import { PureComponent, ReactElement } from 'react';
|
||||
import { connect } from 'react-redux';
|
||||
import { View, Text, StyleSheet, TouchableOpacity, ViewStyle } from 'react-native';
|
||||
import { View, Text, StyleSheet, TouchableOpacity, ViewStyle, Platform } from 'react-native';
|
||||
import BackButtonService from '../../services/BackButtonService';
|
||||
import NavService from '@joplin/lib/services/NavService';
|
||||
import { _, _n } from '@joplin/lib/locale';
|
||||
@@ -20,6 +20,7 @@ import { PluginStates } from '@joplin/lib/services/plugins/reducer';
|
||||
import { ContainerType } from '@joplin/lib/services/plugins/WebviewController';
|
||||
import { Dispatch } from 'redux';
|
||||
import WarningBanner from './WarningBanner';
|
||||
import WebBetaButton from './WebBetaButton';
|
||||
|
||||
import Menu, { MenuOptionType } from './Menu';
|
||||
import shim from '@joplin/lib/shim';
|
||||
@@ -70,9 +71,6 @@ interface ScreenHeaderProps {
|
||||
showContextMenuButton?: boolean;
|
||||
showPluginEditorButton?: boolean;
|
||||
showBackButton?: boolean;
|
||||
showViewToggleButton?: boolean;
|
||||
onViewTogglePress?: OnPressCallback;
|
||||
viewToggleIconName?: string;
|
||||
|
||||
saveButtonDisabled?: boolean;
|
||||
showSaveButton?: boolean;
|
||||
@@ -170,7 +168,7 @@ class ScreenHeaderComponent extends PureComponent<ScreenHeaderProps, ScreenHeade
|
||||
},
|
||||
contextMenuTrigger: {
|
||||
fontSize: 30,
|
||||
paddingLeft: 5,
|
||||
paddingLeft: 10,
|
||||
paddingRight: theme.marginRight,
|
||||
color: theme.color2,
|
||||
fontWeight: 'bold',
|
||||
@@ -376,16 +374,6 @@ class ScreenHeaderComponent extends PureComponent<ScreenHeaderProps, ScreenHeade
|
||||
});
|
||||
};
|
||||
|
||||
const renderViewToggleButton = () => {
|
||||
if (!this.props.showViewToggleButton || !this.props.onViewTogglePress || !this.props.viewToggleIconName) return null;
|
||||
return renderTopButton({
|
||||
iconName: this.props.viewToggleIconName,
|
||||
description: _('Toggle view/edit'),
|
||||
onPress: this.props.onViewTogglePress,
|
||||
visible: true,
|
||||
});
|
||||
};
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
|
||||
function selectAllButton(styles: any, onPress: OnPressCallback) {
|
||||
return (
|
||||
@@ -455,6 +443,18 @@ class ScreenHeaderComponent extends PureComponent<ScreenHeaderProps, ScreenHeade
|
||||
);
|
||||
};
|
||||
|
||||
const betaIconButton = () => {
|
||||
if (Platform.OS !== 'web') return null;
|
||||
|
||||
return (
|
||||
<WebBetaButton
|
||||
themeId={themeId}
|
||||
wrapperStyle={this.styles().iconButton}
|
||||
iconStyle={this.styles().topIcon}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
|
||||
const renderTogglePluginEditorButton = (styles: any, onPress: OnPressCallback, disabled: boolean) => {
|
||||
if (!this.props.showPluginEditorButton) return null;
|
||||
@@ -653,6 +653,7 @@ class ScreenHeaderComponent extends PureComponent<ScreenHeaderProps, ScreenHeade
|
||||
const sideMenuComp = !showSideMenuButton ? null : sideMenuButton(this.styles(), () => this.sideMenuButton_press());
|
||||
const backButtonComp = !showBackButton ? null : backButton(this.styles(), () => this.backButton_press(), backButtonDisabled);
|
||||
const pluginPanelsComp = pluginPanelToggleButton(this.styles(), () => this.pluginPanelToggleButton_press());
|
||||
const betaIconComp = betaIconButton();
|
||||
const selectAllButtonComp = !showSelectAllButton ? null : selectAllButton(this.styles(), () => this.selectAllButton_press());
|
||||
const searchButtonComp = !showSearchButton ? null : searchButton(this.styles(), () => this.searchButton_press());
|
||||
const customDeleteButtonComp = this.props.onDeleteButtonPress ? customDeleteButton(this.styles(), this.props.onDeleteButtonPress) : null;
|
||||
@@ -666,12 +667,12 @@ class ScreenHeaderComponent extends PureComponent<ScreenHeaderProps, ScreenHeade
|
||||
// space while in use, we allow certain buttons to be hidden.
|
||||
const hideableRightComponents = <>
|
||||
{pluginPanelsComp}
|
||||
{betaIconComp}
|
||||
{togglePluginEditorButton}
|
||||
{selectAllButtonComp}
|
||||
{searchButtonComp}
|
||||
{deleteButtonComp}
|
||||
{customDeleteButtonComp}
|
||||
{renderViewToggleButton()}
|
||||
</>;
|
||||
|
||||
const titleComp = createTitleComponent(hideableRightComponents);
|
||||
|
||||
@@ -8,7 +8,7 @@ import { Icon, Text } from 'react-native-paper';
|
||||
import { _ } from '@joplin/lib/locale';
|
||||
import JoplinCloudIcon from './JoplinCloudIcon';
|
||||
import NavService from '@joplin/lib/services/NavService';
|
||||
import { Platform, StyleSheet, View } from 'react-native';
|
||||
import { StyleSheet, View } from 'react-native';
|
||||
import CardButton from '../buttons/CardButton';
|
||||
import Setting from '@joplin/lib/models/Setting';
|
||||
|
||||
@@ -47,14 +47,6 @@ const styles = StyleSheet.create({
|
||||
},
|
||||
});
|
||||
|
||||
const useShouldShowOtherButton = () => {
|
||||
// Always show "other" on non-web platforms
|
||||
if (Platform.OS !== 'web') return true;
|
||||
// Don't show "other" when hosted on Joplin Cloud (other sync
|
||||
// targets can still be selected from settings).
|
||||
return location.origin !== 'https://app.joplincloud.com';
|
||||
};
|
||||
|
||||
interface SyncProviderProps {
|
||||
title: string;
|
||||
icon: ()=> React.ReactNode;
|
||||
@@ -110,8 +102,6 @@ const SyncWizard: React.FC<Props> = ({ themeId, visible, dispatch }) => {
|
||||
await NavService.go('Config', { sectionName: 'sync' });
|
||||
}, [onDismiss]);
|
||||
|
||||
const showOther = useShouldShowOtherButton();
|
||||
|
||||
return <DismissibleDialog
|
||||
themeId={themeId}
|
||||
visible={visible}
|
||||
@@ -136,14 +126,14 @@ const SyncWizard: React.FC<Props> = ({ themeId, visible, dispatch }) => {
|
||||
onPress={onSelectJoplinCloud}
|
||||
disabled={false}
|
||||
/>
|
||||
{showOther && <SyncProvider
|
||||
<SyncProvider
|
||||
title={_('Other')}
|
||||
description={_('Select one of the other supported sync targets.')}
|
||||
icon={() => <Icon size={iconSize} source='dots-horizontal-circle'/>}
|
||||
featuresList={[]}
|
||||
onPress={onSelectOtherTarget}
|
||||
disabled={false}
|
||||
/>}
|
||||
/>
|
||||
</View>
|
||||
</DismissibleDialog>;
|
||||
};
|
||||
|
||||
@@ -8,6 +8,7 @@ import { themeStyle } from './global-style';
|
||||
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||
import useKeyboardState from '../utils/hooks/useKeyboardState';
|
||||
import usePrevious from '@joplin/lib/hooks/usePrevious';
|
||||
import FeedbackBanner from './FeedbackBanner';
|
||||
import { Theme } from '@joplin/lib/themes/type';
|
||||
import { useMemo } from 'react';
|
||||
import KeyboardAvoidingView from './KeyboardAvoidingView';
|
||||
@@ -77,6 +78,7 @@ const AppNavComponent: React.FC<Props> = (props) => {
|
||||
<NotesScreen visible={notesScreenVisible} />
|
||||
{searchScreenLoaded && <SearchScreen visible={searchScreenVisible} />}
|
||||
{!notesScreenVisible && !searchScreenVisible && <Screen navigation={{ state: route }} themeId={props.themeId} dispatch={props.dispatch} />}
|
||||
{notesScreenVisible ? <FeedbackBanner/> : null}
|
||||
<View style={{ height: autocompletionBarPadding }} />
|
||||
</KeyboardAvoidingView>
|
||||
);
|
||||
|
||||
@@ -39,6 +39,8 @@ const styles = (() => {
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: 20,
|
||||
marginLeft: 10,
|
||||
marginRight: 10,
|
||||
};
|
||||
return StyleSheet.create({
|
||||
descriptionText: {
|
||||
@@ -62,7 +64,7 @@ const styles = (() => {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
paddingVertical: 10,
|
||||
padding: 10,
|
||||
marginTop: 12,
|
||||
marginBottom: 14,
|
||||
},
|
||||
@@ -121,7 +123,7 @@ const PluginInfoModalContent: React.FC<Props> = props => {
|
||||
});
|
||||
|
||||
const aboutPlugin = (
|
||||
<Card mode='outlined' style={{ marginVertical: 8 }} testID='plugin-card'>
|
||||
<Card mode='outlined' style={{ margin: 8 }} testID='plugin-card'>
|
||||
<Card.Content>
|
||||
<PluginTitle manifest={manifest}/>
|
||||
<Text variant='bodyMedium'>{_('by %s', manifest.author)}</Text>
|
||||
|
||||
@@ -126,17 +126,20 @@ const openNoteActionsMenu = async () => {
|
||||
};
|
||||
|
||||
const expectToBeEditing = async (editing: boolean) => {
|
||||
if (editing) {
|
||||
await getMarkdownEditorControl();
|
||||
} else {
|
||||
await getNoteViewerDom();
|
||||
}
|
||||
await waitFor(() => {
|
||||
const editButton = screen.queryByLabelText('Edit');
|
||||
if (editing) {
|
||||
expect(editButton).toBeNull();
|
||||
} else {
|
||||
expect(editButton).not.toBeNull();
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const openEditor = async () => {
|
||||
const editToggle = await screen.findByLabelText('Toggle view/edit');
|
||||
const editButton = await screen.findByLabelText('Edit');
|
||||
|
||||
fireEvent.press(editToggle);
|
||||
fireEvent.press(editButton);
|
||||
await expectToBeEditing(true);
|
||||
};
|
||||
|
||||
@@ -146,18 +149,6 @@ const runEditorCommand = async (commandName: string) => {
|
||||
});
|
||||
};
|
||||
|
||||
const setupNoteWithPanes = async (panes: string[], noteTitle = 'Test note') => {
|
||||
store.dispatch({
|
||||
type: 'NOTE_VISIBLE_PANES_SET',
|
||||
panes: panes,
|
||||
});
|
||||
await openNewNote({ title: noteTitle, body: 'Test body' });
|
||||
const renderResult = render(<WrappedNoteScreen />);
|
||||
const titleInput = await screen.findByDisplayValue(noteTitle);
|
||||
expect(titleInput).toBeVisible();
|
||||
return renderResult;
|
||||
};
|
||||
|
||||
describe('screens/Note', () => {
|
||||
beforeEach(async () => {
|
||||
await setupDatabaseAndSynchronizer(1);
|
||||
@@ -373,127 +364,4 @@ describe('screens/Note', () => {
|
||||
|
||||
unmount();
|
||||
});
|
||||
|
||||
it.each([
|
||||
[['viewer']],
|
||||
[['editor']],
|
||||
])('should initialize in the correct mode when noteVisiblePanes is %j', async (panes) => {
|
||||
await setupNoteWithPanes(panes);
|
||||
await expectToBeEditing(panes.includes('editor'));
|
||||
});
|
||||
|
||||
it('should show toggle button', async () => {
|
||||
await setupNoteWithPanes(['viewer']);
|
||||
const toggleButton = await screen.findByLabelText('Toggle view/edit');
|
||||
expect(toggleButton).toBeVisible();
|
||||
});
|
||||
|
||||
it.each([
|
||||
[['viewer']],
|
||||
[['editor']],
|
||||
])('should switch modes when toggle button is pressed', async (panes) => {
|
||||
const initialEditing = panes.includes('editor');
|
||||
const expectedEditing = !initialEditing;
|
||||
await setupNoteWithPanes(panes);
|
||||
await expectToBeEditing(initialEditing);
|
||||
const toggleButton = await screen.findByLabelText('Toggle view/edit');
|
||||
fireEvent.press(toggleButton);
|
||||
await expectToBeEditing(expectedEditing);
|
||||
});
|
||||
|
||||
it('should always start in edit mode for provisional notes regardless of noteVisiblePanes', async () => {
|
||||
store.dispatch({
|
||||
type: 'NOTE_VISIBLE_PANES_SET',
|
||||
panes: ['viewer'],
|
||||
});
|
||||
const noteId = await openNewNote({ title: 'Provisional note', body: 'Test body' });
|
||||
// Mark note as provisional by dispatching NOTE_UPDATE_ONE with provisional flag
|
||||
const note = await Note.load(noteId);
|
||||
store.dispatch({
|
||||
type: 'NOTE_UPDATE_ONE',
|
||||
note: note,
|
||||
provisional: true,
|
||||
});
|
||||
render(<WrappedNoteScreen />);
|
||||
const titleInput = await screen.findByDisplayValue('Provisional note');
|
||||
expect(titleInput).toBeVisible();
|
||||
await expectToBeEditing(true);
|
||||
});
|
||||
|
||||
it.each([
|
||||
[['viewer']],
|
||||
[['editor']],
|
||||
])('should preserve noteVisiblePanes state when leaving and returning to the same note', async (panes) => {
|
||||
const firstRender = await setupNoteWithPanes(panes);
|
||||
await expectToBeEditing(panes.includes('editor'));
|
||||
// Navigate away
|
||||
await act(async () => {
|
||||
store.dispatch({
|
||||
type: 'NAV_GO',
|
||||
routeName: 'Notes',
|
||||
});
|
||||
});
|
||||
firstRender.unmount();
|
||||
|
||||
// Navigate back to the same note
|
||||
const currentState = store.getState();
|
||||
const noteId = currentState.selectedNoteIds[0];
|
||||
await act(async () => {
|
||||
await openExistingNote(noteId);
|
||||
});
|
||||
render(<WrappedNoteScreen />);
|
||||
const titleInput = await screen.findByDisplayValue('Test note');
|
||||
expect(titleInput).toBeVisible();
|
||||
// Should still be in the same mode
|
||||
await expectToBeEditing(panes.includes('editor'));
|
||||
expect(store.getState().noteVisiblePanes).toEqual(panes);
|
||||
});
|
||||
|
||||
it.each([
|
||||
[['viewer']],
|
||||
[['editor']],
|
||||
])('should preserve noteVisiblePanes state when navigating from note 1 to note 2', async (panes) => {
|
||||
// Open note 1
|
||||
await act(async () => {
|
||||
store.dispatch({
|
||||
type: 'NOTE_VISIBLE_PANES_SET',
|
||||
panes: panes,
|
||||
});
|
||||
});
|
||||
await act(async () => {
|
||||
return await openNewNote({ title: 'Note 1', body: 'Test body 1' });
|
||||
});
|
||||
const render1 = render(<WrappedNoteScreen />);
|
||||
const titleInput1 = await screen.findByDisplayValue('Note 1');
|
||||
expect(titleInput1).toBeVisible();
|
||||
await expectToBeEditing(panes.includes('editor'));
|
||||
render1.unmount();
|
||||
|
||||
// Open note 2
|
||||
const note2Id = await act(async () => {
|
||||
return await openNewNote({ title: 'Note 2', body: 'Test body 2' });
|
||||
});
|
||||
await act(async () => {
|
||||
await openExistingNote(note2Id);
|
||||
});
|
||||
render(<WrappedNoteScreen />);
|
||||
const titleInput2 = await screen.findByDisplayValue('Note 2');
|
||||
expect(titleInput2).toBeVisible();
|
||||
// Note 2 should be in the same mode
|
||||
await expectToBeEditing(panes.includes('editor'));
|
||||
expect(store.getState().noteVisiblePanes).toEqual(panes);
|
||||
});
|
||||
|
||||
it('should set the initial editor cursor location to the specified hash', async () => {
|
||||
await openNewNote({ title: 'To be edited', body: 'a test\n\n# Test\n\n# Test 2\n\n# Test 3' });
|
||||
store.dispatch({ type: 'NAV_GO', noteHash: 'test-2' });
|
||||
const { unmount } = render(<WrappedNoteScreen />);
|
||||
|
||||
await openEditor();
|
||||
const editor = await getMarkdownEditorControl();
|
||||
|
||||
expect(editor.getCursor().line).toBe(4);
|
||||
|
||||
unmount();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -19,6 +19,7 @@ const md5 = require('md5');
|
||||
import BackButtonService from '../../../services/BackButtonService';
|
||||
import NavService, { OnNavigateCallback as OnNavigateCallback } from '@joplin/lib/services/NavService';
|
||||
import { ModelType } from '@joplin/lib/BaseModel';
|
||||
import FloatingActionButton from '../../buttons/FloatingActionButton';
|
||||
import { fileExtension, safeFileExtension } from '@joplin/lib/path-utils';
|
||||
import * as mimeUtils from '@joplin/lib/mime-utils';
|
||||
import ScreenHeader, { MenuOptionType } from '../../ScreenHeader';
|
||||
@@ -121,7 +122,6 @@ interface Props extends BaseProps {
|
||||
pluginHtmlContents: PluginHtmlContents;
|
||||
editorNoteReloadTimeRequest: number;
|
||||
canPublish: boolean;
|
||||
noteVisiblePanes: string[];
|
||||
}
|
||||
|
||||
interface ComponentProps extends Props {
|
||||
@@ -199,7 +199,6 @@ class NoteScreenComponent extends BaseScreenComponent<ComponentProps, State> imp
|
||||
private editorPluginHandler_ = new EditorPluginHandler(PluginService.instance(), saveEvent => {
|
||||
return shared.noteComponent_change(this, 'body', saveEvent.body);
|
||||
});
|
||||
private refreshKey: number | undefined;
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
|
||||
public static navigationOptions(): any {
|
||||
@@ -209,11 +208,9 @@ class NoteScreenComponent extends BaseScreenComponent<ComponentProps, State> imp
|
||||
public constructor(props: ComponentProps) {
|
||||
super(props);
|
||||
|
||||
const initialMode = props.noteVisiblePanes?.includes('editor') ? 'edit' : 'view';
|
||||
|
||||
this.state = {
|
||||
note: Note.new(),
|
||||
mode: initialMode,
|
||||
mode: 'view',
|
||||
readOnly: false,
|
||||
folder: null,
|
||||
lastSavedNote: null,
|
||||
@@ -245,16 +242,12 @@ class NoteScreenComponent extends BaseScreenComponent<ComponentProps, State> imp
|
||||
titleContainerWidth: 0,
|
||||
};
|
||||
|
||||
const initialScroll = NotePositionService.instance().getScrollPercent(props.noteId, defaultWindowId);
|
||||
const initialCursorLocation = NotePositionService.instance().getCursorPosition(props.noteId, defaultWindowId).markdown;
|
||||
// Ignore the initial scroll and cursor location when there's a note hash. The editor/viewer should jump to
|
||||
// the hash, rather than the last position.
|
||||
if (!props.noteHash) {
|
||||
if (initialCursorLocation) {
|
||||
this.selection = { start: initialCursorLocation, end: initialCursorLocation };
|
||||
}
|
||||
this.lastBodyScroll = initialScroll;
|
||||
if (initialCursorLocation) {
|
||||
this.selection = { start: initialCursorLocation, end: initialCursorLocation };
|
||||
}
|
||||
const initialScroll = NotePositionService.instance().getScrollPercent(props.noteId, defaultWindowId);
|
||||
this.lastBodyScroll = initialScroll;
|
||||
|
||||
this.titleTextFieldRef = React.createRef();
|
||||
|
||||
@@ -300,7 +293,14 @@ class NoteScreenComponent extends BaseScreenComponent<ComponentProps, State> imp
|
||||
|
||||
if (this.state.mode === 'edit') {
|
||||
Keyboard.dismiss();
|
||||
|
||||
this.setState({
|
||||
mode: 'view',
|
||||
});
|
||||
|
||||
await this.undoRedoService_.reset();
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
if (this.state.fromShare) {
|
||||
@@ -393,7 +393,6 @@ class NoteScreenComponent extends BaseScreenComponent<ComponentProps, State> imp
|
||||
setMode: (mode: 'view'|'edit') => {
|
||||
this.setState({ mode });
|
||||
},
|
||||
dispatch: this.props.dispatch,
|
||||
},
|
||||
commands,
|
||||
true,
|
||||
@@ -712,27 +711,10 @@ class NoteScreenComponent extends BaseScreenComponent<ComponentProps, State> imp
|
||||
});
|
||||
}
|
||||
|
||||
const editorPluginIdsChanged = this.props.visibleEditorPluginIds !== prevProps.visibleEditorPluginIds;
|
||||
if (editorPluginIdsChanged || this.props.editorNoteReloadTimeRequest !== prevProps.editorNoteReloadTimeRequest) {
|
||||
if (this.props.visibleEditorPluginIds !== prevProps.visibleEditorPluginIds || this.props.editorNoteReloadTimeRequest !== prevProps.editorNoteReloadTimeRequest) {
|
||||
const { editorPlugin } = getShownPluginEditorView(this.props.plugins, this.props.windowId);
|
||||
const explicitReloadRequired = !editorPlugin && this.props.editorNoteReloadTimeRequest > this.state.noteLastLoadTime;
|
||||
|
||||
if (explicitReloadRequired) {
|
||||
void this.reloadNoteAndUpdateRefreshKey();
|
||||
}
|
||||
|
||||
if (explicitReloadRequired || (editorPlugin && editorPluginIdsChanged)) {
|
||||
// Clear the undo / redo state, as undo / redo steps wont be in sync with the current content after the note editor has been refreshed
|
||||
if (!this.useEditorBeta()) {
|
||||
void this.undoRedoService_.reset();
|
||||
}
|
||||
|
||||
this.setState({
|
||||
undoRedoButtonState: {
|
||||
canUndo: false,
|
||||
canRedo: false,
|
||||
},
|
||||
});
|
||||
if (!editorPlugin && this.props.editorNoteReloadTimeRequest > this.state.noteLastLoadTime) {
|
||||
void shared.reloadNote(this);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -773,11 +755,6 @@ class NoteScreenComponent extends BaseScreenComponent<ComponentProps, State> imp
|
||||
});
|
||||
}
|
||||
|
||||
private async reloadNoteAndUpdateRefreshKey() {
|
||||
await shared.reloadNote(this);
|
||||
this.refreshKey = this.props.editorNoteReloadTimeRequest;
|
||||
}
|
||||
|
||||
private title_changeText(text: string) {
|
||||
let newText = text;
|
||||
newText = text.replace(/(\r\n|\n|\r)/gm, ' ');
|
||||
@@ -1468,14 +1445,6 @@ class NoteScreenComponent extends BaseScreenComponent<ComponentProps, State> imp
|
||||
await this.saveOneProperty('todo_completed', checked ? time.unixMs() : 0);
|
||||
}
|
||||
|
||||
private toggleVisiblePanes = () => {
|
||||
const isSwitchingToEdit = this.state.mode === 'view';
|
||||
void CommandService.instance().execute('toggleVisiblePanes');
|
||||
if (isSwitchingToEdit) {
|
||||
this.doFocusUpdate_ = true;
|
||||
}
|
||||
};
|
||||
|
||||
public scheduleFocusUpdate() {
|
||||
if (this.focusUpdateIID_) shim.clearInterval(this.focusUpdateIID_);
|
||||
|
||||
@@ -1736,6 +1705,7 @@ class NoteScreenComponent extends BaseScreenComponent<ComponentProps, State> imp
|
||||
bodyComponent = <NoteEditor
|
||||
ref={this.editorRef}
|
||||
toolbarEnabled={this.props.toolbarEnabled && !increaseSpaceForEditor}
|
||||
themeId={this.props.themeId}
|
||||
noteId={this.props.noteId}
|
||||
noteHash={this.props.noteHash}
|
||||
initialText={note.body}
|
||||
@@ -1766,12 +1736,32 @@ class NoteScreenComponent extends BaseScreenComponent<ComponentProps, State> imp
|
||||
onScroll={this.props.editorType === EditorType.RichText ? this.onBodyViewerScroll : this.onMarkdownEditorScroll}
|
||||
|
||||
mode={this.props.editorType}
|
||||
refreshKey={this.refreshKey}
|
||||
/>;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const voiceTypingDialogShown = this.state.showSpeechToTextDialog || this.state.showAudioRecorder;
|
||||
const renderActionButton = () => {
|
||||
if (voiceTypingDialogShown) return null;
|
||||
if (editorView) return null;
|
||||
if (!this.state.note || !!this.state.note.deleted_time) return null;
|
||||
|
||||
const editButton = {
|
||||
label: _('Edit'),
|
||||
icon: 'create',
|
||||
onPress: () => {
|
||||
this.setState({ mode: 'edit' });
|
||||
|
||||
this.doFocusUpdate_ = true;
|
||||
},
|
||||
};
|
||||
|
||||
if (this.state.mode === 'edit') return null;
|
||||
|
||||
return <FloatingActionButton mainButton={editButton} />;
|
||||
};
|
||||
|
||||
// Save button is not really needed anymore with the improved save logic
|
||||
const showSaveButton = false; // this.state.mode === 'edit' || this.isModified() || this.saveButtonHasBeenShown_;
|
||||
const saveButtonDisabled = true;// !this.isModified();
|
||||
@@ -1870,9 +1860,6 @@ class NoteScreenComponent extends BaseScreenComponent<ComponentProps, State> imp
|
||||
undoButtonDisabled={!this.state.undoRedoButtonState.canUndo && this.state.undoRedoButtonState.canRedo}
|
||||
onUndoButtonPress={this.screenHeader_undoButtonPress}
|
||||
onRedoButtonPress={this.screenHeader_redoButtonPress}
|
||||
showViewToggleButton={!!this.state.note && !this.state.note.deleted_time && !editorView}
|
||||
viewToggleIconName={this.state.mode === 'edit' ? 'ionicon book' : 'ionicon pencil'}
|
||||
onViewTogglePress={this.toggleVisiblePanes}
|
||||
title={getDisplayParentTitle(this.state.note, this.state.folder)}
|
||||
/>;
|
||||
|
||||
@@ -1882,6 +1869,7 @@ class NoteScreenComponent extends BaseScreenComponent<ComponentProps, State> imp
|
||||
{!increaseSpaceForEditor && titleComp}
|
||||
{bodyComponent}
|
||||
{renderVoiceTypingDialogs()}
|
||||
{renderActionButton()}
|
||||
|
||||
<SelectDateTimeDialog themeId={this.props.themeId} shown={this.state.alarmDialogShown} date={dueDate} onAccept={this.onAlarmDialogAccept} onReject={this.onAlarmDialogReject} />
|
||||
|
||||
@@ -1949,7 +1937,6 @@ const NoteScreen = connect((state: AppState) => {
|
||||
plugins: state.pluginService.plugins,
|
||||
pluginHtmlContents: state.pluginService.pluginHtmlContents,
|
||||
editorNoteReloadTimeRequest: state.editorNoteReloadTimeRequest,
|
||||
noteVisiblePanes: state.noteVisiblePanes,
|
||||
|
||||
editorType: state.settings['editor.codeView'] ? EditorType.Markdown : EditorType.RichText,
|
||||
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { CommandContext, CommandDeclaration, CommandRuntime } from '@joplin/lib/services/CommandService';
|
||||
import { CommandRuntimeProps } from '../types';
|
||||
import Setting from '@joplin/lib/models/Setting';
|
||||
|
||||
export const declaration: CommandDeclaration = {
|
||||
// For compatibility with the desktop app, this command is called "toggleVisiblePanes".
|
||||
@@ -11,13 +10,8 @@ export const declaration: CommandDeclaration = {
|
||||
export const runtime = (props: CommandRuntimeProps): CommandRuntime => {
|
||||
return {
|
||||
execute: async (_context: CommandContext) => {
|
||||
const panes = Setting.value('noteVisiblePanes') || ['viewer'];
|
||||
props.dispatch({
|
||||
type: 'NOTE_VISIBLE_PANES_SET',
|
||||
panes: panes.includes('editor') ? ['viewer'] : ['editor'],
|
||||
});
|
||||
const currentMode = props.getMode();
|
||||
const newMode = currentMode === 'edit' ? 'view' : 'edit';
|
||||
// For now, the only two "panes" on mobile are view and edit.
|
||||
const newMode = props.getMode() === 'edit' ? 'view' : 'edit';
|
||||
props.setMode(newMode);
|
||||
},
|
||||
};
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { ResourceEntity } from '@joplin/lib/services/database/types';
|
||||
import { DialogControl } from '../../DialogManager';
|
||||
import { Dispatch } from 'redux';
|
||||
|
||||
export interface PickerResponse {
|
||||
uri?: string;
|
||||
@@ -21,5 +20,4 @@ export interface CommandRuntimeProps {
|
||||
setTagDialogVisible(visible: boolean): void;
|
||||
setAudioRecorderVisible(visible: boolean): void;
|
||||
dialogs: DialogControl;
|
||||
dispatch: Dispatch;
|
||||
}
|
||||
|
||||
@@ -21,7 +21,6 @@ const defaultRendererSettings: RenderSettings = {
|
||||
|
||||
pluginSettings: {},
|
||||
requestPluginSetting: () => { },
|
||||
showNoteLinkIcon: true,
|
||||
};
|
||||
|
||||
const makeRenderer = (options: Partial<RendererSetupOptions>) => {
|
||||
|
||||
@@ -27,7 +27,6 @@ export interface RenderSettings {
|
||||
|
||||
splitted?: boolean; // Move CSS into a separate output
|
||||
mapsToLine?: boolean; // Sourcemaps
|
||||
showNoteLinkIcon?: boolean;
|
||||
|
||||
createEditPopupSyntax: string;
|
||||
destroyEditPopupSyntax: string;
|
||||
@@ -137,7 +136,6 @@ export default class Renderer {
|
||||
splitted: settings.splitted,
|
||||
mapsToLine: settings.mapsToLine,
|
||||
whiteBackgroundNoteRendering: markup.language === MarkupLanguage.Html,
|
||||
showNoteLinkIcon: settings.showNoteLinkIcon,
|
||||
globalSettings: settings.globalSettings,
|
||||
};
|
||||
|
||||
|
||||
@@ -71,7 +71,6 @@ export interface RenderOptions {
|
||||
// Forwarded renderer settings
|
||||
splitted?: boolean;
|
||||
mapsToLine?: boolean;
|
||||
showNoteLinkIcon?: boolean;
|
||||
}
|
||||
|
||||
type CancelEvent = { cancelled: boolean };
|
||||
|
||||
@@ -240,7 +240,6 @@ const useWebViewSetup = (props: Props): Result => {
|
||||
}
|
||||
},
|
||||
removeUnusedPluginAssets: options.removeUnusedPluginAssets,
|
||||
showNoteLinkIcon: options.showNoteLinkIcon,
|
||||
globalSettings: {
|
||||
'markdown.plugin.abc.options': Setting.value('markdown.plugin.abc.options'),
|
||||
},
|
||||
|
||||
@@ -2015,7 +2015,7 @@ PODS:
|
||||
- Yoga
|
||||
- RNCPushNotificationIOS (1.11.0):
|
||||
- React-Core
|
||||
- RNDateTimePicker (8.5.1):
|
||||
- RNDateTimePicker (8.4.7):
|
||||
- hermes-engine
|
||||
- RCTRequired
|
||||
- RCTTypeSafety
|
||||
@@ -2041,7 +2041,7 @@ PODS:
|
||||
- React-Core
|
||||
- RNFileViewer (2.1.5):
|
||||
- React-Core
|
||||
- RNLocalize (3.6.0):
|
||||
- RNLocalize (3.5.4):
|
||||
- hermes-engine
|
||||
- RCTRequired
|
||||
- RCTTypeSafety
|
||||
@@ -2627,10 +2627,10 @@ SPEC CHECKSUMS:
|
||||
rn-fetch-blob: 25612b6d6f6e980c6f17ed98ba2f58f5696a51ca
|
||||
RNCClipboard: 88d7eeb555d1183915f0885bdbc5c97eb6f7f3ba
|
||||
RNCPushNotificationIOS: 6c4ca3388c7434e4a662b92e4dfeeee858e6f440
|
||||
RNDateTimePicker: 19ffa303c4524ec0a2dfdee2658198451c16b7f1
|
||||
RNDateTimePicker: f11373a05d806e849ab984e2806c531278b47cdd
|
||||
RNDeviceInfo: bcce8752b5043a623fe3c26789679b473f705d3c
|
||||
RNFileViewer: 4b5d83358214347e4ab2d4ca8d5c1c90d869e251
|
||||
RNLocalize: 83a242b38886bf7e84073410c101e9ea39a1c1a5
|
||||
RNLocalize: da3c00bf1044a67e72cf8b450289e263fd5baab3
|
||||
RNQuickAction: c2c8f379e614428be0babe4d53a575739667744d
|
||||
RNSecureRandom: b64d263529492a6897e236a22a2c4249aa1b53dc
|
||||
RNShare: 0e600372fb35783fe30d413efd28d11de2bf6cf0
|
||||
|
||||
@@ -31,7 +31,7 @@
|
||||
"@joplin/whisper-voice-typing": "~3.6",
|
||||
"@js-draw/material-icons": "1.33.0",
|
||||
"@react-native-clipboard/clipboard": "1.16.3",
|
||||
"@react-native-community/datetimepicker": "8.5.1",
|
||||
"@react-native-community/datetimepicker": "8.4.7",
|
||||
"@react-native-community/geolocation": "3.4.0",
|
||||
"@react-native-community/netinfo": "11.4.1",
|
||||
"@react-native-community/push-notification-ios": "1.11.0",
|
||||
@@ -66,7 +66,7 @@
|
||||
"react-native-file-viewer": "2.1.5",
|
||||
"react-native-get-random-values": "1.11.0",
|
||||
"react-native-image-picker": "8.2.1",
|
||||
"react-native-localize": "3.6.0",
|
||||
"react-native-localize": "3.5.4",
|
||||
"react-native-modal-datetime-picker": "18.0.0",
|
||||
"react-native-nitro-modules": "0.33.2",
|
||||
"react-native-paper": "5.14.5",
|
||||
|
||||
@@ -225,10 +225,6 @@ const generalMiddleware = (store: any) => (next: any) => async (action: any) =>
|
||||
void ResourceFetcher.instance().autoAddResources();
|
||||
}
|
||||
|
||||
if (['NOTE_VISIBLE_PANES_SET'].indexOf(action.type) >= 0) {
|
||||
Setting.setValue('noteVisiblePanes', newState.noteVisiblePanes);
|
||||
}
|
||||
|
||||
if (doRefreshFolders) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
|
||||
await scheduleRefreshFolders((action: any) => storeDispatch(action), newState.selectedFolderId);
|
||||
|
||||
@@ -18,7 +18,6 @@ const appDefaultState: AppState = {
|
||||
showPanelsDialog: false,
|
||||
noteEditorVisible: false,
|
||||
syncWizardVisible: false,
|
||||
noteVisiblePanes: ['viewer'],
|
||||
...defaultState,
|
||||
|
||||
// On mobile, it's possible to select notes that aren't in the selected folder/tag/etc.
|
||||
|
||||
@@ -260,13 +260,6 @@ const appReducer = (state = appDefaultState, action: any) => {
|
||||
case 'SYNC_WIZARD_VISIBLE_CHANGE':
|
||||
newState = { ...state, syncWizardVisible: action.visible };
|
||||
break;
|
||||
|
||||
case 'NOTE_VISIBLE_PANES_SET':
|
||||
newState = {
|
||||
...state,
|
||||
noteVisiblePanes: Array.isArray(action.panes) && action.panes.length ? action.panes : ['viewer'],
|
||||
};
|
||||
break;
|
||||
}
|
||||
} catch (error) {
|
||||
error.message = `In reducer: ${error.message} Action: ${JSON.stringify(action)}`;
|
||||
|
||||
@@ -367,19 +367,6 @@ const buildStartupTasks = (
|
||||
ids: Setting.value('collapsedFolderIds'),
|
||||
});
|
||||
});
|
||||
addTask('buildStartupTasks/initialize note visible panes', async () => {
|
||||
const panes = Setting.value('noteVisiblePanes') || ['viewer'];
|
||||
|
||||
dispatch({
|
||||
type: 'NOTE_VISIBLE_PANES_SET',
|
||||
panes: panes,
|
||||
});
|
||||
|
||||
dispatch({
|
||||
type: 'NOTE_EDITOR_VISIBLE_CHANGE',
|
||||
visible: panes.includes('editor'),
|
||||
});
|
||||
});
|
||||
addTask('buildStartupTasks/load tags', async () => {
|
||||
const tags = await Tag.allWithNotes();
|
||||
|
||||
@@ -430,6 +417,10 @@ const buildStartupTasks = (
|
||||
ResourceFetcher.instance().on('downloadComplete', resourceFetcher_downloadComplete);
|
||||
void ResourceFetcher.instance().start();
|
||||
|
||||
// Collect revisions more frequently on mobile because it doesn't auto-save
|
||||
// and it cannot collect anything when the app is not active.
|
||||
RevisionService.instance().runInBackground(1000 * 30);
|
||||
|
||||
reg.setupRecurrentSync();
|
||||
|
||||
// When the app starts we want the full sync to
|
||||
@@ -443,10 +434,6 @@ const buildStartupTasks = (
|
||||
void AlarmService.updateAllNotifications();
|
||||
|
||||
void DecryptionWorker.instance().scheduleStart();
|
||||
|
||||
// Collect revisions more frequently on mobile because it doesn't auto-save
|
||||
// and it cannot collect anything when the app is not active.
|
||||
RevisionService.instance().runInBackground(1000 * 30);
|
||||
});
|
||||
});
|
||||
addTask('buildStartupTasks/set up welcome utils', async () => {
|
||||
|
||||
@@ -12,5 +12,4 @@ export interface AppState extends State {
|
||||
disableSideMenuGestures: boolean;
|
||||
noteEditorVisible: boolean;
|
||||
syncWizardVisible: boolean;
|
||||
noteVisiblePanes: string[];
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@ import { EditorView, keymap } from '@codemirror/view';
|
||||
import { closeBrackets, closeBracketsKeymap } from '@codemirror/autocomplete';
|
||||
import { EditorKeymap, EditorLanguageType, EditorSettings } from '../types';
|
||||
import createTheme from './theme';
|
||||
import { EditorState, Prec, StateField } from '@codemirror/state';
|
||||
import { EditorState } from '@codemirror/state';
|
||||
import { deleteMarkupBackward, markdown, markdownLanguage } from '@codemirror/lang-markdown';
|
||||
import { GFM as GitHubFlavoredMarkdownExtension } from '@lezer/markdown';
|
||||
import markdownMathExtension from './extensions/markdownMathExtension';
|
||||
@@ -13,22 +13,13 @@ import { html } from '@codemirror/lang-html';
|
||||
import { defaultKeymap, emacsStyleKeymap } from '@codemirror/commands';
|
||||
import { vim } from '@replit/codemirror-vim';
|
||||
import { indentUnit } from '@codemirror/language';
|
||||
import { Prec } from '@codemirror/state';
|
||||
import insertNewlineContinueMarkup from './editorCommands/insertNewlineContinueMarkup';
|
||||
import renderingExtension from './extensions/rendering/renderingExtension';
|
||||
import { RenderedContentContext } from './extensions/rendering/types';
|
||||
import highlightActiveLineExtension from './extensions/highlightActiveLineExtension';
|
||||
import renderBlockImages from './extensions/rendering/renderBlockImages';
|
||||
|
||||
const closingFencedBlock = StateField.define<boolean>({
|
||||
create: () => false,
|
||||
update: (_, tr) => {
|
||||
const pos = tr.state.selection.main.from;
|
||||
const textBefore = tr.state.doc.sliceString(Math.max(0, pos - 2), pos);
|
||||
const backticksBefore = textBefore.length - textBefore.replace(/`+$/, '').length;
|
||||
return backticksBefore >= 2;
|
||||
},
|
||||
});
|
||||
|
||||
const configFromSettings = (settings: EditorSettings, context: RenderedContentContext) => {
|
||||
const languageExtension = (() => {
|
||||
const openingBrackets = '`([{\'"‘“(《「『【〔〖〘〚'.split('');
|
||||
@@ -59,14 +50,7 @@ const configFromSettings = (settings: EditorSettings, context: RenderedContentCo
|
||||
htmlTagLanguage: html({ matchClosingTags: false, autoCloseTags: false }),
|
||||
}),
|
||||
}),
|
||||
markdownLanguage.data.compute([closingFencedBlock], state => {
|
||||
// Don't auto-complete `s when closing a code block.
|
||||
// See https://github.com/laurent22/joplin/issues/12569.
|
||||
if (state.field(closingFencedBlock)) {
|
||||
return { closeBrackets: { brackets: openingBrackets.filter(b => b !== '`') } };
|
||||
}
|
||||
return { closeBrackets: { brackets: openingBrackets } };
|
||||
}),
|
||||
markdownLanguage.data.of({ closeBrackets: { brackets: openingBrackets } }),
|
||||
keymap.of(settings.autocompleteMarkup ? [
|
||||
{ key: 'Enter', run: insertNewlineContinueMarkup },
|
||||
{ key: 'Backspace', run: deleteMarkupBackward },
|
||||
@@ -82,7 +66,6 @@ const configFromSettings = (settings: EditorSettings, context: RenderedContentCo
|
||||
|
||||
const extensions = [
|
||||
languageExtension,
|
||||
closingFencedBlock,
|
||||
createTheme(settings.themeData),
|
||||
EditorView.contentAttributes.of({
|
||||
autocapitalize: 'sentence',
|
||||
|
||||
@@ -30,9 +30,9 @@ export type OnClickHandler = (event: OnClickEvent) => Promise<void>;
|
||||
* The `item.*` properties are specific to the rendered item. The most important being
|
||||
* `item.selected`, which you can use to display the selected note in a different way.
|
||||
*/
|
||||
export type ListRendererDependency = ListRendererDatabaseDependency | 'item.index' | 'item.selected' | 'item.size.height' | 'item.size.width' | 'note.checkboxes' | 'note.folder.title' | 'note.isWatched' | 'note.tags' | 'note.todoStatusText' | 'note.titleHtml';
|
||||
export type ListRendererDependency = ListRendererDatabaseDependency | 'item.index' | 'item.selected' | 'item.size.height' | 'item.size.width' | 'note.folder.title' | 'note.isWatched' | 'note.tags' | 'note.todoStatusText' | 'note.titleHtml';
|
||||
export type ListRendererItemValueTemplates = Record<string, string>;
|
||||
export declare const columnNames: readonly ["note.checkboxes", "note.folder.title", "note.is_todo", "note.latitude", "note.longitude", "note.source_url", "note.tags", "note.title", "note.todo_completed", "note.todo_due", "note.user_created_time", "note.user_updated_time"];
|
||||
export declare const columnNames: readonly ["note.folder.title", "note.is_todo", "note.latitude", "note.longitude", "note.source_url", "note.tags", "note.title", "note.todo_completed", "note.todo_due", "note.user_created_time", "note.user_updated_time"];
|
||||
export type ColumnName = typeof columnNames[number];
|
||||
export interface ListRenderer {
|
||||
/**
|
||||
|
||||
@@ -50,7 +50,6 @@ export type ListRendererDependency =
|
||||
'item.selected' |
|
||||
'item.size.height' |
|
||||
'item.size.width' |
|
||||
'note.checkboxes' |
|
||||
'note.folder.title' |
|
||||
'note.isWatched' |
|
||||
'note.tags' |
|
||||
@@ -60,7 +59,6 @@ export type ListRendererDependency =
|
||||
export type ListRendererItemValueTemplates = Record<string, string>;
|
||||
|
||||
export const columnNames = [
|
||||
'note.checkboxes',
|
||||
'note.folder.title',
|
||||
'note.is_todo',
|
||||
'note.latitude',
|
||||
|
||||
@@ -434,29 +434,8 @@ export interface EditorPluginCallbacks {
|
||||
|
||||
export type VisibleHandler = ()=> Promise<void>;
|
||||
|
||||
/**
|
||||
* Identifies the type of element that was right-clicked in the editor context menu.
|
||||
*/
|
||||
export enum ContextMenuItemType {
|
||||
None = '',
|
||||
Image = 'image',
|
||||
Resource = 'resource',
|
||||
Text = 'text',
|
||||
Link = 'link',
|
||||
}
|
||||
|
||||
export interface EditContextMenuFilterObject {
|
||||
items: MenuItem[];
|
||||
/**
|
||||
* Context about what was right-clicked. Plugins should use this instead of
|
||||
* checking the editor cursor position, as the cursor may not reflect the
|
||||
* actual click location.
|
||||
*/
|
||||
context?: {
|
||||
resourceId?: string;
|
||||
itemType?: ContextMenuItemType;
|
||||
textToCopy?: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface EditorActivationCheckFilterObject {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { exists, mkdir, readFile, remove, writeFile } from 'fs-extra';
|
||||
import { exists, mkdir, readFile, remove } from 'fs-extra';
|
||||
import { join } from 'path';
|
||||
import htmlpack from '.';
|
||||
|
||||
@@ -32,25 +32,4 @@ describe('htmlpack/index', () => {
|
||||
</body>
|
||||
</html>`);
|
||||
});
|
||||
|
||||
test('should not throw when a script asset is missing', async () => {
|
||||
const inputFile = join(outputDirectory, 'input.html');
|
||||
const outputFile = join(outputDirectory, 'output.html');
|
||||
|
||||
const inputHtml = `
|
||||
<html>
|
||||
<head>
|
||||
<script type="application/javascript" src="missing-script.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
<p>Test</p>
|
||||
</body>
|
||||
</html>`;
|
||||
|
||||
await writeFile(inputFile, inputHtml, 'utf8');
|
||||
await htmlpack(inputFile, outputFile);
|
||||
|
||||
const outputContent = await readFile(outputFile, 'utf8');
|
||||
expect(outputContent).toContain('<p>Test</p>');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -132,7 +132,6 @@ const packToString = async (baseDir: string, inputFileText: string, fs: FileApi)
|
||||
if (!src) return null;
|
||||
|
||||
const scriptFilePath = `${baseDir}/${src}`;
|
||||
if (!await fs.exists(scriptFilePath)) return null;
|
||||
let content = await fs.readFileText(scriptFilePath);
|
||||
|
||||
// There's no simple way to insert arbitrary content in <script> tags.
|
||||
|
||||
@@ -531,13 +531,6 @@ export default class Synchronizer {
|
||||
|
||||
// console.info('NEW', newInfo);
|
||||
|
||||
if (newInfo.revisionServiceEnabled !== localInfo.revisionServiceEnabled) {
|
||||
Setting.setValue('revisionService.enabled', newInfo.revisionServiceEnabled);
|
||||
}
|
||||
if (newInfo.revisionServiceTtlDays !== localInfo.revisionServiceTtlDays) {
|
||||
Setting.setValue('revisionService.ttlDays', newInfo.revisionServiceTtlDays);
|
||||
}
|
||||
|
||||
if (newInfo.e2ee !== previousE2EE) {
|
||||
if (newInfo.e2ee) {
|
||||
const mk = getActiveMasterKey(newInfo);
|
||||
@@ -878,7 +871,7 @@ export default class Synchronizer {
|
||||
|
||||
let context = null;
|
||||
let newDeltaContext = null;
|
||||
const localFoldersToDelete = new Set<string>();
|
||||
const localFoldersToDelete = [];
|
||||
let hasCancelled = false;
|
||||
if (lastContext.delta) context = lastContext.delta;
|
||||
|
||||
@@ -980,8 +973,6 @@ export default class Synchronizer {
|
||||
reason = 'remote exists but local does not';
|
||||
content = await loadContent();
|
||||
ItemClass = content ? BaseItem.itemClass(content) : null;
|
||||
} else {
|
||||
reason = 'skipping: the item was deleted';
|
||||
}
|
||||
} else {
|
||||
ItemClass = BaseItem.itemClass(local);
|
||||
@@ -990,11 +981,6 @@ export default class Synchronizer {
|
||||
action = SyncAction.DeleteLocal;
|
||||
reason = 'remote has been deleted';
|
||||
} else {
|
||||
if (localFoldersToDelete.has(remoteId)) {
|
||||
logger.debug('Removing a scheduled folder deletion (', remoteId, '). It was recreated by sync.');
|
||||
localFoldersToDelete.delete(remoteId);
|
||||
}
|
||||
|
||||
if (this.api().supportsAccurateTimestamp && remote.jop_updated_time === local.updated_time) {
|
||||
// Nothing to do, and no need to fetch the content
|
||||
} else {
|
||||
@@ -1081,12 +1067,7 @@ export default class Synchronizer {
|
||||
await MasterKey.save(content);
|
||||
}
|
||||
} else {
|
||||
const saved = await ItemClass.save(content, options);
|
||||
|
||||
// Ensure that the item can be found if another create/update event is received for the same item:
|
||||
if (!local) {
|
||||
locals.push(saved);
|
||||
}
|
||||
await ItemClass.save(content, options);
|
||||
}
|
||||
|
||||
if (creatingOrUpdatingResource) this.dispatch({ type: 'SYNC_CREATED_OR_UPDATED_RESOURCE', id: content.id });
|
||||
@@ -1103,7 +1084,7 @@ export default class Synchronizer {
|
||||
if (content.encryption_applied) this.dispatch({ type: 'SYNC_GOT_ENCRYPTED_ITEM' });
|
||||
} else if (action === SyncAction.DeleteLocal) {
|
||||
if (local.type_ === BaseModel.TYPE_FOLDER) {
|
||||
localFoldersToDelete.add(local.id);
|
||||
localFoldersToDelete.push(local);
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -1153,12 +1134,12 @@ export default class Synchronizer {
|
||||
// ------------------------------------------------------------------------
|
||||
|
||||
if (!this.cancelling()) {
|
||||
for (const folderId of localFoldersToDelete) {
|
||||
const noteIds = await Folder.noteIds(folderId);
|
||||
for (let i = 0; i < localFoldersToDelete.length; i++) {
|
||||
const item = localFoldersToDelete[i];
|
||||
const noteIds = await Folder.noteIds(item.id);
|
||||
if (noteIds.length) {
|
||||
logger.warn('Conflict: Folder to be deleted', folderId, 'still contains notes', noteIds);
|
||||
// CONFLICT
|
||||
await Folder.markNotesAsConflict(folderId);
|
||||
await Folder.markNotesAsConflict(item.id);
|
||||
}
|
||||
|
||||
const deletionOptions: DeleteOptions = {
|
||||
@@ -1167,7 +1148,7 @@ export default class Synchronizer {
|
||||
changeSource: ItemChange.SOURCE_SYNC,
|
||||
sourceDescription: 'Sync',
|
||||
};
|
||||
await Folder.delete(folderId, deletionOptions);
|
||||
await Folder.delete(item.id, deletionOptions);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import Logger from '@joplin/utils/Logger';
|
||||
import shim, { FetchOptions } from './shim';
|
||||
import shim from './shim';
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
|
||||
const parseXmlString: (xml: string, options: any, callback: (error: Error | null, result: any)=> void)=> void = require('xml2js').parseString;
|
||||
import JoplinError from './JoplinError';
|
||||
@@ -41,13 +41,6 @@ interface ExecOptions {
|
||||
path?: string;
|
||||
}
|
||||
|
||||
// detection state, whether invalid If-None-Match header is accepted by server
|
||||
enum ExcludeIfNoneMatch {
|
||||
Unknown = 1,
|
||||
No = 2,
|
||||
Yes = 3,
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
|
||||
type JsonValue = any;
|
||||
|
||||
@@ -55,13 +48,11 @@ class WebDavApi {
|
||||
private logger_: Logger;
|
||||
private options_: WebDavApiOptions;
|
||||
private lastRequests_: LoggedRequest[];
|
||||
private excludeIfNoneMatch: ExcludeIfNoneMatch;
|
||||
|
||||
public constructor(options: WebDavApiOptions) {
|
||||
this.logger_ = new Logger();
|
||||
this.options_ = options;
|
||||
this.lastRequests_ = [];
|
||||
this.excludeIfNoneMatch = ExcludeIfNoneMatch.Unknown;
|
||||
// Prevent unused method warning - this method is kept for debugging
|
||||
void this._requestToCurl;
|
||||
}
|
||||
@@ -378,42 +369,6 @@ class WebDavApi {
|
||||
}
|
||||
}
|
||||
|
||||
private async fetchWithIfNoneMatchTest(url: string, fetchOptions: FetchOptions): Promise<Response> {
|
||||
let response: Response = null;
|
||||
|
||||
if (['GET', 'HEAD'].indexOf(fetchOptions.method) < 0 && this.excludeIfNoneMatch === ExcludeIfNoneMatch.Unknown) {
|
||||
// some webserver, for example Apache Tomcat do not accept invalid If-None-Match header,
|
||||
// which is being sent to resolve issue with Seafile and network library on iOS
|
||||
// to fix this issue, a request is sent with invalid If-None-Match header at first
|
||||
//
|
||||
// if it succeeds, excludeIfNoneMatch flag is set to No, to indicate,
|
||||
// that subsequent request will be sent with If-None-Match header
|
||||
//
|
||||
// if first request with invalid If-None-Match header fails, it's retried without the header
|
||||
// if successful, excludeIfNoneMatch is set to Yes, to indicate,
|
||||
// that subsequent request will be sent without If-None-Match header
|
||||
response = await shim.fetch(url, fetchOptions);
|
||||
if (response.ok) {
|
||||
this.excludeIfNoneMatch = ExcludeIfNoneMatch.No;
|
||||
} else if (response.status === 400) {
|
||||
const fetchOptionsAlt = { ... fetchOptions };
|
||||
fetchOptionsAlt.headers = { ... fetchOptions.headers };
|
||||
delete fetchOptionsAlt.headers['If-None-Match'];
|
||||
const responseAlt = await shim.fetch(url, fetchOptionsAlt);
|
||||
if (responseAlt.ok) {
|
||||
this.excludeIfNoneMatch = ExcludeIfNoneMatch.Yes;
|
||||
return responseAlt;
|
||||
} else if (response.status === 400) {
|
||||
this.excludeIfNoneMatch = ExcludeIfNoneMatch.No;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
response = await shim.fetch(url, fetchOptions);
|
||||
}
|
||||
return response;
|
||||
}
|
||||
|
||||
|
||||
// curl -u admin:123456 'http://nextcloud.local/remote.php/dav/files/admin/' -X PROPFIND --data '<?xml version="1.0" encoding="UTF-8"?>
|
||||
// <d:propfind xmlns:d="DAV:">
|
||||
// <d:prop xmlns:oc="http://owncloud.org/ns">
|
||||
@@ -455,9 +410,7 @@ class WebDavApi {
|
||||
// The "solution", an ugly one, is to send a purposely invalid string as eTag, which will bypass the If-None-Match check - Seafile
|
||||
// finds out that no resource has this ID and simply sends the requested data.
|
||||
// Also add a random value to make sure the eTag is unique for each call.
|
||||
if (['GET', 'HEAD'].indexOf(method) < 0 && this.excludeIfNoneMatch !== ExcludeIfNoneMatch.Yes) {
|
||||
headers['If-None-Match'] = `JoplinIgnore-${Math.floor(Math.random() * 100000)}`;
|
||||
}
|
||||
if (['GET', 'HEAD'].indexOf(method) < 0) headers['If-None-Match'] = `JoplinIgnore-${Math.floor(Math.random() * 100000)}`;
|
||||
if (!headers['User-Agent']) headers['User-Agent'] = 'Joplin/1.0';
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
|
||||
@@ -494,7 +447,7 @@ class WebDavApi {
|
||||
response = await shim.uploadBlob(url, fetchOptions);
|
||||
} else if (options.target === 'string') {
|
||||
if (typeof body === 'string') fetchOptions.headers['Content-Length'] = `${shim.stringByteLength(body)}`;
|
||||
response = await this.fetchWithIfNoneMatchTest(url, fetchOptions);
|
||||
response = await shim.fetch(url, fetchOptions);
|
||||
} else {
|
||||
// file
|
||||
response = await shim.fetchBlob(url, fetchOptions);
|
||||
|
||||
@@ -1,26 +0,0 @@
|
||||
import { setupDatabaseAndSynchronizer, switchClient } from './testing/test-utils';
|
||||
import WelcomeUtils from './WelcomeUtils';
|
||||
import Folder from './models/Folder';
|
||||
import { FolderIconType } from './services/database/types';
|
||||
|
||||
describe('WelcomeUtils', () => {
|
||||
|
||||
beforeEach(async () => {
|
||||
await setupDatabaseAndSynchronizer(1);
|
||||
await switchClient(1);
|
||||
});
|
||||
|
||||
it('should create welcome items with waving hand emoji icon for the folder', async () => {
|
||||
const result = await WelcomeUtils.createWelcomeItems('en_GB');
|
||||
|
||||
expect(result.defaultFolderId).toBeTruthy();
|
||||
|
||||
const folder = await Folder.load(result.defaultFolderId);
|
||||
expect(folder).toBeTruthy();
|
||||
|
||||
const icon = Folder.unserializeIcon(folder.icon);
|
||||
expect(icon.type).toBe(FolderIconType.Emoji);
|
||||
expect(icon.emoji).toBe('👋');
|
||||
});
|
||||
|
||||
});
|
||||
@@ -7,7 +7,6 @@ import uuid from './uuid';
|
||||
import { fileExtension, basename } from './path-utils';
|
||||
import { _ } from './locale';
|
||||
const { pregQuote } = require('./string-utils');
|
||||
import { FolderIconType } from './services/database/types';
|
||||
|
||||
export interface ItemMetadatum {
|
||||
id: string;
|
||||
@@ -61,16 +60,7 @@ class WelcomeUtils {
|
||||
|
||||
// Actually we don't really support multiple folders at this point, because not needed
|
||||
for (let i = 0; i < folderAssets.length; i++) {
|
||||
const folderIcon = {
|
||||
emoji: '👋',
|
||||
name: '',
|
||||
dataUrl: '',
|
||||
type: FolderIconType.Emoji,
|
||||
};
|
||||
const folder = await Folder.save({
|
||||
title: _('Welcome!'),
|
||||
icon: Folder.serializeIcon(folderIcon),
|
||||
});
|
||||
const folder = await Folder.save({ title: _('Welcome!') });
|
||||
if (!output.defaultFolderId) output.defaultFolderId = folder.id;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,41 +0,0 @@
|
||||
import { _ } from '../../../../locale';
|
||||
import Setting from '../../../../models/Setting';
|
||||
import shim from '../../../../shim';
|
||||
|
||||
interface Props {
|
||||
editorMigrationVersion: number;
|
||||
markdownEditorEnabled: boolean;
|
||||
inEditorRenderingEnabled: boolean;
|
||||
}
|
||||
|
||||
const useEditorTypeMigrationBanner = ({ markdownEditorEnabled, inEditorRenderingEnabled, editorMigrationVersion }: Props) => {
|
||||
const React = shim.react();
|
||||
const enabled = markdownEditorEnabled && editorMigrationVersion < 1 && inEditorRenderingEnabled;
|
||||
|
||||
return React.useMemo(() => {
|
||||
const onMigrationComplete = () => {
|
||||
Setting.setValue('editor.migration', 1);
|
||||
};
|
||||
|
||||
return {
|
||||
enabled,
|
||||
label: _('Certain Markdown formatting is now rendered in the editor by default. For example, **bold** will appear as actual bold text. You can change this behaviour in the "Editor" section of Settings.'),
|
||||
disable: {
|
||||
label: _('Disable it'),
|
||||
onPress: () => {
|
||||
Setting.setValue('editor.inlineRendering', false);
|
||||
Setting.setValue('editor.imageRendering', false);
|
||||
onMigrationComplete();
|
||||
},
|
||||
},
|
||||
keepEnabled: {
|
||||
label: _('Keep it enabled'),
|
||||
onPress: async () => {
|
||||
onMigrationComplete();
|
||||
},
|
||||
},
|
||||
};
|
||||
}, [enabled]);
|
||||
};
|
||||
|
||||
export default useEditorTypeMigrationBanner;
|
||||
@@ -29,7 +29,6 @@ export interface Props {
|
||||
noteId: string;
|
||||
folders: FolderEntity[];
|
||||
sharedData: SharedData|undefined;
|
||||
noteVisiblePanes: string[];
|
||||
}
|
||||
|
||||
export interface BaseState {
|
||||
@@ -294,8 +293,7 @@ shared.reloadNote = async (comp: BaseNoteScreenComponent) => {
|
||||
|
||||
const note = await Note.load(comp.props.noteId);
|
||||
|
||||
const panes = comp.props.noteVisiblePanes;
|
||||
let mode = panes.includes('editor') ? 'edit' : 'view';
|
||||
let mode = 'view';
|
||||
|
||||
if (isProvisionalNote && !comp.props.sharedData) {
|
||||
mode = 'edit';
|
||||
|
||||
@@ -10,7 +10,6 @@ import BaseItem from '../../models/BaseItem';
|
||||
import shim from '../../shim';
|
||||
import { Dispatch } from 'redux';
|
||||
import { State } from '../../reducer';
|
||||
import { onRevisionServiceSettingsChanged } from '../../services/synchronizer/syncInfoUtils';
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
|
||||
let sortNoteListTimeout: any = null;
|
||||
@@ -32,10 +31,6 @@ export default async (store: any, _next: any, action: any, dispatch: Dispatch) =
|
||||
reg.resetSyncTarget();
|
||||
}
|
||||
|
||||
if (action.type === 'SETTING_UPDATE_ONE') {
|
||||
onRevisionServiceSettingsChanged(action.key, action.value);
|
||||
}
|
||||
|
||||
let mustAutoAddResources = false;
|
||||
|
||||
if (action.type === 'SETTING_UPDATE_ONE' && action.key === 'sync.resourceDownloadMode') {
|
||||
|
||||
@@ -247,13 +247,4 @@ describe('import-enex-md-gen', () => {
|
||||
expect(note4.body).toBe('[Note 5](https://joplinapp.org)');
|
||||
});
|
||||
|
||||
it('should remove empty hidden divs from imported notes', async () => {
|
||||
const empty = await enexXmlToMd('<div style="display:none;--en-chs:"metadata""> </div><div>Test content</div>', [], []);
|
||||
const withContent = await enexXmlToMd('<div style="display:none;">Important data</div><div>Visible text</div>', [], []);
|
||||
|
||||
expect(empty).not.toContain('<div style="display: none;">');
|
||||
expect(empty).toContain('Test content');
|
||||
expect(withContent).toContain('<div style="display: none;">');
|
||||
expect(withContent).toContain('Important data');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1435,12 +1435,8 @@ function renderLine(line: any) {
|
||||
// ENEX notes sometimes have hidden tags. We could strip off these
|
||||
// sections but in the spirit of preserving all data we wrap them in
|
||||
// a hidden tag too.
|
||||
const rendered = renderLines(line.lines);
|
||||
const content = rendered.join('').replace(/\[\[NEWLINE\]\]|\[\[BLOCK_OPEN\]\]|\[\[BLOCK_CLOSE\]\]|\[\[SPACE\]\]|\[\[MERGED\]\]/g, '').replace(/\s+/g, '');
|
||||
if (!content) return [];
|
||||
|
||||
let hiddenLines = ['<div style="display: none;">'];
|
||||
hiddenLines = hiddenLines.concat(rendered);
|
||||
hiddenLines = hiddenLines.concat(renderLines(line.lines));
|
||||
hiddenLines.push('</div>');
|
||||
|
||||
// We need to add two new lines after the HTML block, or the Markdown
|
||||
|
||||
@@ -27,12 +27,11 @@ import isSqliteSyntaxError from '../services/database/isSqliteSyntaxError';
|
||||
import { internalUrl, isResourceUrl, isSupportedImageMimeType, resourceFilename, resourceFullPath, resourcePathToId, resourceRelativePath, resourceUrlToId } from './utils/resourceUtils';
|
||||
|
||||
export const resourceOcrStatusToString = (status: ResourceOcrStatus) => {
|
||||
const s: Record<ResourceOcrStatus, string> = {
|
||||
const s = {
|
||||
[ResourceOcrStatus.Todo]: _('Idle'),
|
||||
[ResourceOcrStatus.Processing]: _('Processing'),
|
||||
[ResourceOcrStatus.Error]: _('Error'),
|
||||
[ResourceOcrStatus.Done]: _('Done'),
|
||||
[ResourceOcrStatus.TodoAccessible]: _('Idle'),
|
||||
};
|
||||
|
||||
return s[status];
|
||||
@@ -526,14 +525,13 @@ export default class Resource extends BaseItem {
|
||||
SELECT ${selectSql}
|
||||
FROM resources
|
||||
WHERE
|
||||
(ocr_status = ? OR ocr_status = ? OR ocr_status = ?) AND
|
||||
(ocr_status = ? or ocr_status = ?) AND
|
||||
encryption_applied = 0 AND
|
||||
mime IN ('${supportedMimeTypes.join('\',\'')}')
|
||||
`,
|
||||
params: [
|
||||
ResourceOcrStatus.Todo,
|
||||
ResourceOcrStatus.Processing,
|
||||
ResourceOcrStatus.TodoAccessible,
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
@@ -208,7 +208,6 @@ describe('models/Setting', () => {
|
||||
expect(Setting.sectionNameToLabel('mySection')).toBe('My section');
|
||||
}));
|
||||
|
||||
|
||||
it('should save and load settings from file', (async () => {
|
||||
Setting.setValue('sync.target', 9); // Saved to file
|
||||
Setting.setValue('encryption.passwordCache', {}); // Saved to keychain or db
|
||||
|
||||
@@ -1291,7 +1291,6 @@ class Setting extends BaseModel {
|
||||
'sync',
|
||||
'encryption',
|
||||
'joplinCloud',
|
||||
'editor',
|
||||
'plugins',
|
||||
'markdownPlugins',
|
||||
'note',
|
||||
@@ -1353,7 +1352,6 @@ class Setting extends BaseModel {
|
||||
if (name === 'general') return _('General');
|
||||
if (name === 'sync') return _('Synchronisation');
|
||||
if (name === 'appearance') return _('Appearance');
|
||||
if (name === 'editor') return _('Editor');
|
||||
if (name === 'note') return _('Note');
|
||||
if (name === 'folder') return _('Notebook');
|
||||
if (name === 'markdownPlugins') return _('Markdown');
|
||||
@@ -1390,12 +1388,11 @@ class Setting extends BaseModel {
|
||||
// TODO: This is currently specific to the mobile app
|
||||
const sectionNameToSummary: Record<string, string> = {
|
||||
'general': _('Language, date format'),
|
||||
'appearance': _('Themes'),
|
||||
'appearance': _('Themes, editor font'),
|
||||
'sync': _('Sync, encryption, proxy'),
|
||||
'joplinCloud': _('Email To Note, login information'),
|
||||
'editor': _('Typography, spellcheck, layout'),
|
||||
'markdownPlugins': _('Media player, math, diagrams, table of contents'),
|
||||
'note': _('Geolocation, image resize'),
|
||||
'note': _('Geolocation, spellcheck, editor toolbar, image resize'),
|
||||
'revisionService': _('Toggle note history, keep notes for'),
|
||||
'tools': _('Logs, profiles, sync status'),
|
||||
'importOrExport': _('Import or export your data'),
|
||||
@@ -1429,7 +1426,6 @@ class Setting extends BaseModel {
|
||||
'general': 'icon-general',
|
||||
'sync': 'icon-sync',
|
||||
'appearance': 'icon-appearance',
|
||||
'editor': 'fas fa-edit',
|
||||
'note': 'icon-note',
|
||||
'folder': 'icon-notebooks',
|
||||
'plugins': 'icon-plugins',
|
||||
@@ -1454,7 +1450,6 @@ class Setting extends BaseModel {
|
||||
'general': 'fa fa-sliders-h',
|
||||
'sync': 'fa fa-sync',
|
||||
'appearance': 'fa fa-ruler',
|
||||
'editor': 'fas fa-pen',
|
||||
'note': 'fa fa-sticky-note',
|
||||
'revisionService': 'far fa-history',
|
||||
'plugins': 'fa fa-puzzle-piece',
|
||||
|
||||
@@ -95,7 +95,6 @@ const builtInMetadata = (Setting: typeof SettingType) => {
|
||||
value: true,
|
||||
type: SettingItemType.Bool,
|
||||
public: false,
|
||||
section: 'editor',
|
||||
appTypes: [AppType.Desktop, AppType.Mobile],
|
||||
storage: SettingStorage.File,
|
||||
isGlobal: true,
|
||||
@@ -574,28 +573,12 @@ const builtInMetadata = (Setting: typeof SettingType) => {
|
||||
type: SettingItemType.Bool,
|
||||
public: true,
|
||||
appTypes: [AppType.Desktop],
|
||||
label: () => _('OCR: Enable optical character recognition'),
|
||||
label: () => _('Enable optical character recognition (OCR)'),
|
||||
description: () => _('When enabled, the application will scan your attachments and extract the text from it. This will allow you to search for text in these attachments.'),
|
||||
storage: SettingStorage.File,
|
||||
isGlobal: true,
|
||||
},
|
||||
|
||||
'ocr.pdfMode': {
|
||||
value: 'normal',
|
||||
type: SettingItemType.String,
|
||||
isEnum: true,
|
||||
public: true,
|
||||
appTypes: [AppType.Desktop],
|
||||
label: () => _('OCR: PDF processing mode'),
|
||||
description: () => _('Accessible mode saves additional information, enabling creation of accessible PDFs. It increases database size by approximately 10-20 KB per page.'),
|
||||
options: () => ({
|
||||
normal: _('Normal'),
|
||||
accessible: _('Accessible'),
|
||||
}),
|
||||
storage: SettingStorage.File,
|
||||
isGlobal: true,
|
||||
},
|
||||
|
||||
'ocr.handwrittenTextDriverEnabled': {
|
||||
value: false,
|
||||
type: SettingItemType.Bool,
|
||||
@@ -755,7 +738,7 @@ const builtInMetadata = (Setting: typeof SettingType) => {
|
||||
value: true,
|
||||
type: SettingItemType.Bool,
|
||||
public: true,
|
||||
section: 'editor',
|
||||
section: 'note',
|
||||
appTypes: [AppType.Desktop],
|
||||
label: () => _('Auto-pair braces, parentheses, quotations, etc.'),
|
||||
storage: SettingStorage.File,
|
||||
@@ -766,7 +749,7 @@ const builtInMetadata = (Setting: typeof SettingType) => {
|
||||
advanced: true,
|
||||
type: SettingItemType.Bool,
|
||||
public: true,
|
||||
section: 'editor',
|
||||
section: 'note',
|
||||
appTypes: [AppType.Desktop, AppType.Mobile],
|
||||
label: () => _('Autocomplete Markdown and HTML'),
|
||||
description: () => _('Enables Markdown list continuation, auto-closing HTML tags, and other markup autocompletions.'),
|
||||
@@ -778,10 +761,9 @@ const builtInMetadata = (Setting: typeof SettingType) => {
|
||||
advanced: true,
|
||||
type: SettingItemType.Bool,
|
||||
public: true,
|
||||
section: 'editor',
|
||||
section: 'note',
|
||||
appTypes: [AppType.Desktop],
|
||||
label: () => _('Enable HTML-to-Markdown conversion banner'),
|
||||
description: () => _('If enabled, opening an HTML note displays a prompt to convert the note to Markdown.'),
|
||||
storage: SettingStorage.File,
|
||||
isGlobal: true,
|
||||
},
|
||||
@@ -789,7 +771,7 @@ const builtInMetadata = (Setting: typeof SettingType) => {
|
||||
value: false,
|
||||
type: SettingItemType.Bool,
|
||||
public: true,
|
||||
section: 'editor',
|
||||
section: 'note',
|
||||
appTypes: [AppType.Desktop],
|
||||
label: () => _('Preserve colours when pasting text in Rich Text Editor'),
|
||||
storage: SettingStorage.File,
|
||||
@@ -799,7 +781,7 @@ const builtInMetadata = (Setting: typeof SettingType) => {
|
||||
value: true,
|
||||
type: SettingItemType.Bool,
|
||||
public: true,
|
||||
section: 'editor',
|
||||
section: 'note',
|
||||
appTypes: [AppType.Desktop],
|
||||
label: () => _('Auto-format Markdown in the Rich Text Editor'),
|
||||
description: () => _('Enables Markdown pattern replacement in the Rich Text Editor. For example, when enabled, typing **bold** creates bold text.'),
|
||||
@@ -819,7 +801,7 @@ const builtInMetadata = (Setting: typeof SettingType) => {
|
||||
value: false,
|
||||
type: SettingItemType.Bool,
|
||||
public: false,
|
||||
section: 'editor',
|
||||
section: 'note',
|
||||
appTypes: [AppType.Desktop],
|
||||
label: () => _('Tab moves focus'),
|
||||
storage: SettingStorage.File,
|
||||
@@ -927,7 +909,7 @@ const builtInMetadata = (Setting: typeof SettingType) => {
|
||||
'editor.usePlainText': {
|
||||
value: false,
|
||||
type: SettingItemType.Bool,
|
||||
section: 'editor',
|
||||
section: 'note',
|
||||
public: true,
|
||||
appTypes: [AppType.Mobile],
|
||||
label: () => 'Use the plain text editor',
|
||||
@@ -940,7 +922,7 @@ const builtInMetadata = (Setting: typeof SettingType) => {
|
||||
'editor.mobile.spellcheckEnabled': {
|
||||
value: true,
|
||||
type: SettingItemType.Bool,
|
||||
section: 'editor',
|
||||
section: 'note',
|
||||
public: true,
|
||||
appTypes: [AppType.Mobile],
|
||||
label: () => _('Enable spellcheck in the text editor'),
|
||||
@@ -951,7 +933,7 @@ const builtInMetadata = (Setting: typeof SettingType) => {
|
||||
'editor.mobile.toolbarEnabled': {
|
||||
value: true,
|
||||
type: SettingItemType.Bool,
|
||||
section: 'editor',
|
||||
section: 'note',
|
||||
public: true,
|
||||
appTypes: [AppType.Mobile],
|
||||
label: () => _('Enable the Markdown toolbar'),
|
||||
@@ -1235,7 +1217,7 @@ const builtInMetadata = (Setting: typeof SettingType) => {
|
||||
storage: SettingStorage.File,
|
||||
isGlobal: true,
|
||||
appTypes: [AppType.Desktop, AppType.Mobile],
|
||||
section: 'editor',
|
||||
section: 'appearance',
|
||||
label: () => _('Editor font size'),
|
||||
minimum: 4,
|
||||
maximum: 50,
|
||||
@@ -1250,7 +1232,7 @@ const builtInMetadata = (Setting: typeof SettingType) => {
|
||||
public: true,
|
||||
label: () => _('Editor font'),
|
||||
appTypes: [AppType.Mobile],
|
||||
section: 'editor',
|
||||
section: 'appearance',
|
||||
options: () => {
|
||||
// IMPORTANT: The font mapping must match the one in global-styles.js::editorFont()
|
||||
if (mobilePlatform === 'ios') {
|
||||
@@ -1273,7 +1255,7 @@ const builtInMetadata = (Setting: typeof SettingType) => {
|
||||
type: SettingItemType.String,
|
||||
public: true,
|
||||
appTypes: [AppType.Desktop],
|
||||
section: 'editor',
|
||||
section: 'appearance',
|
||||
label: () => _('Editor font family'),
|
||||
description: () =>
|
||||
_('Used for most text in the markdown editor. If not found, a generic proportional (variable width) font is used.'),
|
||||
@@ -1286,7 +1268,7 @@ const builtInMetadata = (Setting: typeof SettingType) => {
|
||||
type: SettingItemType.String,
|
||||
public: true,
|
||||
appTypes: [AppType.Desktop],
|
||||
section: 'editor',
|
||||
section: 'appearance',
|
||||
label: () => _('Editor monospace font family'),
|
||||
description: () =>
|
||||
_('Used where a fixed width font is needed to lay out text legibly (e.g. tables, checkboxes, code). If not found, a generic monospace (fixed width) font is used.'),
|
||||
@@ -1306,7 +1288,7 @@ const builtInMetadata = (Setting: typeof SettingType) => {
|
||||
subType: SettingItemSubType.FontFamily,
|
||||
},
|
||||
|
||||
'style.editor.contentMaxWidth': { value: 0, type: SettingItemType.Int, public: true, storage: SettingStorage.File, isGlobal: true, appTypes: [AppType.Desktop], section: 'editor', label: () => _('Editor maximum width'), description: () => _('Set it to 0 to make it take the complete available space. Recommended width is 600.') },
|
||||
'style.editor.contentMaxWidth': { value: 0, type: SettingItemType.Int, public: true, storage: SettingStorage.File, isGlobal: true, appTypes: [AppType.Desktop], section: 'appearance', label: () => _('Editor maximum width'), description: () => _('Set it to 0 to make it take the complete available space. Recommended width is 600.') },
|
||||
|
||||
'style.scrollbarSize': {
|
||||
value: ScrollbarSize.Small,
|
||||
@@ -1494,7 +1476,6 @@ const builtInMetadata = (Setting: typeof SettingType) => {
|
||||
value: '',
|
||||
type: SettingItemType.String,
|
||||
public: true,
|
||||
section: 'editor',
|
||||
appTypes: [AppType.Desktop],
|
||||
isEnum: true,
|
||||
advanced: true,
|
||||
@@ -1515,7 +1496,6 @@ const builtInMetadata = (Setting: typeof SettingType) => {
|
||||
value: false,
|
||||
type: SettingItemType.Bool,
|
||||
public: true,
|
||||
section: 'editor',
|
||||
appTypes: [AppType.Desktop],
|
||||
label: () => _('Enable spell checking in Markdown editor'),
|
||||
storage: SettingStorage.File,
|
||||
@@ -1523,30 +1503,30 @@ const builtInMetadata = (Setting: typeof SettingType) => {
|
||||
},
|
||||
|
||||
'editor.inlineRendering': {
|
||||
value: true,
|
||||
value: false,
|
||||
type: SettingItemType.Bool,
|
||||
public: true,
|
||||
appTypes: [AppType.Desktop, AppType.Mobile],
|
||||
label: () => _('Markdown editor: Render markup in editor'),
|
||||
description: () => _('Renders markup on all lines that don\'t include the cursor.'),
|
||||
section: 'editor',
|
||||
section: 'note',
|
||||
storage: SettingStorage.File,
|
||||
},
|
||||
'editor.imageRendering': {
|
||||
value: true,
|
||||
value: false,
|
||||
type: SettingItemType.Bool,
|
||||
public: true,
|
||||
appTypes: [AppType.Desktop, AppType.Mobile],
|
||||
label: () => _('Markdown editor: Render images'),
|
||||
description: () => _('If an image attachment is on its own line and followed by a blank line, it will be rendered just below its Markdown source.'),
|
||||
section: 'editor',
|
||||
section: 'note',
|
||||
storage: SettingStorage.File,
|
||||
},
|
||||
'editor.highlightActiveLine': {
|
||||
value: false,
|
||||
type: SettingItemType.Bool,
|
||||
public: true,
|
||||
section: 'editor',
|
||||
section: 'note',
|
||||
appTypes: [AppType.Desktop, AppType.Mobile],
|
||||
label: () => _('Markdown editor: Highlight active line'),
|
||||
storage: SettingStorage.File,
|
||||
@@ -1576,7 +1556,7 @@ const builtInMetadata = (Setting: typeof SettingType) => {
|
||||
'editor.beta': {
|
||||
value: false,
|
||||
type: SettingItemType.Bool,
|
||||
section: 'editor',
|
||||
section: 'general',
|
||||
public: false,
|
||||
appTypes: [AppType.Desktop],
|
||||
label: () => 'Opt-in to the editor beta',
|
||||
@@ -1589,7 +1569,7 @@ const builtInMetadata = (Setting: typeof SettingType) => {
|
||||
advanced: true,
|
||||
value: false,
|
||||
type: SettingItemType.Bool,
|
||||
section: 'editor',
|
||||
section: 'general',
|
||||
public: true,
|
||||
appTypes: [AppType.Desktop],
|
||||
label: () => _('Use the legacy Markdown editor'),
|
||||
@@ -1598,13 +1578,6 @@ const builtInMetadata = (Setting: typeof SettingType) => {
|
||||
isGlobal: true,
|
||||
},
|
||||
|
||||
// Used to keep track of editor setting migrations that require prompting the user.
|
||||
'editor.migration': {
|
||||
public: false,
|
||||
value: 0,
|
||||
type: SettingItemType.Int,
|
||||
},
|
||||
|
||||
'linking.extraAllowedExtensions': {
|
||||
value: [] as string[],
|
||||
type: SettingItemType.Array,
|
||||
@@ -1750,9 +1723,9 @@ const builtInMetadata = (Setting: typeof SettingType) => {
|
||||
'camera.type': { value: CameraDirection.Back, type: SettingItemType.Int, public: false, appTypes: [AppType.Mobile] },
|
||||
'camera.ratio': { value: '4:3', type: SettingItemType.String, public: false, appTypes: [AppType.Mobile] },
|
||||
|
||||
'spellChecker.enabled': { value: true, type: SettingItemType.Bool, section: 'editor', isGlobal: true, storage: SettingStorage.File, public: false },
|
||||
'spellChecker.language': { value: '', type: SettingItemType.String, section: 'editor', isGlobal: true, storage: SettingStorage.File, public: false }, // Depreciated in favour of spellChecker.languages.
|
||||
'spellChecker.languages': { value: [] as string[], type: SettingItemType.Array, section: 'editor', isGlobal: true, storage: SettingStorage.File, public: false },
|
||||
'spellChecker.enabled': { value: true, type: SettingItemType.Bool, isGlobal: true, storage: SettingStorage.File, public: false },
|
||||
'spellChecker.language': { value: '', type: SettingItemType.String, isGlobal: true, storage: SettingStorage.File, public: false }, // Depreciated in favour of spellChecker.languages.
|
||||
'spellChecker.languages': { value: [] as string[], type: SettingItemType.Array, isGlobal: true, storage: SettingStorage.File, public: false },
|
||||
|
||||
windowContentZoomFactor: {
|
||||
value: 100,
|
||||
@@ -1967,8 +1940,7 @@ const builtInMetadata = (Setting: typeof SettingType) => {
|
||||
},
|
||||
|
||||
'survey.webClientEval2025.progress': {
|
||||
// Ended in February 2026. See https://github.com/laurent22/joplin/pull/14497.
|
||||
value: SurveyProgress.Dismissed,
|
||||
value: SurveyProgress.NotStarted,
|
||||
type: SettingItemType.Int,
|
||||
public: false,
|
||||
isEnum: true,
|
||||
@@ -2078,17 +2050,6 @@ const builtInMetadata = (Setting: typeof SettingType) => {
|
||||
storage: SettingStorage.File,
|
||||
isGlobal: false,
|
||||
},
|
||||
|
||||
'notes.showNoteLinkIcon': {
|
||||
value: true,
|
||||
type: SettingItemType.Bool,
|
||||
storage: SettingStorage.File,
|
||||
section: 'note',
|
||||
public: true,
|
||||
isGlobal: true,
|
||||
label: () => _('Show Joplin icon for note links'),
|
||||
appTypes: [AppType.Desktop, AppType.Mobile],
|
||||
},
|
||||
} satisfies Record<string, SettingItem>;
|
||||
|
||||
for (const [key, md] of Object.entries(output)) {
|
||||
|
||||
@@ -86,7 +86,6 @@
|
||||
"node-notifier": "10.0.1",
|
||||
"node-persist": "3.1.3",
|
||||
"node-rsa": "1.1.1",
|
||||
"pdf-lib": "1.17.1",
|
||||
"query-string": "7.1.3",
|
||||
"re-reselect": "4.0.1",
|
||||
"redux": "4.2.1",
|
||||
@@ -97,7 +96,7 @@
|
||||
"sqlite3": "5.1.6",
|
||||
"string-padding": "1.0.2",
|
||||
"string-to-stream": "3.0.1",
|
||||
"tar": "7.5.8",
|
||||
"tar": "6.2.1",
|
||||
"tcp-port-used": "1.0.2",
|
||||
"uglifycss": "0.0.29",
|
||||
"url-parse": "1.5.10",
|
||||
|
||||
@@ -1,26 +0,0 @@
|
||||
import { setupDatabaseAndSynchronizer, switchClient, decryptionWorker } from '../testing/test-utils';
|
||||
|
||||
describe('services/DecryptionWorker', () => {
|
||||
|
||||
beforeEach(async () => {
|
||||
await setupDatabaseAndSynchronizer(1);
|
||||
await switchClient(1);
|
||||
});
|
||||
|
||||
it('should not return null when a call to .start is cancelled', async () => {
|
||||
const worker = decryptionWorker();
|
||||
|
||||
// Both calls should return a valid DecryptionResult, even if the
|
||||
// queue skips one task due to concurrency.
|
||||
const results = await Promise.all([
|
||||
worker.start(),
|
||||
worker.start(),
|
||||
]);
|
||||
|
||||
for (const result of results) {
|
||||
expect(result === null).toBe(false);
|
||||
expect(result === undefined).toBe(false);
|
||||
expect(result).toHaveProperty('error');
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -349,15 +349,6 @@ export default class DecryptionWorker {
|
||||
if (lastError) {
|
||||
throw lastError;
|
||||
}
|
||||
|
||||
// If this task was skipped due to a concurrent start() call, return an empty
|
||||
// DecryptionResult instead of null. AsyncActionQueue drops earlier tasks when
|
||||
// multiple are queued, but start() guarantees Promise<DecryptionResult> and
|
||||
// must not resolve null.
|
||||
if (!output) {
|
||||
return { error: null, decryptedItemCount: 0, skippedItemCount: 0 };
|
||||
}
|
||||
|
||||
return output;
|
||||
}
|
||||
|
||||
|
||||
@@ -60,7 +60,6 @@ export enum ResourceOcrStatus {
|
||||
Processing = 1,
|
||||
Done = 2,
|
||||
Error = 3,
|
||||
TodoAccessible = 4, // Like Todo, but requests full OCR details for accessible PDF creation
|
||||
}
|
||||
|
||||
export type UserData = Record<string, Record<string, UserDataValue>>;
|
||||
|
||||
@@ -5,9 +5,6 @@ import { ResourceEntity, ResourceOcrDriverId, ResourceOcrStatus } from '../datab
|
||||
import { msleep } from '@joplin/utils/time';
|
||||
import Logger from '@joplin/utils/Logger';
|
||||
import Setting from '../../models/Setting';
|
||||
import createAccessiblePdf from './utils/createAccessiblePdf';
|
||||
import { PdfOcrDetails } from './utils/types';
|
||||
import * as fs from 'fs-extra';
|
||||
|
||||
describe('OcrService', () => {
|
||||
|
||||
@@ -336,58 +333,4 @@ describe('OcrService', () => {
|
||||
|
||||
await service.dispose();
|
||||
});
|
||||
|
||||
it('should throw error for unsupported OCR details version', async () => {
|
||||
const ocrDetails = {
|
||||
version: 999,
|
||||
pages: [] as PdfOcrDetails['pages'],
|
||||
};
|
||||
|
||||
await expect(createAccessiblePdf([], JSON.stringify(ocrDetails)))
|
||||
.rejects.toThrow('Unsupported PDF OCR details version: 999');
|
||||
});
|
||||
|
||||
it('should throw error for page count mismatch', async () => {
|
||||
const ocrDetails: PdfOcrDetails = {
|
||||
version: 1,
|
||||
pages: [{ lines: [] }],
|
||||
};
|
||||
|
||||
await expect(createAccessiblePdf([], JSON.stringify(ocrDetails)))
|
||||
.rejects.toThrow('Page count mismatch: 0 images vs 1 OCR pages');
|
||||
});
|
||||
|
||||
it('should create a multi-page PDF', async () => {
|
||||
const jpegBuffer = await fs.readFile(`${supportDir}/photo.jpg`);
|
||||
|
||||
const ocrDetails: PdfOcrDetails = {
|
||||
version: 1,
|
||||
pages: [
|
||||
{
|
||||
lines: [{ words: [{ t: 'Page1', bb: [10, 60, 10, 30] }] }],
|
||||
},
|
||||
{
|
||||
lines: [{ words: [{ t: 'Page2', bb: [10, 60, 10, 30] }] }],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const pdfBytes = await createAccessiblePdf(
|
||||
[
|
||||
{ buffer: jpegBuffer, width: 200, height: 200 },
|
||||
{ buffer: jpegBuffer, width: 200, height: 200 },
|
||||
],
|
||||
JSON.stringify(ocrDetails),
|
||||
);
|
||||
|
||||
expect(pdfBytes).toBeInstanceOf(Uint8Array);
|
||||
const pdfContent = new TextDecoder().decode(pdfBytes);
|
||||
expect(pdfContent.startsWith('%PDF-')).toBe(true);
|
||||
expect(pdfContent).toContain('%%EOF');
|
||||
|
||||
// Multi-page PDF should be roughly twice the size of single page
|
||||
// (minus some overhead for shared resources like fonts)
|
||||
expect(pdfBytes.length).toBeGreaterThan(jpegBuffer.length * 1.5);
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
@@ -4,7 +4,7 @@ import Setting from '../../models/Setting';
|
||||
import shim from '../../shim';
|
||||
import { ResourceEntity, ResourceOcrDriverId, ResourceOcrStatus } from '../database/types';
|
||||
import OcrDriverBase from './OcrDriverBase';
|
||||
import { emptyRecognizeResult, PdfOcrDetails, PdfOcrPage, RecognizeResult, RecognizeResultLine } from './utils/types';
|
||||
import { emptyRecognizeResult, RecognizeResult } from './utils/types';
|
||||
import { Minute } from '@joplin/utils/time';
|
||||
import Logger from '@joplin/utils/Logger';
|
||||
import TaskQueue from '../../TaskQueue';
|
||||
@@ -82,73 +82,37 @@ export default class OcrService {
|
||||
if (!driver) throw new Error(`Unknown driver ID: ${resource.ocr_driver_id}`);
|
||||
|
||||
if (resource.mime === 'application/pdf') {
|
||||
// Save OCR details if the setting is enabled OR if this specific resource
|
||||
// was marked with TodoAccessible status (requesting accessible PDF creation)
|
||||
const saveOcrDetails = Setting.value('ocr.pdfMode') === 'accessible' || resource.ocr_status === ResourceOcrStatus.TodoAccessible;
|
||||
|
||||
// OCR can be slow for large PDFs.
|
||||
// Skip it if the PDF already includes text (unless accessible processing is requested)
|
||||
if (!saveOcrDetails) {
|
||||
const pageTexts = await shim.pdfExtractEmbeddedText(resourceFilePath);
|
||||
const pagesWithText = pageTexts.filter(text => !!text.trim().length);
|
||||
// Skip it if the PDF already includes text.
|
||||
const pageTexts = await shim.pdfExtractEmbeddedText(resourceFilePath);
|
||||
const pagesWithText = pageTexts.filter(text => !!text.trim().length);
|
||||
|
||||
if (pagesWithText.length > 0) {
|
||||
return {
|
||||
...emptyRecognizeResult(),
|
||||
ocr_status: ResourceOcrStatus.Done,
|
||||
ocr_text: pageTexts.join('\n'),
|
||||
};
|
||||
}
|
||||
if (pagesWithText.length > 0) {
|
||||
return {
|
||||
...emptyRecognizeResult(),
|
||||
ocr_status: ResourceOcrStatus.Done,
|
||||
ocr_text: pageTexts.join('\n'),
|
||||
};
|
||||
}
|
||||
|
||||
const imageFilePaths = await shim.pdfToImages(resourceFilePath, await this.pdfExtractDir());
|
||||
|
||||
const results: RecognizeResult[] = [];
|
||||
const pdfOcrPages: PdfOcrPage[] = [];
|
||||
|
||||
try {
|
||||
let pageIndex = 0;
|
||||
for (const imagePath of imageFilePaths) {
|
||||
logger.info(`Recognize: ${resourceInfo(resource)}: Processing PDF page ${pageIndex + 1} / ${imageFilePaths.length}...`);
|
||||
const result = await driver.recognize(language, imagePath, resource.id);
|
||||
results.push(result);
|
||||
|
||||
if (saveOcrDetails) {
|
||||
// Parse OCR details for this page
|
||||
let pageLines: RecognizeResultLine[] = [];
|
||||
try {
|
||||
pageLines = Resource.unserializeOcrDetails(result.ocr_details) || [];
|
||||
} catch (error) {
|
||||
logger.warn(`Failed to parse OCR details for page ${pageIndex + 1}: ${error.message}`);
|
||||
}
|
||||
pdfOcrPages.push({
|
||||
lines: pageLines,
|
||||
});
|
||||
}
|
||||
|
||||
pageIndex++;
|
||||
}
|
||||
} finally {
|
||||
for (const imagePath of imageFilePaths) {
|
||||
await shim.fsDriver().remove(imagePath);
|
||||
}
|
||||
let pageIndex = 0;
|
||||
for (const imageFilePath of imageFilePaths) {
|
||||
logger.info(`Recognize: ${resourceInfo(resource)}: Processing PDF page ${pageIndex + 1} / ${imageFilePaths.length}...`);
|
||||
results.push(await driver.recognize(language, imageFilePath, resource.id));
|
||||
pageIndex++;
|
||||
}
|
||||
|
||||
// Only create PDF OCR details structure if setting is enabled
|
||||
let ocrDetails = '';
|
||||
if (saveOcrDetails) {
|
||||
const pdfOcrDetails: PdfOcrDetails = {
|
||||
version: 1,
|
||||
pages: pdfOcrPages,
|
||||
};
|
||||
ocrDetails = JSON.stringify(pdfOcrDetails);
|
||||
for (const imageFilePath of imageFilePaths) {
|
||||
await shim.fsDriver().remove(imageFilePath);
|
||||
}
|
||||
|
||||
return {
|
||||
...emptyRecognizeResult(),
|
||||
ocr_status: ResourceOcrStatus.Done,
|
||||
ocr_text: results.map(r => r.ocr_text).join('\n'),
|
||||
ocr_details: ocrDetails,
|
||||
};
|
||||
} else {
|
||||
return driver.recognize(language, resourceFilePath, resource.id);
|
||||
@@ -228,7 +192,6 @@ export default class OcrService {
|
||||
'file_extension',
|
||||
'encryption_applied',
|
||||
'ocr_driver_id',
|
||||
'ocr_status',
|
||||
],
|
||||
});
|
||||
|
||||
|
||||
@@ -108,12 +108,6 @@ export default class OcrDriverTesseract extends OcrDriverBase {
|
||||
|
||||
const createWorkerOptions: Partial<WorkerOptions> = {
|
||||
workerBlobURL: false,
|
||||
|
||||
// Sometimes Tesseract stops working (especially in dev mode?) as the worker is stuck
|
||||
// loading the language file. In that case, setting the cacheMode to "none" fixes the
|
||||
// issue.
|
||||
|
||||
// cacheMethod: 'none',
|
||||
};
|
||||
|
||||
if (this.workerPath_) createWorkerOptions.workerPath = this.workerPath_;
|
||||
|
||||
@@ -1,113 +0,0 @@
|
||||
import { PDFDocument, PDFFont, PDFPage, rgb, StandardFonts } from 'pdf-lib';
|
||||
import { PdfOcrDetails, RecognizeResultLine } from './types';
|
||||
|
||||
// The PDF OCR images are created at 2x scale by pdfToImages()
|
||||
const OCR_SCALE_FACTOR = 2;
|
||||
|
||||
export interface PageImageWithDimensions {
|
||||
buffer: Buffer;
|
||||
width: number;
|
||||
height: number;
|
||||
}
|
||||
|
||||
// Adds an invisible text layer to a PDF page based on OCR word positions.
|
||||
// The text is rendered in "invisible" mode (render mode 3) so it doesn't
|
||||
// appear visually but can be selected, copied, and read by screen readers.
|
||||
const addInvisibleTextLayer = (
|
||||
page: PDFPage,
|
||||
lines: RecognizeResultLine[],
|
||||
font: PDFFont,
|
||||
_pageWidth: number,
|
||||
pageHeight: number,
|
||||
): void => {
|
||||
for (const line of lines) {
|
||||
for (const word of line.words) {
|
||||
const text = word.t;
|
||||
if (!text || !text.trim()) continue;
|
||||
|
||||
// Bounding box format from Tesseract: [x0, x1, y0, y1]
|
||||
const [x0, , y0, y1] = word.bb;
|
||||
|
||||
// Convert from OCR coordinates (2x scale, origin top-left)
|
||||
// to PDF coordinates (1x scale, origin bottom-left)
|
||||
const pdfX = x0 / OCR_SCALE_FACTOR;
|
||||
const pdfY = pageHeight - (y1 / OCR_SCALE_FACTOR); // Flip Y axis and use bottom of bbox
|
||||
|
||||
// Calculate word height in PDF coordinates (width not currently used)
|
||||
const wordHeight = (y1 - y0) / OCR_SCALE_FACTOR;
|
||||
|
||||
// Estimate font size based on word height
|
||||
// We want the text to fit within the bounding box
|
||||
const fontSize = Math.max(1, wordHeight * 0.85);
|
||||
|
||||
// Draw the text as invisible (using transparent color)
|
||||
// PDF.js and screen readers can still detect and read this text
|
||||
page.drawText(text, {
|
||||
x: pdfX,
|
||||
y: pdfY,
|
||||
size: fontSize,
|
||||
font: font,
|
||||
color: rgb(0, 0, 0),
|
||||
opacity: 0, // Make text invisible
|
||||
// Note: pdf-lib doesn't directly support render mode 3 (invisible),
|
||||
// but opacity: 0 achieves the same visual effect while keeping
|
||||
// the text selectable
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Creates an accessible PDF by overlaying invisible text on top of page images.
|
||||
// The text positions are derived from OCR bounding boxes, allowing the PDF to be
|
||||
// searched and read by screen readers while maintaining the visual appearance.
|
||||
// Page dimensions are provided separately (from pdfToImagesWithDimensions) rather
|
||||
// than stored in OCR details, to keep storage size smaller.
|
||||
const createAccessiblePdf = async (
|
||||
pageImages: PageImageWithDimensions[],
|
||||
ocrDetailsJson: string,
|
||||
): Promise<Uint8Array> => {
|
||||
const ocrDetails: PdfOcrDetails = JSON.parse(ocrDetailsJson);
|
||||
|
||||
if (ocrDetails.version !== 1) {
|
||||
throw new Error(`Unsupported PDF OCR details version: ${ocrDetails.version}`);
|
||||
}
|
||||
|
||||
if (pageImages.length !== ocrDetails.pages.length) {
|
||||
throw new Error(`Page count mismatch: ${pageImages.length} images vs ${ocrDetails.pages.length} OCR pages`);
|
||||
}
|
||||
|
||||
const pdfDoc = await PDFDocument.create();
|
||||
|
||||
// Embed a standard font for the invisible text layer
|
||||
const font = await pdfDoc.embedFont(StandardFonts.Helvetica);
|
||||
|
||||
for (let pageIndex = 0; pageIndex < pageImages.length; pageIndex++) {
|
||||
const pageImage = pageImages[pageIndex];
|
||||
const pageOcr = ocrDetails.pages[pageIndex];
|
||||
|
||||
// Embed the page image
|
||||
const image = await pdfDoc.embedJpg(pageImage.buffer);
|
||||
|
||||
// Calculate page dimensions from image dimensions (scaled down from 2x)
|
||||
const pageWidth = pageImage.width / OCR_SCALE_FACTOR;
|
||||
const pageHeight = pageImage.height / OCR_SCALE_FACTOR;
|
||||
|
||||
// Add a page with the calculated dimensions
|
||||
const page = pdfDoc.addPage([pageWidth, pageHeight]);
|
||||
|
||||
// Draw the image as the background, filling the entire page
|
||||
page.drawImage(image, {
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: pageWidth,
|
||||
height: pageHeight,
|
||||
});
|
||||
|
||||
// Add invisible text layer on top
|
||||
addInvisibleTextLayer(page, pageOcr.lines, font, pageWidth, pageHeight);
|
||||
}
|
||||
|
||||
return pdfDoc.save();
|
||||
};
|
||||
|
||||
export default createAccessiblePdf;
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user