1
0
mirror of https://github.com/laurent22/joplin.git synced 2026-03-12 10:00:05 +02:00

Compare commits

..

33 Commits

Author SHA1 Message Date
Laurent Cozic
68d1601847 update 2026-03-11 22:31:39 +00:00
Laurent Cozic
2132c2cdf4 Revert "Desktop: Fix context menu missing cut/copy when selecting resource links in markdown editor" (#14710) 2026-03-11 21:19:55 +00:00
Sergio
67aff20e39 Desktop: Fixes #14676: Toggling checkboxes in the note history viewer opens an open with prompt on Windows (#14679) 2026-03-11 16:19:46 +00:00
Gnana Pragadeesh K
3719e1eee0 Mobile: Fixes #14152: Fix font-size inconsistency of code block and inline code (#14463) 2026-03-11 16:19:08 +00:00
Laurent Cozic
4abe83fdb6 Doc: Fix broken language selector on website 2026-03-10 18:49:04 +00:00
Laurent Cozic
6ba912e5aa Chore: Fixed website localisation issue 2026-03-10 15:32:29 +00:00
Surendra Manjhi
8533083730 Desktop: Fixes #14627: use resourceUrl() for base64 images in pasteAsMarkdown (#14632) 2026-03-10 12:38:00 +00:00
Yugal Kaushik
754ff28b36 Desktop: Fixes #101111: ENEX import no longer breaks bullet items with a line break into separate paragraphs (#14642) 2026-03-10 12:36:48 +00:00
Ahmed Idani
b663c64def All: Fixes #14412: Skip share consistency check when not using Joplin Server/Cloud (#14649) 2026-03-10 12:35:55 +00:00
Laurent Cozic
998b26d9a4 Doc: Update CLAUDE.md to specify whitespace rules
Clarified guidelines on whitespace changes in code.
2026-03-10 12:31:00 +00:00
Veivel P
b097cf9a6a Desktop: Resolves #14143: Fixed scrolling behaviour in long lines for TinyMCE and CodeMirror (#14669) 2026-03-10 12:26:03 +00:00
Vinayreddy765
e22c367566 Desktop: Fixes #14637: Fix context menu missing cut/copy when selecting resource links in markdown editor (#14638)
Co-authored-by: Laurent Cozic <laurent22@users.noreply.github.com>
2026-03-10 12:20:40 +00:00
Justin Charles
71a2e98155 Desktop: Fixes #14661: hide new note/todo buttons when no notebook exists (#14674)
Signed-off-by: justin212407 <charlesjustin2124@gmail.com>
2026-03-10 12:15:34 +00:00
Davideb18
714bbd6d23 Desktop: Fixes #11823: Fixed cancel behavior labels when switching config screens (#14677) 2026-03-10 12:14:04 +00:00
Akshaj Rawat
eda03333a6 Desktop: Fixes #12394: Fix search bar remaining empty when navigating back (#14488) 2026-03-10 12:01:16 +00:00
divyanshkhurana06
93f17a87fa Desktop: Fixes #14142: Fix search highlights breaking mermaid diagram rendering (#14516) 2026-03-10 11:57:15 +00:00
Dipanshu Rawat
c765306e6f Chore: ArrayUtils optimize unique and removeElement functions, improve type handling (#14552)
Co-authored-by: Laurent Cozic <laurent22@users.noreply.github.com>
2026-03-10 11:38:28 +00:00
Justin Charles
f05fe5754d Desktop: Fixes #14542: Fix Prevent unclosed frontmatter from breaking Markdown rendering (#14563)
Signed-off-by: justin212407 <charlesjustin2124@gmail.com>
2026-03-10 11:31:38 +00:00
Keshav
d046bfa14b Desktop: Resolves #10562: Preserve table customization made on RTE (#14572) 2026-03-10 11:29:59 +00:00
Yousef Genedy
2a681008dd Mobile, Desktop: Resolves #9481: Start sync when app opens or resumes (#14574) 2026-03-10 11:27:46 +00:00
Ashutosh Singh
7214823c74 Chore: Resolves #12037: Remove JSDOM from Turndown package (#14653) 2026-03-09 14:09:08 +00:00
renovate[bot]
ed5b92a91e Update dependency ldapts to v8.0.18 (#14655)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-03-09 10:27:43 +00:00
Aayushi Rajesh
2c8a9eee61 Doc: Add Plugins link to website navigation (#14645) 2026-03-09 09:10:33 +00:00
renovate[bot]
6451305c89 Update dependency esbuild to v0.26.0 (#14654)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-03-09 09:05:27 +00:00
Henry Heino
5fd0dc23da Desktop: Fixes #14584: Fix changes to editor settings not applied until editor reloads (#14586) 2026-03-08 21:11:54 +00:00
renovate[bot]
fd3b133b16 Update dependency dompurify to v3.3.1 (#14648)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-03-08 17:12:46 +00:00
renovate[bot]
118bc3edf1 Update dependency git to v2.51.0 (#14646)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-03-08 14:39:59 +00:00
renovate[bot]
d90836bc50 Update dependency ldapts to v8.0.17 (#14641)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-03-08 13:45:27 +00:00
Dipanshu Rawat
9a477dbeb9 Chore: Fix typo for enum for Right value (#14575)
Co-authored-by: Laurent Cozic <laurent22@users.noreply.github.com>
2026-03-08 12:01:31 +00:00
Justin Charles
5271081b3a Docs: Add plugin website link in README (#14626)
Signed-off-by: justin212407 <charlesjustin2124@gmail.com>
2026-03-08 11:58:25 +00:00
bwat47
b26370fc5a Desktop, Mobile: Fixes #14630: underline disappearing from ++insert++ syntax when cursor is on that line (#14631) 2026-03-08 11:57:51 +00:00
Laurent Cozic
737c7dcdb4 CI: Do not cancel CI execution on dev branch 2026-03-08 11:17:08 +00:00
Joplin Bot
04babe0261 Doc: Auto-update documentation
Auto-updated using release-website.sh
2026-03-07 18:44:23 +00:00
74 changed files with 1002 additions and 362 deletions

View File

@@ -271,6 +271,7 @@ packages/app-desktop/gui/NoteEditor/NoteBody/CodeMirror/v6/Editor.js
packages/app-desktop/gui/NoteEditor/NoteBody/CodeMirror/v6/useEditorCommands.js
packages/app-desktop/gui/NoteEditor/NoteBody/CodeMirror/v6/utils/localisation.js
packages/app-desktop/gui/NoteEditor/NoteBody/CodeMirror/v6/utils/useContentScriptRegistration.js
packages/app-desktop/gui/NoteEditor/NoteBody/CodeMirror/v6/utils/useEditorSettings.js
packages/app-desktop/gui/NoteEditor/NoteBody/CodeMirror/v6/utils/useKeymap.js
packages/app-desktop/gui/NoteEditor/NoteBody/CodeMirror/v6/utils/useRefocusOnVisiblePaneChange.js
packages/app-desktop/gui/NoteEditor/NoteBody/CodeMirror/v6/utils/useSyncEditorValue.js

View File

@@ -5,7 +5,7 @@ name: react-native-android-build-apk
on: [push, pull_request]
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
group: ${{ github.ref == 'refs/heads/dev' && github.run_id || format('{0}-{1}', github.workflow, github.ref) }}
cancel-in-progress: true
jobs:

View File

@@ -2,7 +2,7 @@ name: Build macOS M1
on: [push, pull_request]
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
group: ${{ github.ref == 'refs/heads/dev' && github.run_id || format('{0}-{1}', github.workflow, github.ref) }}
cancel-in-progress: true
jobs:

View File

@@ -2,7 +2,7 @@ name: Joplin Continuous Integration
on: [push, pull_request]
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
group: ${{ github.ref == 'refs/heads/dev' && github.run_id || format('{0}-{1}', github.workflow, github.ref) }}
cancel-in-progress: true
jobs:

View File

@@ -2,7 +2,7 @@ name: Joplin UI tests
on: [push, pull_request]
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
group: ${{ github.ref == 'refs/heads/dev' && github.run_id || format('{0}-{1}', github.workflow, github.ref) }}
cancel-in-progress: true
permissions:

1
.gitignore vendored
View File

@@ -244,6 +244,7 @@ packages/app-desktop/gui/NoteEditor/NoteBody/CodeMirror/v6/Editor.js
packages/app-desktop/gui/NoteEditor/NoteBody/CodeMirror/v6/useEditorCommands.js
packages/app-desktop/gui/NoteEditor/NoteBody/CodeMirror/v6/utils/localisation.js
packages/app-desktop/gui/NoteEditor/NoteBody/CodeMirror/v6/utils/useContentScriptRegistration.js
packages/app-desktop/gui/NoteEditor/NoteBody/CodeMirror/v6/utils/useEditorSettings.js
packages/app-desktop/gui/NoteEditor/NoteBody/CodeMirror/v6/utils/useKeymap.js
packages/app-desktop/gui/NoteEditor/NoteBody/CodeMirror/v6/utils/useRefocusOnVisiblePaneChange.js
packages/app-desktop/gui/NoteEditor/NoteBody/CodeMirror/v6/utils/useSyncEditorValue.js

View File

@@ -1351,11 +1351,7 @@ footer .bottom-links-row p {
ENGLISH VERSION
*****************************************************************/
:lang(en-gb) #made-in-france-section {
display: none;
}
:lang(en-gb) .top-section-img-cn {
:not(:lang(zh-cn)) .top-section-img-cn {
display: none;
}

View File

@@ -145,7 +145,7 @@ function setupLocaleRedirect() {
if (!isRootPage) return;
// Check if user has explicitly chosen to stay on current locale
const localePreference = localStorage.getItem('joplin-locale-preference');
const localePreference = (localStorage.getItem('joplin-locale-preference') || '').toLowerCase();
if (localePreference === 'en') return;
// Get user's preferred language from browser
@@ -160,9 +160,10 @@ function setupLocaleRedirect() {
window.location.href = getLocalePath(langCode) + '/';
}
// Allow users to switch back to English and remember their preference
function setLocalePreference(locale) {
// Allow users to switch language and remember their preference
function setLocalePreference(locale, url) {
localStorage.setItem('joplin-locale-preference', locale);
window.location.href = url;
}
// Expose globally for language switcher links

View File

@@ -14,6 +14,7 @@
<div class="col-9 text-right d-none d-md-block">
{{> twitterLink}}
<a href="{{baseUrl}}/news/" class="fw500">News</a>
<a href="{{baseUrl}}/plugins/" class="fw500">Plugins</a>
<a href="{{baseUrl}}/help/" class="fw500">Help</a>
<a href="{{forumUrl}}" class="fw500">Forum</a>
@@ -23,7 +24,7 @@
</button>
<ul class="dropdown-menu dropdown-menu-end">
{{#availableLocales}}
<li><a class="dropdown-item {{#isActive}}active{{/isActive}}" href="{{baseUrl}}/{{pathPrefix}}" onclick="setLocalePreference('{{code}}')">{{name}}</a></li>
<li><a class="dropdown-item {{#isActive}}active{{/isActive}}" href="{{baseUrl}}/{{pathPrefix}}" onclick="setLocalePreference('{{code}}', this.href); return false;">{{name}}</a></li>
{{/availableLocales}}
</ul>
</div>
@@ -59,6 +60,7 @@
<div class="text-center menu-mobile-top">
<a href="{{baseUrl}}/news/" class="fw500 mobile-menu-link">News</a>
<a href="{{baseUrl}}/plugins/" class="fw500 mobile-menu-link">Plugins</a>
<a href="{{baseUrl}}/help/" class="fw500 mobile-menu-link">Help</a>
<a href="{{forumUrl}}" class="fw500 mobile-menu-link">Forum</a>
</div>
@@ -73,7 +75,7 @@
<div class="text-center menu-mobile-language">
<p class="fw500 mobile-menu-language-label"><i class="fas fa-globe"></i> Language</p>
{{#availableLocales}}
<a href="{{baseUrl}}/{{pathPrefix}}" class="fw500 mobile-menu-link mobile-language-link {{#isActive}}active{{/isActive}}" onclick="setLocalePreference('{{code}}')">{{name}}</a>
<a href="{{baseUrl}}/{{pathPrefix}}" class="fw500 mobile-menu-link mobile-language-link {{#isActive}}active{{/isActive}}" onclick="setLocalePreference('{{code}}', this.href); return false;">{{name}}</a>
{{/availableLocales}}
</div>
</div>

View File

@@ -10,6 +10,7 @@
- When creating Jest tests, there should be only one `describe()` statement in the file.
- Focus on testing essential behaviour and edge cases — avoid adding tests for every minor detail.
- Avoid duplicating code in tests; when testing the same logic with different inputs, use `test.each` or shared helpers instead of repeating similar test blocks.
- Do not make white space changes - do not add unnecessary new lines, or spaces to existing code, or wrap existing code.
## Full Documentation

View File

@@ -17,7 +17,7 @@
"version": "latest",
"excluded_platforms": ["aarch64-darwin", "x86_64-darwin"],
},
"git": "2.50.1",
"git": "2.51.0",
},
"shell": {
"init_hook": [

View File

@@ -45,6 +45,10 @@ describe('HtmlToMd', () => {
htmlToMdOptions.preserveColorStyles = true;
}
if (htmlFilename.indexOf('table_with') === 0 || htmlFilename.indexOf('table_default') === 0) {
htmlToMdOptions.preserveTableStyles = true;
}
const html = await readFile(htmlPath, 'utf8');
let expectedMd = await readFile(mdPath, 'utf8');

View File

@@ -0,0 +1,7 @@
<ul>
<li>First line<br/>Second line</li>
<li>Normal item</li>
<li>With sub-list<ul>
<li>Sub-list<br/>Paragraph<br/>Also another line</li>
</ul></li>
</ul>

View File

@@ -0,0 +1,8 @@
- First line
Second line
- Normal item
- With sub-list
- Sub-list
Paragraph
Also another line

View File

@@ -0,0 +1,18 @@
<table style="border-collapse: collapse; width: 100%;">
<thead>
<tr>
<th style="width: 50%;">Name</th>
<th style="width: 50%;">Value</th>
</tr>
</thead>
<tbody>
<tr>
<td style="width: 50%;">Cell A</td>
<td style="width: 50%;">Cell B</td>
</tr>
<tr>
<td style="width: 50%;">Cell C</td>
<td style="width: 50%;">Cell D</td>
</tr>
</tbody>
</table>

View File

@@ -0,0 +1,4 @@
| Name | Value |
| --- | --- |
| Cell A | Cell B |
| Cell C | Cell D |

View File

@@ -0,0 +1,18 @@
<table bgcolor="#f0f0f0" cellpadding="8">
<thead>
<tr>
<th>Name</th>
<th>Value</th>
</tr>
</thead>
<tbody>
<tr>
<td>Cell A</td>
<td>Cell B</td>
</tr>
<tr>
<td>Cell C</td>
<td>Cell D</td>
</tr>
</tbody>
</table>

View File

@@ -0,0 +1 @@
<div class="joplin-table-wrapper"><table bgcolor="#f0f0f0" cellpadding="8"><thead><tr><th>Name</th><th>Value</th></tr></thead><tbody><tr><td>Cell A</td><td>Cell B</td></tr><tr><td>Cell C</td><td>Cell D</td></tr></tbody></table></div>

View File

@@ -0,0 +1,18 @@
<table style="border-collapse: collapse">
<thead>
<tr>
<th>Name</th>
<th>Value</th>
</tr>
</thead>
<tbody>
<tr>
<td style="background-color: #e03e2d">Red cell</td>
<td style="padding: 10px 15px">Padded cell</td>
</tr>
<tr>
<td style="border-color: #2dc26b; border-style: solid">Green border</td>
<td>Normal cell</td>
</tr>
</tbody>
</table>

View File

@@ -0,0 +1 @@
<div class="joplin-table-wrapper"><table style="border-collapse: collapse"><thead><tr><th>Name</th><th>Value</th></tr></thead><tbody><tr><td style="background-color: #e03e2d">Red cell</td><td style="padding: 10px 15px">Padded cell</td></tr><tr><td style="border-color: #2dc26b; border-style: solid">Green border</td><td>Normal cell</td></tr></tbody></table></div>

View File

@@ -6,7 +6,7 @@ const shim: typeof ShimType = require('@joplin/lib/shim').default;
import { isCallbackUrl } from '@joplin/lib/callbackUrlUtils';
import { FileLocker } from '@joplin/utils/fs';
import { IpcMessageHandler, IpcServer, Message, newHttpError, sendMessage, SendMessageOptions, startServer, stopServer } from '@joplin/utils/ipc';
import { BrowserWindow, Tray, WebContents, screen, App, nativeTheme } from 'electron';
import { BrowserWindow, Tray, WebContents, screen, App, nativeTheme, powerMonitor } from 'electron';
import bridge from './bridge';
import * as url from 'url';
const path = require('path');
@@ -401,6 +401,15 @@ export default class ElectronAppWrapper {
};
addWindowEventHandlers(this.win_.webContents);
// BrowserWindow 'focus' fires when the OS gives focus to the application window
// (i.e. coming from another app or from the taskbar), not on intra-app focus switches.
// We use a dedicated IPC channel so the renderer can trigger an immediate sync on
// OS-level focus gain without conflating it with the 'window-focused' channel that
// handles Joplin-internal window routing.
this.win_.on('focus', () => {
this.win_?.webContents.send('main-window-focused');
});
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
this.win_.on('close', (event: any) => {
// If it's on macOS, the app is completely closed only if the user chooses to close the app (willQuitApp_ will be true)
@@ -892,6 +901,11 @@ export default class ElectronAppWrapper {
event.preventDefault();
void this.openCallbackUrl(url);
});
// When the OS wakes from sleep, notify the renderer so it can trigger an immediate sync.
powerMonitor.on('resume', () => {
this.win_?.webContents.send('system-resumed');
});
}
public async openCallbackUrl(url: string) {

View File

@@ -733,6 +733,23 @@ class Application extends BaseApplication {
});
}
});
// Trigger an immediate sync when the main window gains OS-level focus (i.e. the user
// switches back to Joplin from another application) or when the system wakes from sleep.
// A 30-second cool-down prevents duplicate syncs during rapid focus-in/focus-out cycles.
const minResumeSyncIntervalMs = 30_000;
let lastFocusSyncTime = 0;
const scheduleResumeSync = () => {
const now = Date.now();
if (now - lastFocusSyncTime > minResumeSyncIntervalMs) {
lastFocusSyncTime = now;
void reg.scheduleSync(0);
}
};
ipcRenderer.on('main-window-focused', scheduleResumeSync);
ipcRenderer.on('system-resumed', scheduleResumeSync);
});
addTask('app/initPluginService', () => this.initPluginService());

View File

@@ -19,7 +19,7 @@ import shouldShowMissingPasswordWarning from '@joplin/lib/components/shared/conf
import MacOSMissingPasswordHelpLink from './controls/MissingPasswordHelpLink';
const { KeymapConfigScreen } = require('../KeymapConfig/KeymapConfigScreen');
import SettingComponent, { UpdateSettingValueEvent } from './controls/SettingComponent';
import shim from '@joplin/lib/shim';
import shim, { MessageBoxType } from '@joplin/lib/shim';
interface Font {
@@ -145,8 +145,16 @@ class ConfigScreenComponent extends React.Component<any, any> {
screenName = section.name;
if (this.hasChanges()) {
const ok = await shim.showConfirmationDialog(_('This will open a new screen. Save your current changes?'));
if (ok) {
const answer = await shim.showMessageBox(
_('This will open a new screen. Save your current changes?'),
{
type: MessageBoxType.Confirm,
buttons: [_('Save changes'), _('Discard changes')],
defaultId: 0,
cancelId: 1,
},
);
if (answer === 0) {
await shared.saveSettings(this);
}
}

View File

@@ -6,12 +6,18 @@ describe('useContextMenu', () => {
it('should return type=image when cursor is inside markdown image', () => {
const line = `![alt text](:/${resourceId})`;
expect(getResourceIdFromMarkup(line, 15)).toEqual({ resourceId, type: 'image' });
const result = getResourceIdFromMarkup(line, 15);
expect(result.resourceId).toBe(resourceId);
expect(result.type).toBe('image');
expect(line.substring(result.markupStart, result.markupEnd)).toBe(line);
});
it('should return type=file when cursor is inside markdown link', () => {
const line = `[document.pdf](:/${resourceId})`;
expect(getResourceIdFromMarkup(line, 15)).toEqual({ resourceId, type: 'file' });
const result = getResourceIdFromMarkup(line, 15);
expect(result.resourceId).toBe(resourceId);
expect(result.type).toBe('file');
expect(line.substring(result.markupStart, result.markupEnd)).toBe(line);
});
it('should return null when cursor is outside markup', () => {
@@ -22,8 +28,13 @@ describe('useContextMenu', () => {
it('should correctly distinguish between image and file on same line', () => {
const line = `![image](:/${resourceId}) [file](:/${resourceId2})`;
expect(getResourceIdFromMarkup(line, 10)).toEqual({ resourceId, type: 'image' });
expect(getResourceIdFromMarkup(line, 48)).toEqual({ resourceId: resourceId2, type: 'file' });
const imageResult = getResourceIdFromMarkup(line, 10);
expect(imageResult.resourceId).toBe(resourceId);
expect(imageResult.type).toBe('image');
const fileResult = getResourceIdFromMarkup(line, 48);
expect(fileResult.resourceId).toBe(resourceId2);
expect(fileResult.type).toBe('file');
});
it('should return null for empty line', () => {

View File

@@ -22,6 +22,8 @@ export type ResourceMarkupType = 'image' | 'file';
export interface ResourceMarkupInfo {
resourceId: string;
type: ResourceMarkupType;
markupStart: number;
markupEnd: number;
}
// Extract resource ID from resource markup (images or file attachments) at a given cursor position within a line.
@@ -74,7 +76,7 @@ export const getResourceIdFromMarkup = (lineContent: string, cursorPosInLine: nu
}
if (markupEnd !== -1 && cursorPosInLine >= markupStart && cursorPosInLine <= markupEnd) {
return { resourceId: resourceInfo.itemId, type: markupType };
return { resourceId: resourceInfo.itemId, type: markupType, markupStart, markupEnd };
}
}
}
@@ -161,28 +163,20 @@ 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 => {
if (!editorRef.current) return null;
const editor = editorRef.current.editor;
if (!editor) return null;
const zoom = Setting.value('windowContentZoomFactor');
const x = convertFromScreenCoordinates(zoom, params.x);
const y = convertFromScreenCoordinates(zoom, params.y);
const clickPos = editor.posAtCoords({ x, y });
if (clickPos === null) return null;
const line = editor.state.doc.lineAt(clickPos);
return getResourceIdFromMarkup(line.text, clickPos - line.from);
};
const targetWindow = bridge().windowById(windowId);
const appendEditMenuItems = (menu: typeof Menu.prototype) => {
const hasSelectedText = editorRef.current && !!editorRef.current.getSelection();
menu.append(new MenuItem({ label: _('Cut'), enabled: hasSelectedText, click: () => props.editorCutText() }));
menu.append(new MenuItem({ label: _('Copy'), enabled: hasSelectedText, click: () => props.editorCopyText() }));
menu.append(new MenuItem({ label: _('Paste'), enabled: true, click: () => props.editorPaste() }));
menu.append(new MenuItem({ label: _('Paste as Markdown'), enabled: true, click: () => CommandService.instance().execute('pasteAsMarkdown') }));
};
const showResourceContextMenu = async (resourceId: string, type: ResourceMarkupType) => {
const menu = new Menu();
// Add resource-specific options first
const baseType = type === 'image' ? ContextMenuItemType.Image : ContextMenuItemType.Resource;
const itemType = await resolveContextMenuItemType(baseType, resourceId);
const contextMenuOptions: ContextMenuOptions = {
@@ -194,18 +188,34 @@ const useContextMenu = (props: ContextMenuProps) => {
linkToOpen: null,
textToCopy: null,
htmlToCopy: null,
insertContent: () => {},
isReadOnly: true,
insertContent: () => { editorRef.current?.insertText(''); },
isReadOnly: false,
fireEditorEvent: () => {},
htmlToMd: null,
mdToHtml: null,
};
const resourceMenuItems = await buildMenuItems(menuItems(props.dispatch), contextMenuOptions);
const resourceMenuItems = await buildMenuItems(menuItems(props.dispatch), contextMenuOptions, { excludeEditItems: true, excludePluginItems: true });
for (const item of resourceMenuItems) {
menu.append(item);
}
// Add edit items
menu.append(new MenuItem({ type: 'separator' }));
appendEditMenuItems(menu);
// Add plugin items last
const extraItems = await handleEditorContextMenuFilter({
resourceId,
itemType,
});
if (extraItems.length) {
menu.append(new MenuItem({ type: 'separator' }));
for (const item of extraItems) {
menu.append(item);
}
}
menu.popup({ window: targetWindow });
};
@@ -227,7 +237,25 @@ const useContextMenu = (props: ContextMenuProps) => {
});
};
interface ResourceContextInfo {
resourceId: string;
type: ResourceMarkupType;
}
const getResourceInfoAtPos = (docPos: number): ResourceContextInfo | null => {
const editor = editorRef.current?.editor;
if (!editor) return null;
const line = editor.state.doc.lineAt(docPos);
const info = getResourceIdFromMarkup(line.text, docPos - line.from);
if (!info) return null;
return { resourceId: info.resourceId, type: info.type };
};
const onContextMenu = async (event: Event, params: ContextMenuParams) => {
let resourceInfo: ResourceContextInfo | null = null;
// Check if right-clicking on a rendered image first (images may not be "editable")
const imageContainer = getClickedImageContainer(params);
if (imageContainer && pointerInsideEditor(params, true)) {
@@ -235,19 +263,40 @@ const useContextMenu = (props: ContextMenuProps) => {
if (imgElement) {
const resourceId = pathToId(imgElement.src);
if (resourceId) {
event.preventDefault();
moveCursorToImageLine(imageContainer);
await showResourceContextMenu(resourceId, 'image');
return;
const sourceFrom = imageContainer.dataset.sourceFrom;
if (sourceFrom !== undefined) {
const editor = editorRef.current?.editor;
if (editor) {
const pos = Math.min(Number(sourceFrom), editor.state.doc.length);
resourceInfo = getResourceInfoAtPos(pos);
}
}
// Fallback if we couldn't get markup info
if (!resourceInfo) {
resourceInfo = { resourceId, type: 'image' };
}
}
}
}
// Check if right-clicking on resource markup text (images or file attachments)
const markupResourceInfo = getResourceInfoAtClickPos(params);
if (markupResourceInfo && pointerInsideEditor(params)) {
if (!resourceInfo && pointerInsideEditor(params)) {
const editor = editorRef.current?.editor;
if (editor) {
const zoom = Setting.value('windowContentZoomFactor');
const x = convertFromScreenCoordinates(zoom, params.x);
const y = convertFromScreenCoordinates(zoom, params.y);
const clickPos = editor.posAtCoords({ x, y });
if (clickPos !== null) {
resourceInfo = getResourceInfoAtPos(clickPos);
}
}
}
if (resourceInfo) {
event.preventDefault();
await showResourceContextMenu(markupResourceInfo.resourceId, markupResourceInfo.type);
await showResourceContextMenu(resourceInfo.resourceId, resourceInfo.type);
return;
}
@@ -258,48 +307,7 @@ const useContextMenu = (props: ContextMenuProps) => {
event.preventDefault();
const menu = new Menu();
const hasSelectedText = editorRef.current && !!editorRef.current.getSelection() ;
menu.append(
new MenuItem({
label: _('Cut'),
enabled: hasSelectedText,
click: async () => {
props.editorCutText();
},
}),
);
menu.append(
new MenuItem({
label: _('Copy'),
enabled: hasSelectedText,
click: async () => {
props.editorCopyText();
},
}),
);
menu.append(
new MenuItem({
label: _('Paste'),
enabled: true,
click: async () => {
props.editorPaste();
},
}),
);
menu.append(
new MenuItem({
label: _('Paste as Markdown'),
enabled: true,
click: async () => {
await CommandService.instance().execute('pasteAsMarkdown');
},
}),
);
appendEditMenuItems(menu);
const spellCheckerMenuItems = SpellCheckerService.instance().contextMenuItems(params.misspelledWord, params.dictionarySuggestions);

View File

@@ -221,7 +221,14 @@ const translateLE_ = (codeMirror: any, percent: number, l2e: boolean) => {
linInterp = percent * lineCount - lineU;
result = ePercentU + (ePercentL - ePercentU) * linInterp;
} else {
linInterp = Math.max(0, Math.min(1, (percent - ePercentU) / (ePercentL - ePercentU))) || 0;
const rawLinInterp = (percent - ePercentU) / (ePercentL - ePercentU);
if (ePercentL === ePercentU) {
// Prevents the Viewer from jumping to the bottom of
// the document when there is division by zero.
linInterp = percent;
} else {
linInterp = Math.max(0, Math.min(1, rawLinInterp)) || 0;
}
result = (lineU + linInterp) / lineCount;
}
return Math.max(0, Math.min(1, result));

View File

@@ -1,5 +1,5 @@
import * as React from 'react';
import { useState, useEffect, useRef, forwardRef, useCallback, useImperativeHandle, useMemo, ForwardedRef, useContext } from 'react';
import { useState, useEffect, useRef, forwardRef, useCallback, useImperativeHandle, ForwardedRef, useContext } from 'react';
import { EditorCommand, MarkupToHtmlOptions, NoteBodyEditorProps, NoteBodyEditorRef, OnChangeEvent } from '../../../utils/types';
import { getResourcesFromPasteEvent } from '../../../utils/resourceHandling';
@@ -12,11 +12,10 @@ import Note from '@joplin/lib/models/Note';
import { _ } from '@joplin/lib/locale';
import bridge from '../../../../../services/bridge';
import shim from '@joplin/lib/shim';
import { MarkupToHtml } from '@joplin/renderer';
import { clipboard } from 'electron';
import { reg } from '@joplin/lib/registry';
import ErrorBoundary from '../../../../ErrorBoundary';
import { EditorKeymap, EditorLanguageType, EditorSettings, SearchState, UserEventSource } from '@joplin/editor/types';
import { SearchState, UserEventSource } from '@joplin/editor/types';
import useStyles from '../utils/useStyles';
import { EditorEvent, EditorEventType } from '@joplin/editor/events';
import useScrollHandler from '../utils/useScrollHandler';
@@ -33,6 +32,7 @@ import { WindowIdContext } from '../../../../NewWindowOrIFrame';
import eventManager, { EventName, ResourceChangeEvent } from '@joplin/lib/eventManager';
import useSyncEditorValue from './utils/useSyncEditorValue';
import { getGlobalSettings } from '@joplin/renderer/types';
import useEditorSettings from './utils/useEditorSettings';
const logger = Logger.create('CodeMirror6');
const logDebug = (message: string) => logger.debug(message);
@@ -338,47 +338,6 @@ const CodeMirror = (props: NoteBodyEditorProps, ref: ForwardedRef<NoteBodyEditor
void CommandService.instance().execute('focusElement', 'noteTitle');
}, []);
const editorSettings = useMemo((): EditorSettings => {
const isHTMLNote = props.contentMarkupLanguage === MarkupToHtml.MARKUP_LANGUAGE_HTML;
let keyboardMode = EditorKeymap.Default;
if (props.keyboardMode === 'vim') {
keyboardMode = EditorKeymap.Vim;
} else if (props.keyboardMode === 'emacs') {
keyboardMode = EditorKeymap.Emacs;
}
return {
language: isHTMLNote ? EditorLanguageType.Html : EditorLanguageType.Markdown,
readOnly: props.disabled,
markdownMarkEnabled: Setting.value('markdown.plugin.mark'),
markdownInsertEnabled: Setting.value('markdown.plugin.insert'),
katexEnabled: Setting.value('markdown.plugin.katex'),
inlineRenderingEnabled: Setting.value('editor.inlineRendering'),
imageRenderingEnabled: Setting.value('editor.imageRendering'),
highlightActiveLine: Setting.value('editor.highlightActiveLine'),
themeData: {
...styles.globalTheme,
marginLeft: 0,
marginRight: 0,
monospaceFont: Setting.value('style.editor.monospaceFontFamily'),
},
automatchBraces: Setting.value('editor.autoMatchingBraces'),
autocompleteMarkup: Setting.value('editor.autocompleteMarkup'),
useExternalSearch: false,
ignoreModifiers: true,
spellcheckEnabled: Setting.value('editor.spellcheckBeta'),
keymap: keyboardMode,
preferMacShortcuts: shim.isMac(),
indentWithTabs: true,
tabMovesFocus: props.tabMovesFocus,
editorLabel: _('Markdown editor'),
};
}, [
props.contentMarkupLanguage, props.disabled, props.keyboardMode, styles.globalTheme,
props.tabMovesFocus,
]);
const initialCursorLocationRef = useRef(0);
initialCursorLocationRef.current = props.initialCursorLocation.markdown ?? 0;
@@ -391,6 +350,14 @@ const CodeMirror = (props: NoteBodyEditorProps, ref: ForwardedRef<NoteBodyEditor
initialCursorLocationRef,
});
const settings = useEditorSettings({
baseTheme: styles.globalTheme,
contentMarkupLanguage: props.contentMarkupLanguage,
disabled: props.disabled,
keyboardMode: props.keyboardMode,
tabMovesFocus: props.tabMovesFocus,
});
const renderEditor = () => {
return (
<div className='editor'>
@@ -400,7 +367,7 @@ const CodeMirror = (props: NoteBodyEditorProps, ref: ForwardedRef<NoteBodyEditor
initialSelectionRef={initialCursorLocationRef}
initialNoteId={props.noteId}
ref={editorRef}
settings={editorSettings}
settings={settings}
pluginStates={props.plugins}
onPasteFile={null}
onEvent={onEditorEvent}

View File

@@ -0,0 +1,76 @@
import { EditorKeymap, EditorLanguageType, EditorSettings, EditorTheme } from '@joplin/editor/types';
import shim from '@joplin/lib/shim';
import { MarkupLanguage, MarkupToHtml } from '@joplin/renderer';
import { useMemo } from 'react';
import { useSelector } from 'react-redux';
import { AppState } from '../../../../../../app.reducer';
import { _ } from '@joplin/lib/locale';
import { isDeepStrictEqual } from 'node:util';
interface EditorSettingsProps {
contentMarkupLanguage: MarkupLanguage;
keyboardMode: string;
disabled: boolean;
tabMovesFocus: boolean;
baseTheme: EditorTheme;
}
const useEditorSettings = (props: EditorSettingsProps) => {
const stateToSettings = (state: AppState) => ({
markdownMark: state.settings['markdown.plugin.mark'],
markdownInsert: state.settings['markdown.plugin.insert'],
katex: state.settings['markdown.plugin.katex'],
inlineRendering: state.settings['editor.inlineRendering'],
imageRendering: state.settings['editor.imageRendering'],
highlightActiveLine: state.settings['editor.highlightActiveLine'],
monospaceFont: state.settings['style.editor.monospaceFontFamily'],
automatchBraces: state.settings['editor.autoMatchingBraces'],
autocompleteMarkup: state.settings['editor.autocompleteMarkup'],
spellcheckEnabled: state.settings['editor.spellcheckBeta'],
});
type SelectedSettings = ReturnType<typeof stateToSettings>;
const settings = useSelector<AppState, SelectedSettings>(stateToSettings, isDeepStrictEqual);
return useMemo((): EditorSettings => {
const isHTMLNote = props.contentMarkupLanguage === MarkupToHtml.MARKUP_LANGUAGE_HTML;
let keyboardMode = EditorKeymap.Default;
if (props.keyboardMode === 'vim') {
keyboardMode = EditorKeymap.Vim;
} else if (props.keyboardMode === 'emacs') {
keyboardMode = EditorKeymap.Emacs;
}
return {
language: isHTMLNote ? EditorLanguageType.Html : EditorLanguageType.Markdown,
readOnly: props.disabled,
markdownMarkEnabled: settings.markdownMark,
markdownInsertEnabled: settings.markdownInsert,
katexEnabled: settings.katex,
inlineRenderingEnabled: settings.inlineRendering,
imageRenderingEnabled: settings.imageRendering,
highlightActiveLine: settings.highlightActiveLine,
themeData: {
...props.baseTheme,
marginLeft: 0,
marginRight: 0,
monospaceFont: settings.monospaceFont,
},
automatchBraces: settings.automatchBraces,
autocompleteMarkup: settings.autocompleteMarkup,
useExternalSearch: false,
ignoreModifiers: true,
spellcheckEnabled: settings.spellcheckEnabled,
keymap: keyboardMode,
preferMacShortcuts: shim.isMac(),
indentWithTabs: true,
tabMovesFocus: props.tabMovesFocus,
editorLabel: _('Markdown editor'),
};
}, [
props.contentMarkupLanguage, props.disabled, props.keyboardMode, props.baseTheme,
props.tabMovesFocus, settings,
]);
};
export default useEditorSettings;

View File

@@ -896,6 +896,30 @@ const TinyMCE = (props: NoteBodyEditorProps, ref: Ref<NoteBodyEditorRef>) => {
editor.addShortcut('Meta+Shift+8', '', () => editor.execCommand('InsertUnorderedList'));
editor.addShortcut('Meta+Shift+9', '', () => editor.execCommand('InsertJoplinChecklist'));
// Override ScrollIntoView to scroll to the cursor's character position
// instead of the start of the paragraph.
// See: https://github.com/laurent22/joplin/issues/14143
editor.on('ScrollIntoView', (event) => {
const sel = editor.getDoc().getSelection();
if (!sel || sel.rangeCount === 0) return;
const rect = sel.getRangeAt(0).getBoundingClientRect();
const win = editor.getWin();
const viewHeight = win.innerHeight;
if (rect.top < 0) {
win.scrollBy(0, rect.top);
} else if (rect.bottom > viewHeight) {
win.scrollBy(0, rect.bottom - viewHeight);
} else if (rect.top === 0 && rect.height === 0) {
// Handles edge case where rect is not rendered
// See: https://stackoverflow.com/a/14384220/5757550
return;
}
event.preventDefault();
return;
});
// TODO: remove event on unmount?
editor.on('drop', (event) => {
// Prevent the message "Dropped file type is not supported" from showing up.
@@ -1335,13 +1359,35 @@ const TinyMCE = (props: NoteBodyEditorProps, ref: Ref<NoteBodyEditorRef>) => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
const onSetAttrib = (event: EditorEvent<any>) => {
// Dispatch onChange when a link is edited
// Dispatch onChange when a link or table-related formatting is edited
const target = Array.isArray(event.attrElm) ? event.attrElm[0] : event.attrElm;
if (!target) return;
if (target.nodeName === 'A') {
if (event.attrName === 'title' || event.attrName === 'href' || event.attrName === 'rel') {
onChangeHandler();
}
}
if (['TABLE', 'TR', 'TD', 'TH'].includes(target.nodeName)) {
const attributeName = (event.attrName ?? '').toLowerCase();
if (
attributeName === 'style' ||
attributeName === 'class' ||
attributeName === 'bgcolor' ||
attributeName === 'bordercolor' ||
attributeName === 'background' ||
attributeName === 'cellpadding' ||
attributeName === 'cellspacing'
) {
onChangeHandler();
}
}
};
// Table plugin fires this on structure/style changes from dialogs.
const onTableModified = () => {
onChangeHandler();
};
// Keypress means that a printable key (letter, digit, etc.) has been
@@ -1490,6 +1536,7 @@ const TinyMCE = (props: NoteBodyEditorProps, ref: Ref<NoteBodyEditorRef>) => {
editor.on(TinyMceEditorEvents.Redo, onChangeHandler);
editor.on(TinyMceEditorEvents.ExecCommand, onExecCommand);
editor.on(TinyMceEditorEvents.SetAttrib, onSetAttrib);
editor.on('TableModified', onTableModified);
return () => {
try {
@@ -1506,6 +1553,7 @@ const TinyMCE = (props: NoteBodyEditorProps, ref: Ref<NoteBodyEditorRef>) => {
editor.off(TinyMceEditorEvents.Redo, onChangeHandler);
editor.off(TinyMceEditorEvents.ExecCommand, onExecCommand);
editor.off(TinyMceEditorEvents.SetAttrib, onSetAttrib);
editor.off('TableModified', onTableModified);
} catch (error) {
console.warn('Error removing events', error);
}

View File

@@ -197,39 +197,48 @@ export const handleEditorContextMenuFilter = async (context?: EditorContextMenuF
return output;
};
export const buildMenuItems = async (items: ContextMenuItems, options: ContextMenuOptions) => {
export interface BuildMenuItemsOptions {
excludeEditItems?: boolean;
excludePluginItems?: boolean;
}
export const buildMenuItems = async (items: ContextMenuItems, options: ContextMenuOptions, buildOptions?: BuildMenuItemsOptions) => {
const editItemKeys = ['cut', 'copy', 'paste', 'pasteAsText', 'separator4'];
const activeItems: ContextMenuItem[] = [];
for (const itemKey in items) {
if (buildOptions?.excludeEditItems && editItemKeys.includes(itemKey)) continue;
const item = items[itemKey];
if (item.isActive(options.itemType, options)) {
activeItems.push(item);
}
}
const extraItems = await handleEditorContextMenuFilter({
resourceId: options.resourceId,
itemType: options.itemType,
textToCopy: options.textToCopy,
});
if (extraItems.length) {
activeItems.push({
isActive: () => true,
label: '',
onAction: () => {},
isSeparator: true,
if (!buildOptions?.excludePluginItems) {
const extraItems = await handleEditorContextMenuFilter({
resourceId: options.resourceId,
itemType: options.itemType,
textToCopy: options.textToCopy,
});
}
for (const [, extraItem] of extraItems.entries()) {
activeItems.push({
isActive: () => true,
label: extraItem.label,
onAction: () => {
extraItem.click();
},
isSeparator: extraItem.type === 'separator',
});
if (extraItems.length) {
activeItems.push({
isActive: () => true,
label: '',
onAction: () => {},
isSeparator: true,
});
}
for (const [, extraItem] of extraItems.entries()) {
activeItems.push({
isActive: () => true,
label: extraItem.label,
onAction: () => {
extraItem.click();
},
isSeparator: extraItem.type === 'separator',
});
}
}
const filteredItems = filterSeparators(activeItems, item => item.isSeparator);

View File

@@ -13,6 +13,7 @@ export async function htmlToMarkdown(markupLanguage: number, html: string, origi
newBody = htmlToMd.parse(html, {
preserveImageTagsWithSize: true,
preserveNestedTables: true,
preserveTableStyles: true,
preserveColorStyles: true,
...parseOptions,
});

View File

@@ -1,8 +1,9 @@
import Setting from '@joplin/lib/models/Setting';
import { processPastedHtml } from './resourceHandling';
import { processImagesInPastedHtml, processPastedHtml } from './resourceHandling';
import markupLanguageUtils from '@joplin/lib/markupLanguageUtils';
import HtmlToMd from '@joplin/lib/HtmlToMd';
import { HtmlToMarkdownHandler, MarkupToHtmlHandler } from './types';
import { setupDatabaseAndSynchronizer, switchClient } from '@joplin/lib/testing/test-utils';
const createTestMarkupConverters = () => {
const markupToHtml: MarkupToHtmlHandler = async (markupLanguage, markup, options) => {
@@ -111,4 +112,21 @@ describe('resourceHandling', () => {
expect(result).not.toMatch(/alt="[^"]*\n/);
}
});
// Regression test: base64 branch was hardcoding file:// and ignoring useInternalUrls
// 1x1 transparent PNG — smallest valid base64-encoded image for testing
const minimalPng = 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==';
test.each([
{ useInternalUrls: true, expectMatch: /src=":\/[a-f0-9]+"/, expectAbsent: 'file://' },
{ useInternalUrls: false, expectMatch: /src="file:\/\//, expectAbsent: 'data:' },
])('should convert base64 image using resourceUrl (useInternalUrls=$useInternalUrls)', async ({ useInternalUrls, expectMatch, expectAbsent }) => {
await setupDatabaseAndSynchronizer(1);
await switchClient(1);
const html = `<img src="data:image/png;base64,${minimalPng}"/>`;
const result = await processImagesInPastedHtml(html, { useInternalUrls });
expect(result).toMatch(expectMatch);
expect(result).not.toContain(expectAbsent);
expect(result).not.toContain('data:');
});
});

View File

@@ -185,7 +185,7 @@ export const processImagesInPastedHtml = async (html: string, options: ProcessIm
// Word encodes base64 with MIME line breaks every ~76 chars.
// Strip whitespace before decoding, then save as a Joplin resource
// so Turndown's outerHTML (used for images with width/height) gets
// a short file:// URL instead of 200KB of base64.
// a short URL instead of 200KB of base64.
const cleanSrc = imageSrc.replace(/\s/g, '');
const dataUrlMatch = cleanSrc.match(/^data:([^;]+);base64,(.+)$/);
if (dataUrlMatch) {
@@ -196,7 +196,7 @@ export const processImagesInPastedHtml = async (html: string, options: ProcessIm
try {
await shim.fsDriver().writeFile(filePath, base64Data, 'base64');
const createdResource = await shim.createResourceFromPath(filePath);
mappedResources[imageSrc] = `file://${encodeURI(Resource.fullPath(createdResource))}`;
mappedResources[imageSrc] = resourceUrl(createdResource);
} catch (writeError) {
writeError.message = `processPastedHtml: Failed to write or create resource from pasted image: ${writeError.message}`;
throw writeError;

View File

@@ -284,9 +284,11 @@ interface ConnectProps {
const mapStateToProps = (state: AppState, ownProps: ConnectProps) => {
const whenClauseContext = stateToWhenClauseContext(state, { windowId: ownProps.windowId });
const windowState = stateUtils.windowStateById(state, ownProps.windowId);
const hasFolderForNewNotes = whenClauseContext.selectedFolderIsValid
&& windowState.selectedFolderId !== getTrashFolderId();
return {
showNewNoteButtons: windowState.selectedFolderId !== getTrashFolderId(),
showNewNoteButtons: hasFolderForNewNotes,
newNoteButtonEnabled: CommandService.instance().isEnabled('newNote', whenClauseContext),
newTodoButtonEnabled: CommandService.instance().isEnabled('newTodo', whenClauseContext),
sortOrderButtonsVisible: state.settings['notes.sortOrder.buttonsVisible'],

View File

@@ -60,7 +60,7 @@ const useNoteListControlsBreakpoints = (width: number, newNoteButtonElement: Ele
const [dynamicBreakpoints, setDynamicBreakpoints] = useState<Breakpoints>({ Sm: BaseBreakpoint.Sm, Md: BaseBreakpoint.Md, Lg: BaseBreakpoint.Lg, Xl: BaseBreakpoint.Xl });
const previousWidth = usePrevious(width);
const widthHasChanged = width !== previousWidth;
const showNewNoteButton = selectedFolderId !== getTrashFolderId();
const showNewNoteButton = !!selectedFolderId && selectedFolderId !== getTrashFolderId();
// Initialize language-specific breakpoints
useEffect(() => {

View File

@@ -157,7 +157,10 @@ const NoteRevisionViewerComponent: React.FC<Props> = ({ themeId, noteId, onBack,
// if (msg !== 'percentScroll') console.info(`Got ipc-message: ${msg}`, args);
try {
if (msg.indexOf('joplin://') === 0) {
if (msg.indexOf('checkboxclick:') === 0) {
// Revision previews are read-only. Ignore checkbox toggle IPC messages so they
// don't fall through to URL handling (`checkboxclick:` looks like a protocol).
} else if (msg.indexOf('joplin://') === 0) {
throw new Error(_('Unsupported link or message: %s', msg));
} else if (urlUtils.urlProtocol(msg)) {
await bridge().openExternal(msg);

View File

@@ -27,6 +27,7 @@ interface Props {
dispatch?: Function;
selectedNoteId: string;
isFocused?: boolean;
globalQuery?: string;
}
function SearchBar(props: Props) {
@@ -163,6 +164,22 @@ function SearchBar(props: Props) {
// eslint-disable-next-line @seiyab/react-hooks/exhaustive-deps -- Old code before rule was applied
}, []);
// if the globalQuery is not undefined and is not equal to the current value of query changes the query to global query. Else the current query remains the same.
// used setQuery((previousQuery)=>{}) to prevent linter error asking to have [query] in the dependency array, since this useEffect would then run every time the query is changed
useEffect(() => {
if (props.globalQuery !== undefined) {
setQuery((previousQuery) => {
if (props.globalQuery !== previousQuery) {
if (props.globalQuery.length > 0) {
setSearchStarted(true);
}
return props.globalQuery;
}
return previousQuery;
});
}
}, [props.globalQuery]);
return (
<Root className="search-bar">
<SearchInput
@@ -186,10 +203,20 @@ interface OwnProps {
const mapStateToProps = (state: AppState, ownProps: OwnProps) => {
const windowState = stateUtils.windowStateById(state, ownProps.windowId);
let globalQuery = '';
if (windowState.notesParentType === 'Search' && windowState.selectedSearchId) {
const activeSearch = state.searches.find((s: { id: string; query_pattern: string }) => s.id === windowState.selectedSearchId);
if (activeSearch && activeSearch.query_pattern) {
globalQuery = activeSearch.query_pattern;
}
}
return {
notesParentType: windowState.notesParentType,
selectedNoteId: stateUtils.selectedNoteId(windowState),
isFocused: state.focusedField === 'globalSearch',
globalQuery: globalQuery,
};
};

View File

@@ -3,7 +3,7 @@ import { _ } from '@joplin/lib/locale';
import Note from '@joplin/lib/models/Note';
import Folder from '@joplin/lib/models/Folder';
export const newNoteEnabledConditions = 'oneFolderSelected && !inConflictFolder && !folderIsReadOnly && !folderIsTrash';
export const newNoteEnabledConditions = 'oneFolderSelected && selectedFolderIsValid && !inConflictFolder && !folderIsReadOnly && !folderIsTrash';
export const declaration: CommandDeclaration = {
name: 'newNote',

View File

@@ -34,13 +34,14 @@ export default class MainScreen {
}
public async waitFor() {
await this.newNoteButton.waitFor();
await this.noteList.waitFor();
}
// Follows the steps a user would use to create a new note.
public async createNewNote(title: string) {
await this.waitFor();
// The new note button is only visible when a folder is selected -- wait for it explicitly.
await this.newNoteButton.waitFor();
// Create the new note. Retry this -- creating new notes can sometimes fail if done just after
// application startup.

View File

@@ -172,7 +172,7 @@
"electron-builder": "24.13.3",
"electron-updater": "6.6.8",
"electron-window-state": "5.0.3",
"esbuild": "^0.25.3",
"esbuild": "^0.26.0",
"formatcoords": "1.1.3",
"glob": "11.0.3",
"gulp": "4.0.2",

View File

@@ -27,6 +27,7 @@ function useCss(themeId: number, editorCss: string): string {
:root {
background-color: ${theme.backgroundColor};
font-size: 13pt;
}
body {
@@ -41,7 +42,6 @@ function useCss(themeId: number, editorCss: string): string {
padding-bottom: 1px;
padding-top: 10px;
font-size: 13pt;
font-family: ${JSON.stringify(theme.fontFamily)}, sans-serif;
}

View File

@@ -10,6 +10,7 @@ const convertHtmlToMarkdown = (html: string|HTMLElement) => {
codeBlockStyle: 'fenced',
preserveImageTagsWithSize: true,
preserveNestedTables: true,
preserveTableStyles: true,
preserveColorStyles: true,
bulletListMarker: '-',
emDelimiter: '*',

View File

@@ -122,7 +122,7 @@
"babel-loader": "9.1.3",
"babel-plugin-module-resolver": "4.1.0",
"babel-plugin-react-native-web": "0.21.2",
"esbuild": "0.25.12",
"esbuild": "0.26.0",
"fast-deep-equal": "3.1.3",
"fs-extra": "11.3.2",
"gulp": "4.0.2",

View File

@@ -19,7 +19,7 @@ import SyncTargetJoplinServer from '@joplin/lib/SyncTargetJoplinServer';
import SyncTargetJoplinCloud from '@joplin/lib/SyncTargetJoplinCloud';
import SyncTargetOneDrive from '@joplin/lib/SyncTargetOneDrive';
import { Keyboard, BackHandler, Animated, StatusBar, Platform, Dimensions } from 'react-native';
import { AppState as RNAppState, EmitterSubscription, View, Text, Linking, NativeEventSubscription, Appearance, ActivityIndicator } from 'react-native';
import { AppState as RNAppState, AppStateStatus, EmitterSubscription, View, Text, Linking, NativeEventSubscription, Appearance, ActivityIndicator } from 'react-native';
import getResponsiveValue from './components/getResponsiveValue';
import NetInfo, { NetInfoSubscription } from '@react-native-community/netinfo';
const DropdownAlert = require('react-native-dropdownalert').default;
@@ -295,7 +295,8 @@ class AppComponent extends React.Component<AppComponentProps, AppComponentState>
private unsubscribeScreenWidthChangeHandler_: EmitterSubscription|undefined;
private unsubscribeNetInfoHandler_: NetInfoSubscription|undefined;
private unsubscribeNewShareListener_: UnsubscribeShareListener|undefined;
private onAppStateChange_: ()=> void;
private onAppStateChange_: (nextAppState: AppStateStatus)=> void;
private lastResumeSyncTime_ = 0;
private backButtonHandler_: BackButtonHandler;
private handleNewShare_: ()=> void;
private handleOpenURL_: (event: unknown)=> void;
@@ -315,8 +316,24 @@ class AppComponent extends React.Component<AppComponentProps, AppComponentState>
return this.backButtonHandler();
};
this.onAppStateChange_ = () => {
this.onAppStateChange_ = (nextAppState: AppStateStatus) => {
PoorManIntervals.update();
// Trigger sync immediately when the app becomes active (resume from background/lock screen).
// Only run when the app becomes active, with a 30-second minimum interval
// prevent sync spam on rapid lock/unlock cycles.
const minResumeSyncIntervalMs = 30_000;
if (nextAppState === 'active') {
const elapsed = Date.now() - this.lastResumeSyncTime_;
if (elapsed >= minResumeSyncIntervalMs) {
logger.info(`onAppStateChange_: App became active - scheduling immediate sync (elapsed since last resume sync: ${elapsed}ms)`);
this.lastResumeSyncTime_ = Date.now();
void reg.scheduleSync(0, null, true);
} else {
logger.info(`onAppStateChange_: App became active but skipping sync - minimum interval not reached (${elapsed}ms < ${minResumeSyncIntervalMs}ms)`);
}
}
};
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied

View File

@@ -207,6 +207,12 @@ const config = {
label: 'News',
position: 'right',
},
{
to: process.env.WEBSITE_BASE_URL + '/plugins',
label: 'Plugins',
position: 'right',
target: '_self',
},
{
type: 'docSidebar',
sidebarId: 'helpSidebar',

View File

@@ -210,4 +210,47 @@ describe('CodeMirror5Emulation', () => {
{ line: 0, ch: 5 },
)).toThrow(/is not an integer/i);
});
it('heightAtLine for a line past the document should be greater than for the last line', () => {
const codeMirror = makeCodeMirrorEmulation('line1\nline2\nline3');
// Mock lineBlockAt to return a block with non-zero height so that the
// distinction between top and top+height is observable in the test.
const mockLineBlock = { top: 900, bottom: 1000, height: 100, from: 0, to: 0 };
jest.spyOn(codeMirror.editor, 'lineBlockAt').mockReturnValue(mockLineBlock as never);
const lineCount = codeMirror.lineCount();
const heightAtLastLine = codeMirror.heightAtLine(lineCount - 1, 'local');
const heightPastEnd = codeMirror.heightAtLine(lineCount, 'local');
expect(heightPastEnd).toBeGreaterThan(heightAtLastLine);
});
it('heightAtLine for a line past the document should be greater than for the last line, given one long line', () => {
const singleLine = 'Very long line of text. '.repeat(400);
const codeMirror = makeCodeMirrorEmulation(`${singleLine}`);
// Mock lineBlockAt to return a block with non-zero height so that the
// distinction between top and top+height is observable in the test.
const mockLineBlock = { top: 900, bottom: 1000, height: 100, from: 0, to: 0 };
jest.spyOn(codeMirror.editor, 'lineBlockAt').mockReturnValue(mockLineBlock as never);
const lineCount = codeMirror.lineCount();
const heightAtLastLine = codeMirror.heightAtLine(lineCount - 1, 'local');
const heightPastEnd = codeMirror.heightAtLine(lineCount, 'local');
expect(heightPastEnd).toBeGreaterThan(heightAtLastLine);
});
it('heightAtLine should return a non-negative value for valid line numbers', () => {
const codeMirror = makeCodeMirrorEmulation('first\nsecond\nthird');
const lineCount = codeMirror.lineCount();
// Test all lines from top to bottom of document
for (let i = 0; i <= lineCount; i++) {
expect(codeMirror.heightAtLine(i, 'local')).toBeGreaterThanOrEqual(0);
}
});
});

View File

@@ -268,7 +268,15 @@ export default class CodeMirror5Emulation extends BaseCodeMirror5Emulation {
const lineInfo = doc.line(Math.min(lineNumber + 1, doc.lines));
const lineBlock = this.editor.lineBlockAt(lineInfo.from);
const height = lineBlock.top;
let height;
if (lineNumber >= doc.lines) {
// Handle case when lineNumber is at or below the last line
// This ensures ePercentL != ePercentU in translateLE_, which may cause linInterp to be infinity.
// See: https://github.com/laurent22/joplin/issues/14143#issuecomment-3767793559
height = lineBlock.top + lineBlock.height;
} else {
height = lineBlock.top;
}
if (mode === 'local') {
const editorTop = this.editor.lineBlockAt(0).top;
return height - editorTop;

View File

@@ -27,4 +27,21 @@ left | right
expect(codeBlock.textContent).toBe('`foo`');
expect(codeBlock.parentElement.classList.contains('.cm-tableRow'));
});
test.each([
0,
'before ++'.length + 1,
])('should decorate ++insert++ spans when the caret is at %i', async cursorPos => {
const editorText = 'before ++inserted++ after';
const editor = await createTestEditor(
editorText,
EditorSelection.cursor(cursorPos),
['Insert'],
[decoratorExtension],
);
const insertSpan = editor.contentDOM.querySelector('.cm-insert');
expect(insertSpan).not.toBeNull();
expect(insertSpan?.textContent).toContain('inserted');
});
});

View File

@@ -116,6 +116,10 @@ const strikethroughDecoration = Decoration.mark({
attributes: { class: 'cm-strike' },
});
const insertDecoration = Decoration.mark({
attributes: { class: 'cm-insert' },
});
const nodeNameToLineDecoration: Record<string, Decoration> = {
'FencedCode': codeBlockDecoration,
'CodeBlock': codeBlockDecoration,
@@ -150,6 +154,7 @@ const nodeNameToMarkDecoration: Record<string, Decoration> = {
'HorizontalRule': horizontalRuleDecoration,
'TaskMarker': taskMarkerDecoration,
'Strikethrough': strikethroughDecoration,
'Insert': insertDecoration,
'Highlight': markDecoration,
};

View File

@@ -54,6 +54,17 @@ describe('MarkdownFrontMatterExtension', () => {
expect(frontMatterNodes.length).toBe(0);
});
it('should treat the entire document as frontmatter when closing delimiter is missing (issue #14542)', async () => {
const documentText = '---\nsome: frontmatter\n--\n\n# Hey';
const editor = await createEditorState(documentText, [frontMatterTagName]);
const frontMatterNodes = findNodesWithName(editor, frontMatterTagName);
// Frontmatter block must be recognised and span the entire document
expect(frontMatterNodes.length).toBe(1);
expect(frontMatterNodes[0].from).toBe(0);
expect(frontMatterNodes[0].to).toBe(documentText.length);
});
it('should handle empty FrontMatter block', async () => {
const documentText = '---\n---\n\n# Heading';
const editor = await createEditorState(documentText, [frontMatterTagName, 'ATXHeading1']);

View File

@@ -66,14 +66,15 @@ const frontMatterConfig: MarkdownConfig = {
return false;
}
// Store the opening delimiter position
// If the document starts with --- always claim it as a frontmatter block,
// even when the closing delimiter is absent.
const openingMarkerStart = cx.lineStart;
const openingMarkerEnd = cx.lineStart + line.text.length;
const contentStart = openingMarkerEnd + 1;
const contentStart = openingMarkerEnd + 1; // Start after the opening --- and newline
let foundEnd = false;
// Consume lines until we find the closing ---
// Consume lines until we find the closing --- or reach end of document.
while (cx.nextLine()) {
if (frontMatterDelimiterRegex.test(line.text)) {
foundEnd = true;
@@ -81,37 +82,34 @@ const frontMatterConfig: MarkdownConfig = {
}
}
if (!foundEnd) {
// No closing delimiter found - not a valid FrontMatter block
return false;
}
// cx.lineStart now points to the closing --- (if found) or end of document (if not).
const contentEnd = cx.lineStart;
// The content is between the two --- delimiters
const contentEnd = cx.lineStart; // Start of the closing --- line
// Closing delimiter positions
const closingMarkerStart = cx.lineStart;
const closingMarkerEnd = cx.lineStart + line.text.length;
// Create marker elements for the --- delimiters
const openingMarkerElem = cx.elt(frontMatterMarkerTagName, openingMarkerStart, openingMarkerEnd);
const closingMarkerElem = cx.elt(frontMatterMarkerTagName, closingMarkerStart, closingMarkerEnd);
// Create the content element (the YAML content between delimiters)
const contentElem = cx.elt(frontMatterContentTagName, contentStart, contentEnd);
// Create the container element spanning from start of first --- to end of last ---
const containerElement = cx.elt(
frontMatterTagName,
0, // Start at document beginning
closingMarkerEnd, // End after closing ---
[openingMarkerElem, contentElem, closingMarkerElem],
);
if (foundEnd) {
const closingMarkerStart = cx.lineStart;
const closingMarkerEnd = cx.lineStart + line.text.length;
const closingMarkerElem = cx.elt(frontMatterMarkerTagName, closingMarkerStart, closingMarkerEnd);
cx.addElement(containerElement);
// Move past the closing delimiter
cx.nextLine();
const containerElement = cx.elt(
frontMatterTagName,
0,
closingMarkerEnd,
[openingMarkerElem, contentElem, closingMarkerElem],
);
cx.addElement(containerElement);
cx.nextLine();
} else {
const containerElement = cx.elt(
frontMatterTagName,
0,
contentEnd,
[openingMarkerElem, contentElem],
);
cx.addElement(containerElement);
}
return true;
},

View File

@@ -187,6 +187,9 @@ const createTheme = (theme: EditorTheme): Extension[] => {
'& .cm-strike': {
textDecoration: 'line-through',
},
'& .cm-insert': {
textDecoration: 'underline',
},
// Applying font size changes with CSS rather than the theme below works
// around an issue where the border for code blocks in headings was too

View File

@@ -44,7 +44,7 @@
"@lezer/highlight": "1.2.3",
"@lezer/markdown": "1.6.3",
"@replit/codemirror-vim": "6.2.1",
"dompurify": "3.3.0",
"dompurify": "3.3.1",
"orderedmap": "2.1.1",
"prosemirror-commands": "1.7.1",
"prosemirror-dropcursor": "1.8.2",

View File

@@ -4,6 +4,12 @@ describe('ArrayUtils', () => {
it('should return unique elements', (async () => {
expect(ArrayUtils.unique(['un', 'deux', 'un', 'trois', 'deux'])).toEqual(['un', 'deux', 'trois']);
expect(ArrayUtils.unique([1, 2, 1, 3, 2])).toEqual([1, 2, 3]);
expect(ArrayUtils.unique([])).toEqual([]);
}));
it('should remove array elements', (async () => {
let a = ['un', 'deux', 'trois'];
a = ArrayUtils.removeElement(a, 'deux');

View File

@@ -1,12 +1,8 @@
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
export const unique = function<T extends any>(array: T[]): T[] {
return array.filter((elem, index, self) => {
return index === self.indexOf(elem);
});
export const unique = function<T>(array: T[]): T[] {
return [...new Set(array)];
};
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
export const removeElement = function<T extends any>(array: T[], element: T): T[] {
export const removeElement = function<T>(array: T[], element: T): T[] {
const index = array.indexOf(element);
if (index < 0) return array;
const newArray = array.slice();
@@ -15,8 +11,7 @@ export const removeElement = function<T extends any>(array: T[], element: T): T[
};
// https://stackoverflow.com/a/10264318/561309
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
export const binarySearch = function(items: any[], value: any) {
export const binarySearch = function<T>(items: T[], value: T) {
let startIndex = 0,
stopIndex = items.length - 1,
middle = Math.floor((stopIndex + startIndex) / 2);
@@ -37,8 +32,7 @@ export const binarySearch = function(items: any[], value: any) {
return items[middle] !== value ? -1 : middle;
};
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
export const findByKey = function(array: any[], key: any, value: any) {
export const findByKey = function<T, K extends keyof T>(array: T[], key: K, value: T[K]): T | null {
for (let i = 0; i < array.length; i++) {
const o = array[i];
if (typeof o !== 'object') continue;
@@ -47,8 +41,7 @@ export const findByKey = function(array: any[], key: any, value: any) {
return null;
};
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
export const contentEquals = function(array1: any[], array2: any[]) {
export const contentEquals = function<T>(array1: T[], array2: T[]) {
if (array1 === array2) return true;
if (!array1.length && !array2.length) return true;
if (array1.length !== array2.length) return false;
@@ -85,8 +78,7 @@ export const mergeOverlappingIntervals = function(intervals: any[], limit: numbe
return stack;
};
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
export const shuffle = function(array: any[]) {
export const shuffle = function<T>(array: T[]): T[] {
array = array.slice();
for (let i = array.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
@@ -98,13 +90,12 @@ export const shuffle = function(array: any[]) {
};
// Used to replace lodash.pull, so that we don't need to import the whole
// package. Not optimised.
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
export const pull = (array: any[], ...elements: any[]) => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
const output: any[] = [];
// package. Uses Set for O(N) performance.
export const pull = <T>(array: T[], ...elements: T[]): T[] => {
const removeSet = new Set(elements);
const output: T[] = [];
for (const e of array) {
if (elements.includes(e)) continue;
if (removeSet.has(e)) continue;
output.push(e);
}
return output;

View File

@@ -8,6 +8,7 @@ export interface ParseOptions {
anchorNames?: string[];
preserveImageTagsWithSize?: boolean;
preserveNestedTables?: boolean;
preserveTableStyles?: boolean;
preserveColorStyles?: boolean;
baseUrl?: string;
disableEscapeContent?: boolean;
@@ -26,6 +27,7 @@ export default class HtmlToMd {
codeBlockStyle: 'fenced',
preserveImageTagsWithSize: !!options.preserveImageTagsWithSize,
preserveNestedTables: !!options.preserveNestedTables,
preserveTableStyles: !!options.preserveTableStyles,
preserveColorStyles: !!options.preserveColorStyles,
bulletListMarker: '-',
emDelimiter: '*',

View File

@@ -233,6 +233,10 @@ const isListItem = function(line: string) {
return line && line.trim().indexOf('- ') === 0;
};
const isListContinuation = function(line: string) {
return line && line.startsWith(' ') && !isListItem(line);
};
const isCodeLine = function(line: string) {
return line && line.indexOf('\t') === 0;
};
@@ -262,7 +266,7 @@ function formatMdLayout(lines: string[]) {
const line = lines[i];
// Add a new line at the end of a list of items
if (isListItem(previous) && line && !isListItem(line)) {
if (isListItem(previous) && line && !isListItem(line) && !isListContinuation(line)) {
newLines.push('');
// Add a new line at the beginning of a list of items
@@ -897,7 +901,13 @@ function enexXmlToMdArray(stream: any, resources: ResourceEntity[], tasks: Extra
section.lines.push(BLOCK_OPEN);
state.inPre = true;
} else if (n === 'br') {
section.lines.push(NEWLINE);
if (state.lists.length) {
const indent = ' '.repeat(state.lists.length - 1);
section.lines.push(NEWLINE);
section.lines.push(`${indent} `);
} else {
section.lines.push(NEWLINE);
}
} else if (n === 'en-media') {
const hash = nodeAttributes.hash;

View File

@@ -60,6 +60,15 @@ markJsUtils.markKeyword = (mark, keyword, stringUtils, extraOptions = null) => {
//
// https://github.com/joplin/plugin-abc-sheet-music
if (isInsideContainer(node, 'SVG')) return false;
// We exclude joplin-source because it contains the raw source
// for editable blocks (mermaid diagrams, etc.). If we highlight
// inside these elements, the <mark> tags corrupt the source code
// and cause rendering to fail when switching editors.
//
// https://github.com/laurent22/joplin/issues/14142
if (node.parentElement?.closest('.joplin-source')) return false;
return true;
},
...extraOptions,

View File

@@ -11,7 +11,7 @@ const emptyListRegex = /^(\s*)([*+-] \[[x ]\]|[*+-]|(\d+)[.)])(\s+)$/;
export enum MarkdownTableJustify {
Left = 'left',
Center = 'center',
Right = 'right,',
Right = 'right',
}
export interface MarkdownTableHeader {

View File

@@ -149,4 +149,47 @@ describe('stateToWhenClauseContext', () => {
stateToWhenClauseContext(applicationState, { commandFolderIds }),
).toHaveProperty('foldersAreDeleted', expectedDeletedState);
});
it.each([
{
label: 'should be false when no folders exist (new profile)',
selectedFolderId: 'non-existent-folder',
folders: [],
expected: false,
},
{
label: 'should be false when selectedFolderId is null',
selectedFolderId: null,
folders: [{ id: '1', deleted_time: 0, share_id: '', parent_id: '' }],
expected: false,
},
{
label: 'should be false when selected folder has been deleted',
selectedFolderId: '1',
folders: [{ id: '1', deleted_time: 1000, share_id: '', parent_id: '' }],
expected: false,
},
{
label: 'should be true when selected folder exists and is not deleted',
selectedFolderId: '1',
folders: [{ id: '1', deleted_time: 0, share_id: '', parent_id: '' }],
expected: true,
},
{
label: 'should be false when selectedFolderId does not match any folder',
selectedFolderId: 'stale-id',
folders: [{ id: '1', deleted_time: 0, share_id: '', parent_id: '' }],
expected: false,
},
])('should set selectedFolderIsValid correctly: $label', ({ selectedFolderId, folders, expected }) => {
const applicationState = buildState({
selectedFolderId,
folders,
notes: [],
});
expect(
stateToWhenClauseContext(applicationState),
).toHaveProperty('selectedFolderIsValid', expected);
});
});

View File

@@ -47,6 +47,7 @@ export interface WhenClauseContext {
noteTodoCompleted: boolean;
oneFolderSelected: boolean;
oneNoteSelected: boolean;
selectedFolderIsValid: boolean;
someNotesSelected: boolean;
syncStarted: boolean;
hasActivePluginEditor: boolean;
@@ -73,6 +74,14 @@ export default function stateToWhenClauseContext(state: State, options: WhenClau
const settings = state.settings || {};
// Check whether the window's selected folder actually exists and is not
// deleted. This resolves the case where selectedFolderId is stale (e.g.
// new profile with no notebooks) or points to a deleted folder.
const selectedFolder = windowState.selectedFolderId
? BaseModel.byId(state.folders, windowState.selectedFolderId)
: null;
const selectedFolderIsValid = !!selectedFolder && !selectedFolder.deleted_time;
return {
// Application state
notesAreBeingSaved: stateUtils.hasNotesBeingSaved(state),
@@ -98,6 +107,7 @@ export default function stateToWhenClauseContext(state: State, options: WhenClau
// Folder selection
oneFolderSelected: selectedFolderIds.length === 1,
selectedFolderIsValid,
// Current note properties
noteIsTodo: selectedNote ? !!selectedNote.is_todo : false,

View File

@@ -323,6 +323,10 @@ describe('ShareService', () => {
// in tests.
const previousLogLevel = Logger.globalLogger.setLevel(LogLevel.Error);
// checkShareConsistency only runs when the sync target supports
// sharing (Joplin Server/Cloud).
Setting.setValue('sync.target', 9);
const service = testShareFolderService({
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
'GET api/shares': async (_query: Record<string, any>, _body: any): Promise<any> => {
@@ -340,6 +344,31 @@ describe('ShareService', () => {
Logger.globalLogger.setLevel(previousLogLevel);
});
it('should skip share consistency check when not using a share-compatible sync target', async () => {
// Simulate a non-Joplin Server sync target (e.g. WebDAV = 6).
// This reproduces the scenario where a user previously used Joplin
// Server (which set share_id on folders), then switched to WebDAV.
// checkShareConsistency() should not attempt to call the Joplin
// Server API since we are no longer on a share-capable sync target.
Setting.setValue('sync.target', 6);
const service = mockShareService({
onExec: async () => {
throw new Error('Should not call the API when share is not enabled');
},
});
// Create a folder with a stale share_id leftover from Joplin Server
const folder = await Folder.save({ share_id: 'stale_share_id' });
// This should not throw or attempt any API calls
await service.checkShareConsistency();
// The folder should still exist since we cannot verify shares
// without a Joplin Server API connection
expect(await Folder.load(folder.id)).toBeTruthy();
});
it('should leave a shared folder', async () => {
const folder1 = await createFolderTree('', [
{

View File

@@ -247,6 +247,12 @@ export default class ShareService {
// necessary otherwise sync will try to update items that are not longer
// accessible and will throw the error "Could not find share with ID: xxxx")
public async checkShareConsistency() {
// Sharing is only supported on Joplin Server/Cloud. If the user is
// using a different sync target, there is no share API to query and
// any share_id values on folders are stale leftovers from a previous
// sync target configuration.
if (!this.enabled) return;
const rootSharedFolders = await Folder.rootSharedFolders(this.shares);
let hasRefreshedShares = false;
let shares = this.shares;

View File

@@ -41,7 +41,7 @@
"jquery": "3.7.1",
"knex": "3.1.0",
"koa": "2.16.3",
"ldapts": "8.0.14",
"ldapts": "8.0.18",
"markdown-it": "13.0.2",
"mustache": "4.2.0",
"node-cron": "3.0.3",

View File

@@ -258,3 +258,5 @@ clearsign
ligne
payant
llamacpp
bgcolor
bordercolor

View File

@@ -207,6 +207,7 @@
"v3.5.13": true,
"v3.6.3": true,
"android-v3.6.13": true,
"ios-v13.6.2": true
"ios-v13.6.2": true,
"v3.6.4": true
}
}

View File

@@ -119,7 +119,8 @@ rules.table = {
}
}
const captionContent = node.caption ? node.caption.textContent || '' : '';
const captionNode = node.querySelector ? node.querySelector('caption') : node.caption;
const captionContent = captionNode ? captionNode.textContent || '' : '';
const caption = captionContent ? `${captionContent}\n\n` : '';
const tableContent = `${emptyHeader}${content}`.trimStart();
return `\n\n${caption}${tableContent}\n\n`;
@@ -209,6 +210,93 @@ const nodeContains = (node, types) => {
return false;
}
// Style properties that count as user customization.
// Excludes TinyMCE/Joplin defaults:
// - border-collapse: set by default on all tables
// - width: set on every cell by TinyMCE
// - text-align: converted to Markdown alignment (:---, :---:, ---:)
// - height: false positives from TinyMCE defaults
const customStyleProperties = [
'background-color', 'background',
'border-color', 'border',
'border-top', 'border-right', 'border-bottom', 'border-left',
'border-style', 'border-width',
'padding', 'padding-top', 'padding-right', 'padding-bottom', 'padding-left',
'float', 'margin-left', 'margin-right',
];
// HTML attributes TinyMCE may set instead of CSS.
const customAttributeNames = [
'bgcolor',
'bordercolor',
'background',
];
const nodeHasCustomStyle = (node) => {
if (!node || !node.getAttribute) return false;
const styleAttr = node.getAttribute('style');
if (!styleAttr) return false;
// Extract property names from the raw style string
const properties = styleAttr.split(';')
.map(s => s.split(':')[0].trim().toLowerCase())
.filter(s => s.length > 0);
for (let i = 0; i < properties.length; i++) {
if (customStyleProperties.includes(properties[i])) return true;
}
return false;
};
const hasNonDefaultSpacingAttribute = (node, name) => {
if (!node || !node.getAttribute) return false;
const value = node.getAttribute(name);
if (value === null) return false;
const normalisedValue = `${value}`.trim().toLowerCase();
if (!normalisedValue) return false;
if (normalisedValue === '0' || normalisedValue === '0px') return false;
return true;
};
const nodeHasCustomAttributes = (node) => {
if (!node || !node.getAttribute) return false;
for (let i = 0; i < customAttributeNames.length; i++) {
const value = node.getAttribute(customAttributeNames[i]);
if (value !== null && `${value}`.trim() !== '') return true;
}
if (node.nodeName === 'TABLE') {
if (hasNonDefaultSpacingAttribute(node, 'cellpadding')) return true;
if (hasNonDefaultSpacingAttribute(node, 'cellspacing')) return true;
}
return false;
};
const nodeHasCustomFormatting = (node) => {
return nodeHasCustomStyle(node) || nodeHasCustomAttributes(node);
};
// Returns true if the table or any of its rows/cells have custom formatting.
const tableHasCustomStyles = (tableNode) => {
if (nodeHasCustomFormatting(tableNode)) return true;
const rows = tableNode.rows;
if (!rows) return false;
for (let i = 0; i < rows.length; i++) {
const row = rows[i];
if (nodeHasCustomFormatting(row)) return true;
for (let j = 0; j < row.childNodes.length; j++) {
const cell = row.childNodes[j];
if ((cell.nodeName === 'TD' || cell.nodeName === 'TH') && nodeHasCustomFormatting(cell)) {
return true;
}
}
}
return false;
};
const tableShouldBeHtml = (tableNode, options) => {
const possibleTags = [
'UL',
@@ -231,7 +319,8 @@ const tableShouldBeHtml = (tableNode, options) => {
if (options.preserveNestedTables) possibleTags.push('TABLE');
return nodeContains(tableNode, 'code') ||
nodeContains(tableNode, possibleTags);
nodeContains(tableNode, possibleTags) ||
(options.preserveTableStyles && tableHasCustomStyles(tableNode));
}
// Various conditions under which a table should be skipped - i.e. each cell

View File

@@ -4,7 +4,7 @@ export default function taskListItems (turndownService) {
const parent = node.parentNode;
const grandparent = parent.parentNode;
const grandparentIsListItem = !!grandparent && grandparent.nodeName === 'LI';
return (node.type === 'checkbox' || node.role === 'checkbox') && (
return (node.type === 'checkbox' || node.getAttribute('role') === 'checkbox') && (
parent.nodeName === 'LI'
// Handles the case where the label contains the checkbox. For example,
// <label><input ...> ...label text...</label>

View File

@@ -9,7 +9,7 @@ export default function(config) {
name: 'TurndownService',
...config.output,
},
external: ['jsdom'],
external: ['@mixmark-io/domino'],
plugins: [
commonjs(),
replace({

View File

@@ -10,8 +10,8 @@
"browser": "lib/turndown.browser.cjs.js",
"dependencies": {
"@adobe/css-tools": "4.4.4",
"html-entities": "1.4.0",
"jsdom": "26.1.0"
"@mixmark-io/domino": "2.2.0",
"html-entities": "1.4.0"
},
"devDependencies": {
"@rollup/plugin-commonjs": "28.0.9",

View File

@@ -47,9 +47,9 @@ function createHTMLParser () {
}
}
} else {
var JSDOM = require('jsdom').JSDOM
var domino = require('@mixmark-io/domino')
Parser.prototype.parseFromString = function (string) {
return new JSDOM(string).window.document
return domino.createDocument(string)
}
}
return Parser

View File

@@ -1,5 +1,28 @@
# Joplin Desktop Changelog
## [v3.6.4](https://github.com/laurent22/joplin/releases/tag/v3.6.4) (Pre-release) - 2026-03-07T17:19:55Z
- New: Add "Paste as Markdown" command for Markdown editor ([#14556](https://github.com/laurent22/joplin/issues/14556))
- New: Plugins: Add support for `joplin.fs.archiveExtract` plugin method ([#14625](https://github.com/laurent22/joplin/issues/14625))
- Improved: Add keyboard shortcuts to toolbar buttons ([#14408](https://github.com/laurent22/joplin/issues/14408)) ([#12326](https://github.com/laurent22/joplin/issues/12326) by [@akshajrawat](https://github.com/akshajrawat))
- Improved: Show feedback message when master passwords do not match ([#14566](https://github.com/laurent22/joplin/issues/14566) by [@Vinayreddy765](https://github.com/Vinayreddy765))
- Improved: Translate Find and Replace dialog in Rich Text editor ([#14529](https://github.com/laurent22/joplin/issues/14529)) ([#12210](https://github.com/laurent22/joplin/issues/12210) by [@Harsh16gupta](https://github.com/Harsh16gupta))
- Fixed: App fails to restart on Linux AppImage ([#14530](https://github.com/laurent22/joplin/issues/14530)) ([#14522](https://github.com/laurent22/joplin/issues/14522) by [@Ahmed-Idani](https://github.com/Ahmed-Idani))
- Fixed: Auto-scroll to selected note from 'Go to Anything' search results ([#14591](https://github.com/laurent22/joplin/issues/14591)) ([#12355](https://github.com/laurent22/joplin/issues/12355) by [@Harsh16gupta](https://github.com/Harsh16gupta))
- Fixed: Error message is incorrect when plugin manifest is invalid ([#14374](https://github.com/laurent22/joplin/issues/14374)) ([#14271](https://github.com/laurent22/joplin/issues/14271) by [@akshajrawat](https://github.com/akshajrawat))
- Fixed: Fix ++insert++ syntax rendering fix in markdown ([#14547](https://github.com/laurent22/joplin/issues/14547)) ([#14543](https://github.com/laurent22/joplin/issues/14543) by [@justin212407](https://github.com/justin212407))
- Fixed: Fix file:// links with backslashes for Windows UNC paths ([#14541](https://github.com/laurent22/joplin/issues/14541)) ([#14196](https://github.com/laurent22/joplin/issues/14196) by Sriram Varun Kumar)
- Fixed: Fix issue where the revision service does not start on the first launch of the app ([#14554](https://github.com/laurent22/joplin/issues/14554) by [@mrjo118](https://github.com/mrjo118))
- Fixed: Fixes zh_TW locale detection on first start ([#14527](https://github.com/laurent22/joplin/issues/14527)) ([#14500](https://github.com/laurent22/joplin/issues/14500) by Ashutosh Singh)
- Fixed: Implement cursor-aware markup rendering and hide bulletpoints on task lists ([#14573](https://github.com/laurent22/joplin/issues/14573)) ([#14564](https://github.com/laurent22/joplin/issues/14564) by [@bwat47](https://github.com/bwat47))
- Fixed: Importing from OneNote: Fix importing cross-page links ([#14567](https://github.com/laurent22/joplin/issues/14567) by [@personalizedrefrigerator](https://github.com/personalizedrefrigerator))
- Fixed: Invisible cursor in legacy editor when using dark theme in separate window ([#14557](https://github.com/laurent22/joplin/issues/14557)) ([#13178](https://github.com/laurent22/joplin/issues/13178) by [@yugalkaushik](https://github.com/yugalkaushik))
- Fixed: Normalize img alt line breaks and convert data: URLs when pasting from Word ([#14518](https://github.com/laurent22/joplin/issues/14518)) ([#13140](https://github.com/laurent22/joplin/issues/13140) by [@manjhss](https://github.com/manjhss))
- Fixed: Prevent All Notes sort order from overwriting shared notebook sort on relaunch ([#14524](https://github.com/laurent22/joplin/issues/14524)) ([#12313](https://github.com/laurent22/joplin/issues/12313) by [@manjhss](https://github.com/manjhss))
- Fixed: Prevent a failing plugin from blocking other plugins ([#14577](https://github.com/laurent22/joplin/issues/14577)) ([#12793](https://github.com/laurent22/joplin/issues/12793) by Ashutosh Singh)
- Fixed: Secondary windows no longer follow primary selection after moving notes ([#14498](https://github.com/laurent22/joplin/issues/14498)) ([#13883](https://github.com/laurent22/joplin/issues/13883) by [@parththirwani](https://github.com/parththirwani))
- Fixed: Show only relevant options in context menu when right-clicking a note link ([#14528](https://github.com/laurent22/joplin/issues/14528)) ([#14525](https://github.com/laurent22/joplin/issues/14525) by [@manjhss](https://github.com/manjhss))
## [v3.6.3](https://github.com/laurent22/joplin/releases/tag/v3.6.3) (Pre-release) - 2026-03-02T18:21:09Z
- New: Add context menu to non-image resources in Markdown editor ([#14402](https://github.com/laurent22/joplin/issues/14402))

View File

@@ -14,7 +14,7 @@ Joplin is "offline first", which means you always have all your data on your pho
The notes can be securely [synchronised](https://github.com/laurent22/joplin/blob/dev/readme/apps/sync/index.md) using [end-to-end encryption](https://github.com/laurent22/joplin/blob/dev/readme/apps/sync/e2ee.md) with various cloud services including Nextcloud, Dropbox, OneDrive and [Joplin Cloud](https://joplinapp.org/plans/).
Full text search is available on all platforms to quickly find the information you need. The app can be customised using plugins and themes, and you can also easily create your own.
Full text search is available on all platforms to quickly find the information you need. The app can be customised using [plugins](https://joplinapp.org/plugins/) and themes, and you can also easily create your own.
The application is available for Windows, Linux, macOS, Android and iOS. A [Web Clipper](https://github.com/laurent22/joplin/blob/dev/readme/apps/clipper.md), to save web pages and screenshots from your browser, is also available for [Firefox](https://addons.mozilla.org/firefox/addon/joplin-web-clipper/) and [Chrome](https://chrome.google.com/webstore/detail/joplin-web-clipper/alofnhikmmkdbbbgpnglcpdollgjjfek?hl=en-GB).

250
yarn.lock
View File

@@ -8682,184 +8682,184 @@ __metadata:
languageName: node
linkType: hard
"@esbuild/aix-ppc64@npm:0.25.12":
version: 0.25.12
resolution: "@esbuild/aix-ppc64@npm:0.25.12"
"@esbuild/aix-ppc64@npm:0.26.0":
version: 0.26.0
resolution: "@esbuild/aix-ppc64@npm:0.26.0"
conditions: os=aix & cpu=ppc64
languageName: node
linkType: hard
"@esbuild/android-arm64@npm:0.25.12":
version: 0.25.12
resolution: "@esbuild/android-arm64@npm:0.25.12"
"@esbuild/android-arm64@npm:0.26.0":
version: 0.26.0
resolution: "@esbuild/android-arm64@npm:0.26.0"
conditions: os=android & cpu=arm64
languageName: node
linkType: hard
"@esbuild/android-arm@npm:0.25.12":
version: 0.25.12
resolution: "@esbuild/android-arm@npm:0.25.12"
"@esbuild/android-arm@npm:0.26.0":
version: 0.26.0
resolution: "@esbuild/android-arm@npm:0.26.0"
conditions: os=android & cpu=arm
languageName: node
linkType: hard
"@esbuild/android-x64@npm:0.25.12":
version: 0.25.12
resolution: "@esbuild/android-x64@npm:0.25.12"
"@esbuild/android-x64@npm:0.26.0":
version: 0.26.0
resolution: "@esbuild/android-x64@npm:0.26.0"
conditions: os=android & cpu=x64
languageName: node
linkType: hard
"@esbuild/darwin-arm64@npm:0.25.12":
version: 0.25.12
resolution: "@esbuild/darwin-arm64@npm:0.25.12"
"@esbuild/darwin-arm64@npm:0.26.0":
version: 0.26.0
resolution: "@esbuild/darwin-arm64@npm:0.26.0"
conditions: os=darwin & cpu=arm64
languageName: node
linkType: hard
"@esbuild/darwin-x64@npm:0.25.12":
version: 0.25.12
resolution: "@esbuild/darwin-x64@npm:0.25.12"
"@esbuild/darwin-x64@npm:0.26.0":
version: 0.26.0
resolution: "@esbuild/darwin-x64@npm:0.26.0"
conditions: os=darwin & cpu=x64
languageName: node
linkType: hard
"@esbuild/freebsd-arm64@npm:0.25.12":
version: 0.25.12
resolution: "@esbuild/freebsd-arm64@npm:0.25.12"
"@esbuild/freebsd-arm64@npm:0.26.0":
version: 0.26.0
resolution: "@esbuild/freebsd-arm64@npm:0.26.0"
conditions: os=freebsd & cpu=arm64
languageName: node
linkType: hard
"@esbuild/freebsd-x64@npm:0.25.12":
version: 0.25.12
resolution: "@esbuild/freebsd-x64@npm:0.25.12"
"@esbuild/freebsd-x64@npm:0.26.0":
version: 0.26.0
resolution: "@esbuild/freebsd-x64@npm:0.26.0"
conditions: os=freebsd & cpu=x64
languageName: node
linkType: hard
"@esbuild/linux-arm64@npm:0.25.12":
version: 0.25.12
resolution: "@esbuild/linux-arm64@npm:0.25.12"
"@esbuild/linux-arm64@npm:0.26.0":
version: 0.26.0
resolution: "@esbuild/linux-arm64@npm:0.26.0"
conditions: os=linux & cpu=arm64
languageName: node
linkType: hard
"@esbuild/linux-arm@npm:0.25.12":
version: 0.25.12
resolution: "@esbuild/linux-arm@npm:0.25.12"
"@esbuild/linux-arm@npm:0.26.0":
version: 0.26.0
resolution: "@esbuild/linux-arm@npm:0.26.0"
conditions: os=linux & cpu=arm
languageName: node
linkType: hard
"@esbuild/linux-ia32@npm:0.25.12":
version: 0.25.12
resolution: "@esbuild/linux-ia32@npm:0.25.12"
"@esbuild/linux-ia32@npm:0.26.0":
version: 0.26.0
resolution: "@esbuild/linux-ia32@npm:0.26.0"
conditions: os=linux & cpu=ia32
languageName: node
linkType: hard
"@esbuild/linux-loong64@npm:0.25.12":
version: 0.25.12
resolution: "@esbuild/linux-loong64@npm:0.25.12"
"@esbuild/linux-loong64@npm:0.26.0":
version: 0.26.0
resolution: "@esbuild/linux-loong64@npm:0.26.0"
conditions: os=linux & cpu=loong64
languageName: node
linkType: hard
"@esbuild/linux-mips64el@npm:0.25.12":
version: 0.25.12
resolution: "@esbuild/linux-mips64el@npm:0.25.12"
"@esbuild/linux-mips64el@npm:0.26.0":
version: 0.26.0
resolution: "@esbuild/linux-mips64el@npm:0.26.0"
conditions: os=linux & cpu=mips64el
languageName: node
linkType: hard
"@esbuild/linux-ppc64@npm:0.25.12":
version: 0.25.12
resolution: "@esbuild/linux-ppc64@npm:0.25.12"
"@esbuild/linux-ppc64@npm:0.26.0":
version: 0.26.0
resolution: "@esbuild/linux-ppc64@npm:0.26.0"
conditions: os=linux & cpu=ppc64
languageName: node
linkType: hard
"@esbuild/linux-riscv64@npm:0.25.12":
version: 0.25.12
resolution: "@esbuild/linux-riscv64@npm:0.25.12"
"@esbuild/linux-riscv64@npm:0.26.0":
version: 0.26.0
resolution: "@esbuild/linux-riscv64@npm:0.26.0"
conditions: os=linux & cpu=riscv64
languageName: node
linkType: hard
"@esbuild/linux-s390x@npm:0.25.12":
version: 0.25.12
resolution: "@esbuild/linux-s390x@npm:0.25.12"
"@esbuild/linux-s390x@npm:0.26.0":
version: 0.26.0
resolution: "@esbuild/linux-s390x@npm:0.26.0"
conditions: os=linux & cpu=s390x
languageName: node
linkType: hard
"@esbuild/linux-x64@npm:0.25.12":
version: 0.25.12
resolution: "@esbuild/linux-x64@npm:0.25.12"
"@esbuild/linux-x64@npm:0.26.0":
version: 0.26.0
resolution: "@esbuild/linux-x64@npm:0.26.0"
conditions: os=linux & cpu=x64
languageName: node
linkType: hard
"@esbuild/netbsd-arm64@npm:0.25.12":
version: 0.25.12
resolution: "@esbuild/netbsd-arm64@npm:0.25.12"
"@esbuild/netbsd-arm64@npm:0.26.0":
version: 0.26.0
resolution: "@esbuild/netbsd-arm64@npm:0.26.0"
conditions: os=netbsd & cpu=arm64
languageName: node
linkType: hard
"@esbuild/netbsd-x64@npm:0.25.12":
version: 0.25.12
resolution: "@esbuild/netbsd-x64@npm:0.25.12"
"@esbuild/netbsd-x64@npm:0.26.0":
version: 0.26.0
resolution: "@esbuild/netbsd-x64@npm:0.26.0"
conditions: os=netbsd & cpu=x64
languageName: node
linkType: hard
"@esbuild/openbsd-arm64@npm:0.25.12":
version: 0.25.12
resolution: "@esbuild/openbsd-arm64@npm:0.25.12"
"@esbuild/openbsd-arm64@npm:0.26.0":
version: 0.26.0
resolution: "@esbuild/openbsd-arm64@npm:0.26.0"
conditions: os=openbsd & cpu=arm64
languageName: node
linkType: hard
"@esbuild/openbsd-x64@npm:0.25.12":
version: 0.25.12
resolution: "@esbuild/openbsd-x64@npm:0.25.12"
"@esbuild/openbsd-x64@npm:0.26.0":
version: 0.26.0
resolution: "@esbuild/openbsd-x64@npm:0.26.0"
conditions: os=openbsd & cpu=x64
languageName: node
linkType: hard
"@esbuild/openharmony-arm64@npm:0.25.12":
version: 0.25.12
resolution: "@esbuild/openharmony-arm64@npm:0.25.12"
"@esbuild/openharmony-arm64@npm:0.26.0":
version: 0.26.0
resolution: "@esbuild/openharmony-arm64@npm:0.26.0"
conditions: os=openharmony & cpu=arm64
languageName: node
linkType: hard
"@esbuild/sunos-x64@npm:0.25.12":
version: 0.25.12
resolution: "@esbuild/sunos-x64@npm:0.25.12"
"@esbuild/sunos-x64@npm:0.26.0":
version: 0.26.0
resolution: "@esbuild/sunos-x64@npm:0.26.0"
conditions: os=sunos & cpu=x64
languageName: node
linkType: hard
"@esbuild/win32-arm64@npm:0.25.12":
version: 0.25.12
resolution: "@esbuild/win32-arm64@npm:0.25.12"
"@esbuild/win32-arm64@npm:0.26.0":
version: 0.26.0
resolution: "@esbuild/win32-arm64@npm:0.26.0"
conditions: os=win32 & cpu=arm64
languageName: node
linkType: hard
"@esbuild/win32-ia32@npm:0.25.12":
version: 0.25.12
resolution: "@esbuild/win32-ia32@npm:0.25.12"
"@esbuild/win32-ia32@npm:0.26.0":
version: 0.26.0
resolution: "@esbuild/win32-ia32@npm:0.26.0"
conditions: os=win32 & cpu=ia32
languageName: node
linkType: hard
"@esbuild/win32-x64@npm:0.25.12":
version: 0.25.12
resolution: "@esbuild/win32-x64@npm:0.25.12"
"@esbuild/win32-x64@npm:0.26.0":
version: 0.26.0
resolution: "@esbuild/win32-x64@npm:0.26.0"
conditions: os=win32 & cpu=x64
languageName: node
linkType: hard
@@ -10539,7 +10539,7 @@ __metadata:
electron-builder: "npm:24.13.3"
electron-updater: "npm:6.6.8"
electron-window-state: "npm:5.0.3"
esbuild: "npm:^0.25.3"
esbuild: "npm:^0.26.0"
formatcoords: "npm:1.1.3"
fs-extra: "npm:11.3.2"
glob: "npm:11.0.3"
@@ -10642,7 +10642,7 @@ __metadata:
constants-browserify: "npm:1.0.0"
crypto-browserify: "npm:3.12.1"
deprecated-react-native-prop-types: "npm:5.0.0"
esbuild: "npm:0.25.12"
esbuild: "npm:0.26.0"
events: "npm:3.3.0"
expo: "npm:54.0.31"
expo-av: "npm:16.0.8"
@@ -10780,7 +10780,7 @@ __metadata:
"@types/react": "npm:19.1.10"
"@types/react-redux": "npm:7.1.33"
"@types/styled-components": "npm:5.1.32"
dompurify: "npm:3.3.0"
dompurify: "npm:3.3.1"
jest: "npm:29.7.0"
jest-environment-jsdom: "npm:29.7.0"
orderedmap: "npm:2.1.1"
@@ -11132,7 +11132,7 @@ __metadata:
jsdom: "npm:26.1.0"
knex: "npm:3.1.0"
koa: "npm:2.16.3"
ldapts: "npm:8.0.14"
ldapts: "npm:8.0.18"
markdown-it: "npm:13.0.2"
mustache: "npm:4.2.0"
node-cron: "npm:3.0.3"
@@ -11256,12 +11256,12 @@ __metadata:
resolution: "@joplin/turndown@workspace:packages/turndown"
dependencies:
"@adobe/css-tools": "npm:4.4.4"
"@mixmark-io/domino": "npm:2.2.0"
"@rollup/plugin-commonjs": "npm:28.0.9"
"@rollup/plugin-node-resolve": "npm:16.0.3"
"@rollup/plugin-replace": "npm:6.0.3"
browserify: "npm:14.5.0"
html-entities: "npm:1.4.0"
jsdom: "npm:26.1.0"
rollup: "npm:4.2.0"
standard: "npm:17.1.2"
turndown-attendant: "npm:0.0.3"
@@ -12639,7 +12639,7 @@ __metadata:
languageName: node
linkType: hard
"@mixmark-io/domino@npm:^2.2.0":
"@mixmark-io/domino@npm:2.2.0, @mixmark-io/domino@npm:^2.2.0":
version: 2.2.0
resolution: "@mixmark-io/domino@npm:2.2.0"
checksum: 10/839624ba6baab655c4f7393e8b8561516849926651e02f40484729b9869436b1e077906810bcac0bba4762448512d3ebd2f6d9b463d8ab0d5f54d75ca5306519
@@ -26289,7 +26289,19 @@ __metadata:
languageName: node
linkType: hard
"dompurify@npm:3.3.0, dompurify@npm:^3.2.5":
"dompurify@npm:3.3.1":
version: 3.3.1
resolution: "dompurify@npm:3.3.1"
dependencies:
"@types/trusted-types": "npm:^2.0.7"
dependenciesMeta:
"@types/trusted-types":
optional: true
checksum: 10/f71cca489e628591165d16e8cf4fa4f0d3e2ee48db4d73e9d2c5bedc6f915c92f9e9f101f8c4ba790bec0cdffe7f4e1747f5e31c69dc53ce7ae20a81ff6b0022
languageName: node
linkType: hard
"dompurify@npm:^3.2.5":
version: 3.3.0
resolution: "dompurify@npm:3.3.0"
dependencies:
@@ -27436,36 +27448,36 @@ __metadata:
languageName: node
linkType: hard
"esbuild@npm:0.25.12, esbuild@npm:^0.25.3":
version: 0.25.12
resolution: "esbuild@npm:0.25.12"
"esbuild@npm:0.26.0, esbuild@npm:^0.26.0":
version: 0.26.0
resolution: "esbuild@npm:0.26.0"
dependencies:
"@esbuild/aix-ppc64": "npm:0.25.12"
"@esbuild/android-arm": "npm:0.25.12"
"@esbuild/android-arm64": "npm:0.25.12"
"@esbuild/android-x64": "npm:0.25.12"
"@esbuild/darwin-arm64": "npm:0.25.12"
"@esbuild/darwin-x64": "npm:0.25.12"
"@esbuild/freebsd-arm64": "npm:0.25.12"
"@esbuild/freebsd-x64": "npm:0.25.12"
"@esbuild/linux-arm": "npm:0.25.12"
"@esbuild/linux-arm64": "npm:0.25.12"
"@esbuild/linux-ia32": "npm:0.25.12"
"@esbuild/linux-loong64": "npm:0.25.12"
"@esbuild/linux-mips64el": "npm:0.25.12"
"@esbuild/linux-ppc64": "npm:0.25.12"
"@esbuild/linux-riscv64": "npm:0.25.12"
"@esbuild/linux-s390x": "npm:0.25.12"
"@esbuild/linux-x64": "npm:0.25.12"
"@esbuild/netbsd-arm64": "npm:0.25.12"
"@esbuild/netbsd-x64": "npm:0.25.12"
"@esbuild/openbsd-arm64": "npm:0.25.12"
"@esbuild/openbsd-x64": "npm:0.25.12"
"@esbuild/openharmony-arm64": "npm:0.25.12"
"@esbuild/sunos-x64": "npm:0.25.12"
"@esbuild/win32-arm64": "npm:0.25.12"
"@esbuild/win32-ia32": "npm:0.25.12"
"@esbuild/win32-x64": "npm:0.25.12"
"@esbuild/aix-ppc64": "npm:0.26.0"
"@esbuild/android-arm": "npm:0.26.0"
"@esbuild/android-arm64": "npm:0.26.0"
"@esbuild/android-x64": "npm:0.26.0"
"@esbuild/darwin-arm64": "npm:0.26.0"
"@esbuild/darwin-x64": "npm:0.26.0"
"@esbuild/freebsd-arm64": "npm:0.26.0"
"@esbuild/freebsd-x64": "npm:0.26.0"
"@esbuild/linux-arm": "npm:0.26.0"
"@esbuild/linux-arm64": "npm:0.26.0"
"@esbuild/linux-ia32": "npm:0.26.0"
"@esbuild/linux-loong64": "npm:0.26.0"
"@esbuild/linux-mips64el": "npm:0.26.0"
"@esbuild/linux-ppc64": "npm:0.26.0"
"@esbuild/linux-riscv64": "npm:0.26.0"
"@esbuild/linux-s390x": "npm:0.26.0"
"@esbuild/linux-x64": "npm:0.26.0"
"@esbuild/netbsd-arm64": "npm:0.26.0"
"@esbuild/netbsd-x64": "npm:0.26.0"
"@esbuild/openbsd-arm64": "npm:0.26.0"
"@esbuild/openbsd-x64": "npm:0.26.0"
"@esbuild/openharmony-arm64": "npm:0.26.0"
"@esbuild/sunos-x64": "npm:0.26.0"
"@esbuild/win32-arm64": "npm:0.26.0"
"@esbuild/win32-ia32": "npm:0.26.0"
"@esbuild/win32-x64": "npm:0.26.0"
dependenciesMeta:
"@esbuild/aix-ppc64":
optional: true
@@ -27521,7 +27533,7 @@ __metadata:
optional: true
bin:
esbuild: bin/esbuild
checksum: 10/bc9c03d64e96a0632a926662c9d29decafb13a40e5c91790f632f02939bc568edc9abe0ee5d8055085a2819a00139eb12e223cfb8126dbf89bbc569f125d91fd
checksum: 10/c74cf3adb9e19b877ac2e4f52623fd83aeef5759b005703704f32139b0187cd7713e8e9e17e14caae8d4e67619ca8d7ce9014732866fb9bad49ee206f1f5e134
languageName: node
linkType: hard
@@ -36189,16 +36201,16 @@ __metadata:
languageName: node
linkType: hard
"ldapts@npm:8.0.14":
version: 8.0.14
resolution: "ldapts@npm:8.0.14"
"ldapts@npm:8.0.18":
version: 8.0.18
resolution: "ldapts@npm:8.0.18"
dependencies:
"@types/asn1": "npm:>=0.2.4"
asn1: "npm:0.2.6"
debug: "npm:4.4.3"
strict-event-emitter-types: "npm:2.0.0"
whatwg-url: "npm:15.1.0"
checksum: 10/0f3856cf36139be2b094f80c0e0b68f99474d859d71c6fae791ea747310d32f968bf010dcd27dd55ce29cb5a7505b059a1628f37d673b769d99c39853fb49604
checksum: 10/bc3f3a0c3bd28c51520dc1337cab5d654806a84103402976dc187433f7840a003a23b10fbb74035fbdb48200cc00c77649ef229155cf43f11bc98727455c8e5f
languageName: node
linkType: hard