You've already forked joplin
mirror of
https://github.com/laurent22/joplin.git
synced 2025-08-30 20:39:46 +02:00
Compare commits
71 Commits
android-v3
...
android-v3
Author | SHA1 | Date | |
---|---|---|---|
|
6875fd271c | ||
|
a5c14c8d10 | ||
|
ddd18551eb | ||
|
612d72d765 | ||
|
2974465882 | ||
|
f7f4a50d35 | ||
|
f1e5ab8255 | ||
|
81993628ab | ||
|
0b3f6a268e | ||
|
a2069df3e0 | ||
|
1ad150c1bf | ||
|
41b251d67a | ||
|
2c40cec639 | ||
|
efb58c5f40 | ||
|
9d8cd1d707 | ||
|
591c458a4f | ||
|
f9b1a32ae7 | ||
|
1a195e23dd | ||
|
26ae3f853e | ||
|
e84e9a58e1 | ||
|
3b8da5023d | ||
|
548d41d0d4 | ||
|
d6c921249f | ||
|
e044c50b03 | ||
|
beec74d792 | ||
|
8b4e163b28 | ||
|
b61467097d | ||
|
447e4638d1 | ||
|
b831525b20 | ||
|
e05be832d5 | ||
|
64c9c3179f | ||
|
0ea61f26eb | ||
|
349fa426ea | ||
|
e3d5f0c9cf | ||
|
e63d545ed8 | ||
|
ab3058612d | ||
|
715abcce32 | ||
|
f165b3f870 | ||
|
8895d745e7 | ||
|
33a9b96a31 | ||
|
d1ac3d415e | ||
|
432fac8fda | ||
|
0f23882d47 | ||
|
693c0f22c8 | ||
|
e2db7a6b61 | ||
|
2a74f60812 | ||
|
2419291976 | ||
|
733845eb95 | ||
|
b3315aeb03 | ||
|
d88c522d96 | ||
|
c0cefc30f4 | ||
|
0dc3589661 | ||
|
f64c3d5484 | ||
|
5fceb5a3c9 | ||
|
916b3f6f69 | ||
|
0c4e8eeafc | ||
|
b27e0ff1f4 | ||
|
59ffb0f265 | ||
|
20b4fd85c1 | ||
|
fc2da05ba6 | ||
|
948ca605b0 | ||
|
eda2c69334 | ||
|
42ab9ecd95 | ||
|
5935c9c147 | ||
|
90640e590e | ||
|
75b8caf816 | ||
|
3ea403d004 | ||
|
058a559de4 | ||
|
ac43c62ce8 | ||
|
c4a7749f2a | ||
|
e6c09da639 |
@@ -374,6 +374,7 @@ packages/app-desktop/gui/NoteListItem/utils/useItemElement.js
|
||||
packages/app-desktop/gui/NoteListItem/utils/useItemEventHandlers.js
|
||||
packages/app-desktop/gui/NoteListItem/utils/useOnContextMenu.js
|
||||
packages/app-desktop/gui/NoteListItem/utils/useRenderedNote.js
|
||||
packages/app-desktop/gui/NoteListItem/utils/useRootElement.test.js
|
||||
packages/app-desktop/gui/NoteListItem/utils/useRootElement.js
|
||||
packages/app-desktop/gui/NoteListWrapper/NoteListWrapper.js
|
||||
packages/app-desktop/gui/NotePropertiesDialog.js
|
||||
@@ -441,7 +442,6 @@ packages/app-desktop/gui/ToggleEditorsButton/ToggleEditorsButton.js
|
||||
packages/app-desktop/gui/ToggleEditorsButton/styles/index.js
|
||||
packages/app-desktop/gui/ToolbarBase.js
|
||||
packages/app-desktop/gui/ToolbarButton/ToolbarButton.js
|
||||
packages/app-desktop/gui/ToolbarButton/styles/index.js
|
||||
packages/app-desktop/gui/ToolbarSpace.js
|
||||
packages/app-desktop/gui/TrashNotification/TrashNotification.js
|
||||
packages/app-desktop/gui/UpdateNotification/UpdateNotification.js
|
||||
@@ -475,6 +475,7 @@ packages/app-desktop/integration-tests/models/NoteList.js
|
||||
packages/app-desktop/integration-tests/models/SettingsScreen.js
|
||||
packages/app-desktop/integration-tests/models/Sidebar.js
|
||||
packages/app-desktop/integration-tests/noteList.spec.js
|
||||
packages/app-desktop/integration-tests/pluginApi.spec.js
|
||||
packages/app-desktop/integration-tests/richTextEditor.spec.js
|
||||
packages/app-desktop/integration-tests/settings.spec.js
|
||||
packages/app-desktop/integration-tests/sidebar.spec.js
|
||||
@@ -786,6 +787,7 @@ packages/app-mobile/utils/shim-init-react/injectedJs.js
|
||||
packages/app-mobile/utils/shim-init-react/shimInitShared.js
|
||||
packages/app-mobile/utils/testing/createMockReduxStore.js
|
||||
packages/app-mobile/utils/testing/getWebViewDomById.js
|
||||
packages/app-mobile/utils/testing/getWebViewWindowById.js
|
||||
packages/app-mobile/utils/types.js
|
||||
packages/app-mobile/web/serviceWorker.js
|
||||
packages/default-plugins/build.js
|
||||
|
4
.gitignore
vendored
4
.gitignore
vendored
@@ -351,6 +351,7 @@ packages/app-desktop/gui/NoteListItem/utils/useItemElement.js
|
||||
packages/app-desktop/gui/NoteListItem/utils/useItemEventHandlers.js
|
||||
packages/app-desktop/gui/NoteListItem/utils/useOnContextMenu.js
|
||||
packages/app-desktop/gui/NoteListItem/utils/useRenderedNote.js
|
||||
packages/app-desktop/gui/NoteListItem/utils/useRootElement.test.js
|
||||
packages/app-desktop/gui/NoteListItem/utils/useRootElement.js
|
||||
packages/app-desktop/gui/NoteListWrapper/NoteListWrapper.js
|
||||
packages/app-desktop/gui/NotePropertiesDialog.js
|
||||
@@ -418,7 +419,6 @@ packages/app-desktop/gui/ToggleEditorsButton/ToggleEditorsButton.js
|
||||
packages/app-desktop/gui/ToggleEditorsButton/styles/index.js
|
||||
packages/app-desktop/gui/ToolbarBase.js
|
||||
packages/app-desktop/gui/ToolbarButton/ToolbarButton.js
|
||||
packages/app-desktop/gui/ToolbarButton/styles/index.js
|
||||
packages/app-desktop/gui/ToolbarSpace.js
|
||||
packages/app-desktop/gui/TrashNotification/TrashNotification.js
|
||||
packages/app-desktop/gui/UpdateNotification/UpdateNotification.js
|
||||
@@ -452,6 +452,7 @@ packages/app-desktop/integration-tests/models/NoteList.js
|
||||
packages/app-desktop/integration-tests/models/SettingsScreen.js
|
||||
packages/app-desktop/integration-tests/models/Sidebar.js
|
||||
packages/app-desktop/integration-tests/noteList.spec.js
|
||||
packages/app-desktop/integration-tests/pluginApi.spec.js
|
||||
packages/app-desktop/integration-tests/richTextEditor.spec.js
|
||||
packages/app-desktop/integration-tests/settings.spec.js
|
||||
packages/app-desktop/integration-tests/sidebar.spec.js
|
||||
@@ -763,6 +764,7 @@ packages/app-mobile/utils/shim-init-react/injectedJs.js
|
||||
packages/app-mobile/utils/shim-init-react/shimInitShared.js
|
||||
packages/app-mobile/utils/testing/createMockReduxStore.js
|
||||
packages/app-mobile/utils/testing/getWebViewDomById.js
|
||||
packages/app-mobile/utils/testing/getWebViewWindowById.js
|
||||
packages/app-mobile/utils/types.js
|
||||
packages/app-mobile/web/serviceWorker.js
|
||||
packages/default-plugins/build.js
|
||||
|
874
.yarn/releases/yarn-3.6.4.cjs
vendored
874
.yarn/releases/yarn-3.6.4.cjs
vendored
File diff suppressed because one or more lines are too long
875
.yarn/releases/yarn-3.8.3.cjs
vendored
Executable file
875
.yarn/releases/yarn-3.8.3.cjs
vendored
Executable file
File diff suppressed because one or more lines are too long
@@ -6,7 +6,7 @@ plugins:
|
||||
- path: .yarn/plugins/@yarnpkg/plugin-workspace-tools.cjs
|
||||
spec: "@yarnpkg/plugin-workspace-tools"
|
||||
|
||||
yarnPath: .yarn/releases/yarn-3.6.4.cjs
|
||||
yarnPath: .yarn/releases/yarn-3.8.3.cjs
|
||||
|
||||
logFilters:
|
||||
|
||||
|
BIN
Assets/WebsiteAssets/images/sponsors/CasinoReviews.png
Normal file
BIN
Assets/WebsiteAssets/images/sponsors/CasinoReviews.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 63 KiB |
@@ -31,7 +31,7 @@ Please see the [donation page](https://github.com/laurent22/joplin/blob/dev/read
|
||||
# Sponsors
|
||||
|
||||
<!-- SPONSORS-ORG -->
|
||||
<a href="https://seirei.ne.jp"><img title="Serei Network" width="256" src="https://joplinapp.org/images/sponsors/SeireiNetwork.png"/></a> <a href="https://www.hosting.de/nextcloud/?mtm_campaign=managed-nextcloud&mtm_kwd=joplinapp&mtm_source=joplinapp-webseite&mtm_medium=banner"><img title="Hosting.de" width="256" src="https://joplinapp.org/images/sponsors/HostingDe.png"/></a> <a href="https://citricsheep.com"><img title="Citric Sheep" width="256" src="https://joplinapp.org/images/sponsors/CitricSheep.png"/></a> <a href="https://sorted.travel/?utm_source=joplinapp"><img title="Sorted Travel" width="256" src="https://joplinapp.org/images/sponsors/SortedTravel.png"/></a> <a href="https://celebian.com"><img title="Celebian" width="256" src="https://joplinapp.org/images/sponsors/Celebian.png"/></a> <a href="https://bestkru.com"><img title="BestKru" width="256" src="https://joplinapp.org/images/sponsors/BestKru.png"/></a> <a href="https://www.socialfollowers.uk/buy-tiktok-followers/"><img title="Social Followers" width="256" src="https://joplinapp.org/images/sponsors/SocialFollowers.png"/></a> <a href="https://stormlikes.com/"><img title="Stormlikes" width="256" src="https://joplinapp.org/images/sponsors/Stormlikes.png"/></a> <a href="https://route4me.com"><img title="Route4Me" width="256" src="https://joplinapp.org/images/sponsors/Route4Me.png"/></a> <a href="https://buyyoutubviews.com"><img title="BYTV" width="256" src="https://joplinapp.org/images/sponsors/BYTV.png"/></a> <a href="https://www.famegear.com"><img title="Famegear" width="256" src="https://joplinapp.org/images/sponsors/Famegear.png"/></a>
|
||||
<a href="https://seirei.ne.jp"><img title="Serei Network" width="256" src="https://joplinapp.org/images/sponsors/SeireiNetwork.png"/></a> <a href="https://www.hosting.de/nextcloud/?mtm_campaign=managed-nextcloud&mtm_kwd=joplinapp&mtm_source=joplinapp-webseite&mtm_medium=banner"><img title="Hosting.de" width="256" src="https://joplinapp.org/images/sponsors/HostingDe.png"/></a> <a href="https://citricsheep.com"><img title="Citric Sheep" width="256" src="https://joplinapp.org/images/sponsors/CitricSheep.png"/></a> <a href="https://sorted.travel/?utm_source=joplinapp"><img title="Sorted Travel" width="256" src="https://joplinapp.org/images/sponsors/SortedTravel.png"/></a> <a href="https://celebian.com"><img title="Celebian" width="256" src="https://joplinapp.org/images/sponsors/Celebian.png"/></a> <a href="https://bestkru.com"><img title="BestKru" width="256" src="https://joplinapp.org/images/sponsors/BestKru.png"/></a> <a href="https://www.socialfollowers.uk/buy-tiktok-followers/"><img title="Social Followers" width="256" src="https://joplinapp.org/images/sponsors/SocialFollowers.png"/></a> <a href="https://stormlikes.com/"><img title="Stormlikes" width="256" src="https://joplinapp.org/images/sponsors/Stormlikes.png"/></a> <a href="https://route4me.com"><img title="Route4Me" width="256" src="https://joplinapp.org/images/sponsors/Route4Me.png"/></a> <a href="https://buyyoutubviews.com"><img title="BYTV" width="256" src="https://joplinapp.org/images/sponsors/BYTV.png"/></a> <a href="https://www.famegear.com"><img title="Famegear" width="256" src="https://joplinapp.org/images/sponsors/Famegear.png"/></a> <a href="https://casinoreviews.net"><img title="Casino Reviews" width="256" src="https://joplinapp.org/images/sponsors/CasinoReviews.png"/></a>
|
||||
<!-- SPONSORS-ORG -->
|
||||
|
||||
* * *
|
||||
|
26
package.json
26
package.json
@@ -72,38 +72,38 @@
|
||||
"@crowdin/cli": "3",
|
||||
"@joplin/utils": "~2.12",
|
||||
"@seiyab/eslint-plugin-react-hooks": "4.5.1-beta.0",
|
||||
"@typescript-eslint/eslint-plugin": "6.8.0",
|
||||
"@typescript-eslint/parser": "6.8.0",
|
||||
"@typescript-eslint/eslint-plugin": "6.21.0",
|
||||
"@typescript-eslint/parser": "6.21.0",
|
||||
"cspell": "5.21.2",
|
||||
"eslint": "8.52.0",
|
||||
"eslint": "8.57.0",
|
||||
"eslint-interactive": "10.8.0",
|
||||
"eslint-plugin-import": "2.28.1",
|
||||
"eslint-plugin-jest": "27.4.3",
|
||||
"eslint-plugin-promise": "6.1.1",
|
||||
"eslint-plugin-react": "7.33.2",
|
||||
"eslint-plugin-import": "2.29.1",
|
||||
"eslint-plugin-jest": "27.9.0",
|
||||
"eslint-plugin-promise": "6.2.0",
|
||||
"eslint-plugin-react": "7.34.3",
|
||||
"execa": "5.1.1",
|
||||
"fs-extra": "11.2.0",
|
||||
"glob": "10.4.2",
|
||||
"glob": "10.4.5",
|
||||
"gulp": "4.0.2",
|
||||
"husky": "3.1.0",
|
||||
"lerna": "3.22.1",
|
||||
"lint-staged": "15.2.7",
|
||||
"madge": "6.1.0",
|
||||
"npm-package-json-lint": "7.1.0",
|
||||
"typescript": "5.2.2"
|
||||
"typescript": "5.4.5"
|
||||
},
|
||||
"dependencies": {
|
||||
"@types/fs-extra": "11.0.4",
|
||||
"eslint-plugin-github": "4.10.1",
|
||||
"eslint-plugin-github": "4.10.2",
|
||||
"http-server": "14.1.1",
|
||||
"node-gyp": "9.4.1",
|
||||
"nodemon": "3.0.3"
|
||||
"nodemon": "3.1.7"
|
||||
},
|
||||
"packageManager": "yarn@3.6.4",
|
||||
"packageManager": "yarn@3.8.3",
|
||||
"resolutions": {
|
||||
"react-native-camera@4.2.1": "patch:react-native-camera@npm%3A4.2.1#./.yarn/patches/react-native-camera-npm-4.2.1-24b2600a7e.patch",
|
||||
"react-native-vosk@0.1.12": "patch:react-native-vosk@npm%3A0.1.12#./.yarn/patches/react-native-vosk-npm-0.1.12-76b1caaae8.patch",
|
||||
"eslint": "patch:eslint@8.52.0#./.yarn/patches/eslint-npm-8.39.0-d92bace04d.patch",
|
||||
"eslint": "patch:eslint@8.57.0#./.yarn/patches/eslint-npm-8.39.0-d92bace04d.patch",
|
||||
"app-builder-lib@24.4.0": "patch:app-builder-lib@npm%3A24.4.0#./.yarn/patches/app-builder-lib-npm-24.4.0-05322ff057.patch",
|
||||
"nanoid": "patch:nanoid@npm%3A3.3.7#./.yarn/patches/nanoid-npm-3.3.7-98824ba130.patch",
|
||||
"pdfjs-dist": "patch:pdfjs-dist@npm%3A3.11.174#./.yarn/patches/pdfjs-dist-npm-3.11.174-67f2fee6d6.patch",
|
||||
|
@@ -72,12 +72,12 @@
|
||||
"devDependencies": {
|
||||
"@joplin/tools": "~3.1",
|
||||
"@types/fs-extra": "11.0.4",
|
||||
"@types/jest": "29.5.8",
|
||||
"@types/jest": "29.5.12",
|
||||
"@types/node": "18.19.39",
|
||||
"@types/proper-lockfile": "^4.1.2",
|
||||
"gulp": "4.0.2",
|
||||
"jest": "29.7.0",
|
||||
"temp": "0.9.4",
|
||||
"typescript": "5.2.2"
|
||||
"typescript": "5.4.5"
|
||||
}
|
||||
}
|
||||
|
@@ -352,4 +352,12 @@ describe('MdToHtml', () => {
|
||||
expect(html).toContain('Inline</span>');
|
||||
expect(html).toContain('Block</span>');
|
||||
});
|
||||
|
||||
it('should sanitize KaTeX errors', async () => {
|
||||
const markdown = '$\\a<svg>$';
|
||||
const renderResult = await newTestMdToHtml().render(markdown, null, { bodyOnly: true });
|
||||
|
||||
// Should not contain the HTML in unsanitized form
|
||||
expect(renderResult.html).not.toContain('<svg>');
|
||||
});
|
||||
});
|
||||
|
@@ -5,7 +5,7 @@ import type ShimType from '@joplin/lib/shim';
|
||||
const shim: typeof ShimType = require('@joplin/lib/shim').default;
|
||||
import { isCallbackUrl } from '@joplin/lib/callbackUrlUtils';
|
||||
|
||||
import { BrowserWindow, Tray, screen } from 'electron';
|
||||
import { BrowserWindow, Tray, WebContents, screen } from 'electron';
|
||||
import bridge from './bridge';
|
||||
const url = require('url');
|
||||
const path = require('path');
|
||||
@@ -232,14 +232,35 @@ export default class ElectronAppWrapper {
|
||||
}, 3000);
|
||||
}
|
||||
|
||||
// will-frame-navigate is fired by clicking on a link within the BrowserWindow.
|
||||
this.win_.webContents.on('will-frame-navigate', event => {
|
||||
// If the link changes the URL of the browser window,
|
||||
if (event.isMainFrame) {
|
||||
event.preventDefault();
|
||||
void bridge().openExternal(event.url);
|
||||
}
|
||||
});
|
||||
const addWindowEventHandlers = (webContents: WebContents) => {
|
||||
// will-frame-navigate is fired by clicking on a link within the BrowserWindow.
|
||||
webContents.on('will-frame-navigate', event => {
|
||||
// If the link changes the URL of the browser window,
|
||||
if (event.isMainFrame) {
|
||||
event.preventDefault();
|
||||
void bridge().openExternal(event.url);
|
||||
}
|
||||
});
|
||||
|
||||
// Override calls to window.open and links with target="_blank": Open most in a browser instead
|
||||
// of Electron:
|
||||
webContents.setWindowOpenHandler((event) => {
|
||||
if (event.url === 'about:blank') {
|
||||
// Script-controlled pages: Used for opening notes in new windows
|
||||
return {
|
||||
action: 'allow',
|
||||
};
|
||||
} else if (event.url.match(/^https?:\/\//)) {
|
||||
void bridge().openExternal(event.url);
|
||||
}
|
||||
return { action: 'deny' };
|
||||
});
|
||||
|
||||
webContents.on('did-create-window', (event) => {
|
||||
addWindowEventHandlers(event.webContents);
|
||||
});
|
||||
};
|
||||
addWindowEventHandlers(this.win_.webContents);
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
|
||||
this.win_.on('close', (event: any) => {
|
||||
|
@@ -127,6 +127,12 @@ class Application extends BaseApplication {
|
||||
bridge().setLocale(Setting.value('locale'));
|
||||
}
|
||||
|
||||
if (action.type === 'SETTING_UPDATE_ONE' && action.key === 'renderer.fileUrls' || action.type === 'SETTING_UPDATE_ALL') {
|
||||
bridge().electronApp().getCustomProtocolHandler().setMediaAccessEnabled(
|
||||
Setting.value('renderer.fileUrls'),
|
||||
);
|
||||
}
|
||||
|
||||
if (action.type === 'SETTING_UPDATE_ONE' && action.key === 'showTrayIcon' || action.type === 'SETTING_UPDATE_ALL') {
|
||||
this.updateTray();
|
||||
}
|
||||
|
@@ -234,7 +234,7 @@ export default function(props: Props) {
|
||||
return (
|
||||
<CellFooter>
|
||||
<NeedUpgradeMessage>
|
||||
{PluginService.instance().describeIncompatibility(props.manifest)}
|
||||
{PluginService.instance().describeIncompatibility(item.manifest)}
|
||||
</NeedUpgradeMessage>
|
||||
</CellFooter>
|
||||
);
|
||||
|
@@ -378,6 +378,8 @@ const CodeMirror = (props: NoteBodyEditorProps, ref: ForwardedRef<NoteBodyEditor
|
||||
katexEnabled: Setting.value('markdown.plugin.katex'),
|
||||
themeData: {
|
||||
...styles.globalTheme,
|
||||
marginLeft: 0,
|
||||
marginRight: 0,
|
||||
monospaceFont: Setting.value('style.editor.monospaceFontFamily'),
|
||||
},
|
||||
automatchBraces: Setting.value('editor.autoMatchingBraces'),
|
||||
|
@@ -1,10 +1,10 @@
|
||||
|
||||
import { RefObject, useMemo } from 'react';
|
||||
import { CommandValue } from '../../../utils/types';
|
||||
import { CommandValue, DropCommandValue } from '../../../utils/types';
|
||||
import { commandAttachFileToBody } from '../../../utils/resourceHandling';
|
||||
import { _ } from '@joplin/lib/locale';
|
||||
import dialogs from '../../../../dialogs';
|
||||
import { EditorCommandType } from '@joplin/editor/types';
|
||||
import { EditorCommandType, UserEventSource } from '@joplin/editor/types';
|
||||
import Logger from '@joplin/utils/Logger';
|
||||
import CodeMirrorControl from '@joplin/editor/CodeMirror/CodeMirrorControl';
|
||||
import { MarkupLanguage } from '@joplin/renderer';
|
||||
@@ -38,13 +38,22 @@ const useEditorCommands = (props: Props) => {
|
||||
};
|
||||
|
||||
return {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
|
||||
dropItems: async (cmd: any) => {
|
||||
dropItems: async (cmd: DropCommandValue) => {
|
||||
let pos = cmd.pos && editorRef.current.editor.posAtCoords({ x: cmd.pos.clientX, y: cmd.pos.clientY });
|
||||
if (cmd.type === 'notes') {
|
||||
editorRef.current.insertText(cmd.markdownTags.join('\n'));
|
||||
const text = cmd.markdownTags.join('\n');
|
||||
if ((pos ?? null) !== null) {
|
||||
editorRef.current.select(pos, pos);
|
||||
}
|
||||
|
||||
editorRef.current.insertText(text, UserEventSource.Drop);
|
||||
} else if (cmd.type === 'files') {
|
||||
const pos = props.selectionRange.from;
|
||||
const newBody = await commandAttachFileToBody(props.editorContent, cmd.paths, { createFileURL: !!cmd.createFileURL, position: pos, markupLanguage: props.contentMarkupLanguage });
|
||||
pos ??= props.selectionRange.from;
|
||||
const newBody = await commandAttachFileToBody(props.editorContent, cmd.paths, {
|
||||
createFileURL: !!cmd.createFileURL,
|
||||
position: pos,
|
||||
markupLanguage: props.contentMarkupLanguage,
|
||||
});
|
||||
editorRef.current.updateBody(newBody);
|
||||
} else {
|
||||
logger.warn('CodeMirror: unsupported drop item: ', cmd);
|
||||
|
@@ -218,15 +218,6 @@ function NoteEditor(props: NoteEditorProps) {
|
||||
}
|
||||
}, [handleProvisionalFlag, formNote, setFormNote, isNewNote, titleHasBeenManuallyChanged, scheduleNoteListResort, scheduleSaveNote]);
|
||||
|
||||
useWindowCommandHandler({
|
||||
dispatch: props.dispatch,
|
||||
setShowLocalSearch,
|
||||
noteSearchBarRef,
|
||||
editorRef,
|
||||
titleInputRef,
|
||||
setFormNote,
|
||||
});
|
||||
|
||||
const onDrop = useDropHandler({ editorRef });
|
||||
|
||||
const onBodyChange = useCallback((event: OnChangeEvent) => onFieldChange('body', event.content, event.changeId), [onFieldChange]);
|
||||
@@ -234,6 +225,15 @@ function NoteEditor(props: NoteEditorProps) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
|
||||
const onTitleChange = useCallback((event: any) => onFieldChange('title', event.target.value), [onFieldChange]);
|
||||
|
||||
useWindowCommandHandler({
|
||||
dispatch: props.dispatch,
|
||||
setShowLocalSearch,
|
||||
noteSearchBarRef,
|
||||
editorRef,
|
||||
titleInputRef,
|
||||
onBodyChange,
|
||||
});
|
||||
|
||||
// const onTitleKeydown = useCallback((event:any) => {
|
||||
// const keyCode = event.keyCode;
|
||||
|
||||
|
@@ -5,30 +5,6 @@ import { ChangeEvent, useCallback, useRef } from 'react';
|
||||
import NoteToolbar from '../../NoteToolbar/NoteToolbar';
|
||||
import { buildStyle } from '@joplin/lib/theme';
|
||||
import time from '@joplin/lib/time';
|
||||
import styled from 'styled-components';
|
||||
|
||||
const StyledRoot = styled.div`
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
padding-left: ${props => props.theme.editorPaddingLeft}px;
|
||||
|
||||
@media (max-width: 800px) {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
}
|
||||
`;
|
||||
|
||||
const InfoGroup = styled.div`
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
|
||||
@media (max-width: 800px) {
|
||||
border-top: 1px solid ${props => props.theme.dividerColor};
|
||||
width: 100%;
|
||||
}
|
||||
`;
|
||||
|
||||
interface Props {
|
||||
themeId: number;
|
||||
@@ -130,7 +106,7 @@ export default function NoteTitleBar(props: Props) {
|
||||
}
|
||||
|
||||
return (
|
||||
<StyledRoot>
|
||||
<div className='note-title-wrapper'>
|
||||
<input
|
||||
className="title-input"
|
||||
type="text"
|
||||
@@ -144,10 +120,10 @@ export default function NoteTitleBar(props: Props) {
|
||||
onBlur={onTitleBlur}
|
||||
value={props.noteTitle}
|
||||
/>
|
||||
<InfoGroup>
|
||||
<div className='note-title-info-group'>
|
||||
{renderTitleBarDate()}
|
||||
{renderNoteToolbar()}
|
||||
</InfoGroup>
|
||||
</StyledRoot>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
@@ -38,7 +38,6 @@ const incompatiblePluginIds = [
|
||||
'ylc395.noteLinkSystem',
|
||||
'outline',
|
||||
'joplin.plugin.cmoptions',
|
||||
'plugin.calebjohn.MathMode',
|
||||
'com.ckant.joplin-plugin-better-code-blocks',
|
||||
// cSpell:enable
|
||||
];
|
||||
|
@@ -1,3 +1,5 @@
|
||||
|
||||
@use "./styles/warning-banner.scss";
|
||||
@use "./styles/warning-banner-link.scss";
|
||||
@use "./styles/note-title-info-group.scss";
|
||||
@use "./styles/note-title-wrapper.scss";
|
||||
|
@@ -0,0 +1,11 @@
|
||||
|
||||
.note-title-info-group {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
|
||||
@media (max-width: 800px) {
|
||||
border-top: 1px solid var(--joplin-divider-color);
|
||||
width: 100%;
|
||||
}
|
||||
}
|
@@ -0,0 +1,12 @@
|
||||
|
||||
.note-title-wrapper {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
padding-left: var(--joplin-editor-padding-left);
|
||||
|
||||
@media (max-width: 800px) {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
}
|
||||
}
|
@@ -252,3 +252,19 @@ export interface CommandValue {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
|
||||
value?: any; // For TinyMCE only
|
||||
}
|
||||
|
||||
type DropCommandBase = {
|
||||
pos: {
|
||||
clientX: number;
|
||||
clientY: number;
|
||||
}|undefined;
|
||||
};
|
||||
|
||||
export type DropCommandValue = ({
|
||||
type: 'notes';
|
||||
markdownTags: string[];
|
||||
}|{
|
||||
type: 'files';
|
||||
paths: string[];
|
||||
createFileURL: boolean;
|
||||
}) & DropCommandBase;
|
||||
|
@@ -1,6 +1,7 @@
|
||||
import { useCallback } from 'react';
|
||||
import Note from '@joplin/lib/models/Note';
|
||||
import { DragEvent as ReactDragEvent } from 'react';
|
||||
import { DropCommandValue } from './types';
|
||||
|
||||
interface HookDependencies {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
|
||||
@@ -19,6 +20,11 @@ export default function useDropHandler(dependencies: HookDependencies): DropHand
|
||||
const dt = event.dataTransfer;
|
||||
const createFileURL = event.altKey;
|
||||
|
||||
const eventPosition = {
|
||||
clientX: event.clientX,
|
||||
clientY: event.clientY,
|
||||
};
|
||||
|
||||
if (dt.types.indexOf('text/x-jop-note-ids') >= 0) {
|
||||
const noteIds = JSON.parse(dt.getData('text/x-jop-note-ids'));
|
||||
|
||||
@@ -29,12 +35,15 @@ export default function useDropHandler(dependencies: HookDependencies): DropHand
|
||||
noteMarkdownTags.push(Note.markdownTag(note));
|
||||
}
|
||||
|
||||
const props: DropCommandValue = {
|
||||
type: 'notes',
|
||||
pos: eventPosition,
|
||||
markdownTags: noteMarkdownTags,
|
||||
};
|
||||
|
||||
editorRef.current.execCommand({
|
||||
name: 'dropItems',
|
||||
value: {
|
||||
type: 'notes',
|
||||
markdownTags: noteMarkdownTags,
|
||||
},
|
||||
value: props,
|
||||
});
|
||||
};
|
||||
void dropNotes();
|
||||
@@ -51,13 +60,16 @@ export default function useDropHandler(dependencies: HookDependencies): DropHand
|
||||
paths.push(file.path);
|
||||
}
|
||||
|
||||
const props: DropCommandValue = {
|
||||
type: 'files',
|
||||
pos: eventPosition,
|
||||
paths: paths,
|
||||
createFileURL: createFileURL,
|
||||
};
|
||||
|
||||
editorRef.current.execCommand({
|
||||
name: 'dropItems',
|
||||
value: {
|
||||
type: 'files',
|
||||
paths: paths,
|
||||
createFileURL: createFileURL,
|
||||
},
|
||||
value: props,
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
@@ -1,5 +1,5 @@
|
||||
import { RefObject, useEffect } from 'react';
|
||||
import { FormNote, NoteBodyEditorRef, ScrollOptionTypes } from './types';
|
||||
import { NoteBodyEditorRef, OnChangeEvent, ScrollOptionTypes } from './types';
|
||||
import editorCommandDeclarations, { enabledCondition } from '../editorCommandDeclarations';
|
||||
import CommandService, { CommandDeclaration, CommandRuntime, CommandContext } from '@joplin/lib/services/CommandService';
|
||||
import time from '@joplin/lib/time';
|
||||
@@ -12,7 +12,7 @@ const commandsWithDependencies = [
|
||||
require('../commands/pasteAsText'),
|
||||
];
|
||||
|
||||
type SetFormNoteCallback = (callback: (prev: FormNote)=> FormNote)=> void;
|
||||
type OnBodyChange = (event: OnChangeEvent)=> void;
|
||||
|
||||
interface HookDependencies {
|
||||
// eslint-disable-next-line @typescript-eslint/ban-types -- Old code before rule was applied
|
||||
@@ -23,13 +23,13 @@ interface HookDependencies {
|
||||
noteSearchBarRef: any;
|
||||
editorRef: RefObject<NoteBodyEditorRef>;
|
||||
titleInputRef: RefObject<HTMLInputElement>;
|
||||
setFormNote: SetFormNoteCallback;
|
||||
onBodyChange: OnBodyChange;
|
||||
}
|
||||
|
||||
function editorCommandRuntime(
|
||||
declaration: CommandDeclaration,
|
||||
editorRef: RefObject<NoteBodyEditorRef>,
|
||||
setFormNote: SetFormNoteCallback,
|
||||
onBodyChange: OnBodyChange,
|
||||
): CommandRuntime {
|
||||
return {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
|
||||
@@ -55,9 +55,7 @@ function editorCommandRuntime(
|
||||
value: args[0],
|
||||
});
|
||||
} else if (declaration.name === 'editor.setText') {
|
||||
setFormNote((prev: FormNote) => {
|
||||
return { ...prev, body: args[0] };
|
||||
});
|
||||
onBodyChange({ content: args[0], changeId: 0 });
|
||||
} else {
|
||||
return editorRef.current.execCommand({
|
||||
name: declaration.name,
|
||||
@@ -78,11 +76,11 @@ function editorCommandRuntime(
|
||||
}
|
||||
|
||||
export default function useWindowCommandHandler(dependencies: HookDependencies) {
|
||||
const { setShowLocalSearch, noteSearchBarRef, editorRef, titleInputRef, setFormNote } = dependencies;
|
||||
const { setShowLocalSearch, noteSearchBarRef, editorRef, titleInputRef, onBodyChange } = dependencies;
|
||||
|
||||
useEffect(() => {
|
||||
for (const declaration of editorCommandDeclarations) {
|
||||
CommandService.instance().registerRuntime(declaration.name, editorCommandRuntime(declaration, editorRef, setFormNote));
|
||||
CommandService.instance().registerRuntime(declaration.name, editorCommandRuntime(declaration, editorRef, onBodyChange));
|
||||
}
|
||||
|
||||
const dependencies = {
|
||||
@@ -105,5 +103,5 @@ export default function useWindowCommandHandler(dependencies: HookDependencies)
|
||||
CommandService.instance().unregisterRuntime(command.declaration.name);
|
||||
}
|
||||
};
|
||||
}, [editorRef, setShowLocalSearch, noteSearchBarRef, titleInputRef, setFormNote]);
|
||||
}, [editorRef, setShowLocalSearch, noteSearchBarRef, titleInputRef, onBodyChange]);
|
||||
}
|
||||
|
@@ -1,5 +1,5 @@
|
||||
import * as React from 'react';
|
||||
import { useMemo, useRef, useEffect } from 'react';
|
||||
import { useMemo, useRef, useEffect, useCallback } from 'react';
|
||||
import { AppState } from '../../app.reducer';
|
||||
import BaseModel, { ModelType } from '@joplin/lib/BaseModel';
|
||||
import { Props } from './utils/types';
|
||||
@@ -275,6 +275,12 @@ const NoteList = (props: Props) => {
|
||||
return output;
|
||||
}, [listRenderer.flow]);
|
||||
|
||||
const onContainerContextMenu = useCallback((event: React.MouseEvent) => {
|
||||
const isFromKeyboard = event.button === -1;
|
||||
if (event.isDefaultPrevented() || !isFromKeyboard) return;
|
||||
onItemContextMenu({ itemId: activeNoteId });
|
||||
}, [onItemContextMenu, activeNoteId]);
|
||||
|
||||
return (
|
||||
<div
|
||||
role='listbox'
|
||||
@@ -293,6 +299,7 @@ const NoteList = (props: Props) => {
|
||||
onKeyDown={onKeyDown}
|
||||
onKeyUp={onKeyUp}
|
||||
onDrop={onDrop}
|
||||
onContextMenu={onContainerContextMenu}
|
||||
>
|
||||
{renderEmptyList()}
|
||||
{renderFiller('top', topFillerStyle)}
|
||||
|
@@ -1,3 +1,4 @@
|
||||
import * as React from 'react';
|
||||
import Folder from '@joplin/lib/models/Folder';
|
||||
import { NoteEntity } from '@joplin/lib/services/database/types';
|
||||
import { PluginStates } from '@joplin/lib/services/plugins/reducer';
|
||||
@@ -6,6 +7,13 @@ import { Dispatch } from 'redux';
|
||||
import bridge from '../../../services/bridge';
|
||||
import NoteListUtils from '../../utils/NoteListUtils';
|
||||
|
||||
interface CustomContextMenuEvent {
|
||||
itemId: string;
|
||||
currentTarget?: undefined;
|
||||
preventDefault?: undefined;
|
||||
}
|
||||
type ContextMenuEvent = React.MouseEvent|CustomContextMenuEvent;
|
||||
|
||||
const useOnContextMenu = (
|
||||
selectedNoteIds: string[],
|
||||
selectedFolderId: string,
|
||||
@@ -15,10 +23,14 @@ const useOnContextMenu = (
|
||||
plugins: PluginStates,
|
||||
customCss: string,
|
||||
) => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
|
||||
return useCallback((event: any) => {
|
||||
const currentNoteId = event.currentTarget.getAttribute('data-id');
|
||||
return useCallback((event: ContextMenuEvent) => {
|
||||
let currentNoteId = event.currentTarget?.getAttribute('data-id');
|
||||
if ('itemId' in event) {
|
||||
currentNoteId = event.itemId;
|
||||
}
|
||||
|
||||
if (!currentNoteId) return;
|
||||
event.preventDefault?.();
|
||||
|
||||
let noteIds = [];
|
||||
if (selectedNoteIds.indexOf(currentNoteId) < 0) {
|
||||
|
@@ -0,0 +1,55 @@
|
||||
import { act, renderHook } from '@testing-library/react-hooks';
|
||||
import useRootElement from './useRootElement';
|
||||
|
||||
describe('useRootElement', () => {
|
||||
beforeEach(() => {
|
||||
jest.useFakeTimers({ advanceTimers: true });
|
||||
});
|
||||
|
||||
test('should find an element with a matching ID', async () => {
|
||||
const testElement = document.createElement('div');
|
||||
testElement.id = 'test-element-id';
|
||||
document.body.appendChild(testElement);
|
||||
|
||||
const { result } = renderHook(useRootElement, {
|
||||
initialProps: testElement.id,
|
||||
});
|
||||
await act(async () => {
|
||||
await jest.advanceTimersByTimeAsync(100);
|
||||
});
|
||||
expect(result.current).toBe(testElement);
|
||||
|
||||
testElement.remove();
|
||||
});
|
||||
|
||||
test('should redo the element search when the elementId prop changes', async () => {
|
||||
const testElement = document.createElement('div');
|
||||
document.body.appendChild(testElement);
|
||||
|
||||
const { rerender, result } = renderHook(useRootElement, {
|
||||
initialProps: 'some-id-here',
|
||||
});
|
||||
await jest.advanceTimersByTimeAsync(100);
|
||||
expect(result.current).toBe(null);
|
||||
|
||||
// Searching for another non-existent ID: Should not match
|
||||
rerender('updated-id');
|
||||
await jest.advanceTimersByTimeAsync(100);
|
||||
expect(result.current).toBe(null);
|
||||
|
||||
// Should not match the first element if its ID is set to the original (search
|
||||
// should be cancelled).
|
||||
testElement.id = 'some-id-here';
|
||||
await jest.advanceTimersByTimeAsync(100);
|
||||
expect(result.current).toBe(null);
|
||||
|
||||
// Should match if the element ID changes to the updated ID.
|
||||
await act(async () => {
|
||||
testElement.id = 'updated-id';
|
||||
await jest.advanceTimersByTimeAsync(100);
|
||||
});
|
||||
expect(result.current).toBe(testElement);
|
||||
|
||||
testElement.remove();
|
||||
});
|
||||
});
|
@@ -6,7 +6,7 @@ const useRootElement = (elementId: string) => {
|
||||
const [rootElement, setRootElement] = useState<HTMLDivElement>(null);
|
||||
|
||||
useAsyncEffect(async (event) => {
|
||||
const element = await waitForElement(document, elementId);
|
||||
const element = await waitForElement(document, elementId, event);
|
||||
if (event.cancelled) return;
|
||||
setRootElement(element);
|
||||
}, [document, elementId]);
|
||||
|
@@ -29,6 +29,7 @@ export default class NoteTextViewerComponent extends React.Component<Props, any>
|
||||
private webviewRef_: React.RefObject<HTMLIFrameElement>;
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
|
||||
private webviewListeners_: any = null;
|
||||
|
||||
private removePluginAssetsCallback_: RemovePluginAssetsCallback|null = null;
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
|
||||
@@ -110,7 +111,7 @@ export default class NoteTextViewerComponent extends React.Component<Props, any>
|
||||
window.addEventListener('message', this.webview_message);
|
||||
}
|
||||
|
||||
public destroyWebview() {
|
||||
private destroyWebview() {
|
||||
const wv = this.webviewRef_.current;
|
||||
if (!wv || !this.initialized_) return;
|
||||
|
||||
@@ -194,14 +195,13 @@ export default class NoteTextViewerComponent extends React.Component<Props, any>
|
||||
}
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
|
||||
public setHtml(html: string, options: SetHtmlOptions) {
|
||||
const protocolHandler = bridge().electronApp().getCustomProtocolHandler();
|
||||
|
||||
// Grant & remove asset access.
|
||||
if (options.pluginAssets) {
|
||||
this.removePluginAssetsCallback_?.();
|
||||
|
||||
const protocolHandler = bridge().electronApp().getCustomProtocolHandler();
|
||||
|
||||
const pluginAssetPaths: string[] = options.pluginAssets.map((asset) => asset.path);
|
||||
const assetAccesses = pluginAssetPaths.map(
|
||||
path => protocolHandler.allowReadAccessToFile(path),
|
||||
@@ -216,7 +216,10 @@ export default class NoteTextViewerComponent extends React.Component<Props, any>
|
||||
};
|
||||
}
|
||||
|
||||
this.send('setHtml', html, options);
|
||||
this.send('setHtml', html, {
|
||||
...options,
|
||||
mediaAccessKey: protocolHandler.getMediaAccessKey(),
|
||||
});
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------
|
||||
|
@@ -1,6 +1,5 @@
|
||||
import * as React from 'react';
|
||||
import { ToolbarButtonInfo } from '@joplin/lib/services/commands/ToolbarButtonUtils';
|
||||
import { StyledIconSpan, StyledIconI } from './styles';
|
||||
|
||||
interface Props {
|
||||
readonly themeId: number;
|
||||
@@ -36,8 +35,12 @@ export default function ToolbarButton(props: Props) {
|
||||
let icon = null;
|
||||
const iconName = getProp(props, 'iconName');
|
||||
if (iconName) {
|
||||
const IconClass = isFontAwesomeIcon(iconName) ? StyledIconI : StyledIconSpan;
|
||||
icon = <IconClass className={iconName} aria-label='' hasTitle={!!title} role='img'/>;
|
||||
const iconProps: React.HTMLProps<HTMLDivElement> = {
|
||||
'aria-label': '',
|
||||
role: 'img',
|
||||
className: `toolbar-icon ${title ? '-has-title' : ''} ${iconName}`,
|
||||
};
|
||||
icon = isFontAwesomeIcon(iconName) ? <i {...iconProps} /> : <span {...iconProps} />;
|
||||
}
|
||||
|
||||
// Keep this for legacy compatibility but for consistency we should use "disabled" prop
|
||||
|
@@ -1,19 +0,0 @@
|
||||
import { ThemeStyle } from '@joplin/lib/theme';
|
||||
|
||||
const styled = require('styled-components').default;
|
||||
const { css } = require('styled-components');
|
||||
|
||||
interface IconProps {
|
||||
readonly theme: ThemeStyle;
|
||||
readonly hasTitle: boolean;
|
||||
}
|
||||
|
||||
const iconStyle = css<IconProps>`
|
||||
font-size: ${(props: IconProps) => props.theme.toolbarIconSize}px;
|
||||
color: ${(props: IconProps) => props.theme.color3};
|
||||
margin-right: ${(props: IconProps) => props.hasTitle ? 5 : 0}px;
|
||||
pointer-events: none; /* Need this to get button tooltip to work */
|
||||
`;
|
||||
|
||||
export const StyledIconI = styled.i`${iconStyle}`;
|
||||
export const StyledIconSpan = styled.span`${iconStyle}`;
|
@@ -377,6 +377,20 @@
|
||||
contentElement.scrollTop = scrollTop;
|
||||
}
|
||||
|
||||
const rewriteFileUrls = (accessKey) => {
|
||||
if (!accessKey) return;
|
||||
|
||||
// To allow accessing local files from the viewer's non-file URL, file:// URLs are re-written
|
||||
// to joplin-content:// URLs:
|
||||
const mediaElements = document.querySelectorAll('video[src], audio[src], source[src], img[src]');
|
||||
for (const element of mediaElements) {
|
||||
if (element.src?.startsWith('file:')) {
|
||||
const newUrl = element.src.replace(/^file:\/\//, 'joplin-content://file-media/');
|
||||
element.src = `${newUrl}?access-key=${accessKey}`;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
ipc.setHtml = (event) => {
|
||||
const html = event.html;
|
||||
|
||||
@@ -388,6 +402,10 @@
|
||||
|
||||
contentElement.innerHTML = html;
|
||||
|
||||
if (html.includes('file://')) {
|
||||
rewriteFileUrls(event.options.mediaAccessKey);
|
||||
}
|
||||
|
||||
scrollmap.create(event.options.markupLineCount);
|
||||
if (typeof event.options.percent !== 'number') {
|
||||
restorePercentScroll(); // First, a quick treatment is applied.
|
||||
@@ -733,6 +751,13 @@
|
||||
}));
|
||||
|
||||
document.addEventListener('click', webviewLib.logEnabledEventHandler(e => {
|
||||
// Links should all have custom click handlers. Allowing Electron to load custom links
|
||||
// can cause security issues, particularly if these links have the same domain as the
|
||||
// top-level page.
|
||||
if (e.target.hasAttribute('href')) {
|
||||
e.preventDefault();
|
||||
}
|
||||
|
||||
document.querySelectorAll('.media-pdf').forEach(element => {
|
||||
if(!!element.contentWindow){
|
||||
element.contentWindow.postMessage({
|
||||
|
4
packages/app-desktop/gui/styles/dialog-anchor-node.scss
Normal file
4
packages/app-desktop/gui/styles/dialog-anchor-node.scss
Normal file
@@ -0,0 +1,4 @@
|
||||
|
||||
.dialog-anchor-node {
|
||||
display: none;
|
||||
}
|
@@ -5,4 +5,7 @@
|
||||
@use './flat-button.scss';
|
||||
@use './help-text.scss';
|
||||
@use './toolbar-button.scss';
|
||||
@use './toolbar-icon.scss';
|
||||
@use './editor-toolbar.scss';
|
||||
@use './user-webview-dialog-container.scss';
|
||||
@use './dialog-anchor-node.scss';
|
||||
|
11
packages/app-desktop/gui/styles/toolbar-icon.scss
Normal file
11
packages/app-desktop/gui/styles/toolbar-icon.scss
Normal file
@@ -0,0 +1,11 @@
|
||||
|
||||
.toolbar-icon {
|
||||
font-size: var(--joplin-toolbar-icon-size);
|
||||
color: var(--joplin-color3);
|
||||
margin-right: 0px;
|
||||
pointer-events: none; /* Need this to get button tooltip to work */
|
||||
|
||||
&.-has-title {
|
||||
margin-right: 5px;
|
||||
}
|
||||
}
|
@@ -0,0 +1,11 @@
|
||||
|
||||
.user-webview-dialog-container {
|
||||
display: flex;
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
z-index: 1000;
|
||||
box-sizing: border-box;
|
||||
}
|
@@ -136,50 +136,55 @@ test.describe('main', () => {
|
||||
expect(fullSize[0] / resizedSize[0]).toBeCloseTo(fullSize[1] / resizedSize[1]);
|
||||
});
|
||||
|
||||
test('clicking on an external link should try to launch a browser', async ({ electronApp, mainWindow }) => {
|
||||
const mainScreen = new MainScreen(mainWindow);
|
||||
await mainScreen.waitFor();
|
||||
for (const target of ['', '_blank']) {
|
||||
test(`clicking on an external link with target=${JSON.stringify(target)} should try to launch a browser`, async ({ electronApp, mainWindow }) => {
|
||||
const mainScreen = new MainScreen(mainWindow);
|
||||
await mainScreen.waitFor();
|
||||
|
||||
// Mock openExternal
|
||||
const nextExternalUrlPromise = electronApp.evaluate(({ shell }) => {
|
||||
return new Promise<string>(resolve => {
|
||||
const openExternal = async (url: string) => {
|
||||
resolve(url);
|
||||
};
|
||||
shell.openExternal = openExternal;
|
||||
// Mock openExternal
|
||||
const nextExternalUrlPromise = electronApp.evaluate(({ shell }) => {
|
||||
return new Promise<string>(resolve => {
|
||||
const openExternal = async (url: string) => {
|
||||
resolve(url);
|
||||
};
|
||||
shell.openExternal = openExternal;
|
||||
});
|
||||
});
|
||||
|
||||
// Create a test link
|
||||
const testLinkTitle = 'This is a test link!';
|
||||
const linkHref = 'https://joplinapp.org/';
|
||||
|
||||
await mainWindow.evaluate(({ testLinkTitle, linkHref, target }) => {
|
||||
const testLink = document.createElement('a');
|
||||
testLink.textContent = testLinkTitle;
|
||||
testLink.onclick = () => {
|
||||
// We need to navigate by setting location.href -- clicking on a link
|
||||
// directly within the main window (i.e. not in a PDF viewer) doesn't
|
||||
// navigate.
|
||||
location.href = linkHref;
|
||||
};
|
||||
testLink.href = '#';
|
||||
|
||||
// Display on top of everything
|
||||
testLink.style.zIndex = '99999';
|
||||
testLink.style.position = 'fixed';
|
||||
testLink.style.top = '0';
|
||||
testLink.style.left = '0';
|
||||
if (target) {
|
||||
testLink.target = target;
|
||||
}
|
||||
|
||||
document.body.appendChild(testLink);
|
||||
}, { testLinkTitle, linkHref, target });
|
||||
|
||||
const testLink = mainWindow.getByText(testLinkTitle);
|
||||
await expect(testLink).toBeVisible();
|
||||
await testLink.click({ noWaitAfter: true });
|
||||
|
||||
expect(await nextExternalUrlPromise).toBe(linkHref);
|
||||
});
|
||||
|
||||
// Create a test link
|
||||
const testLinkTitle = 'This is a test link!';
|
||||
const linkHref = 'https://joplinapp.org/';
|
||||
|
||||
await mainWindow.evaluate(({ testLinkTitle, linkHref }) => {
|
||||
const testLink = document.createElement('a');
|
||||
testLink.textContent = testLinkTitle;
|
||||
testLink.onclick = () => {
|
||||
// We need to navigate by setting location.href -- clicking on a link
|
||||
// directly within the main window (i.e. not in a PDF viewer) doesn't
|
||||
// navigate.
|
||||
location.href = linkHref;
|
||||
};
|
||||
testLink.href = '#';
|
||||
|
||||
// Display on top of everything
|
||||
testLink.style.zIndex = '99999';
|
||||
testLink.style.position = 'fixed';
|
||||
testLink.style.top = '0';
|
||||
testLink.style.left = '0';
|
||||
|
||||
document.body.appendChild(testLink);
|
||||
}, { testLinkTitle, linkHref });
|
||||
|
||||
const testLink = mainWindow.getByText(testLinkTitle);
|
||||
await expect(testLink).toBeVisible();
|
||||
await testLink.click({ noWaitAfter: true });
|
||||
|
||||
expect(await nextExternalUrlPromise).toBe(linkHref);
|
||||
});
|
||||
}
|
||||
|
||||
test('should start in safe mode if profile-dir/force-safe-mode-on-next-start exists', async ({ profileDirectory }) => {
|
||||
await writeFile(join(profileDirectory, 'force-safe-mode-on-next-start'), 'true', 'utf8');
|
||||
|
@@ -30,4 +30,16 @@ export default class GoToAnything {
|
||||
public async expectToBeOpen() {
|
||||
await expect(this.containerLocator).toBeAttached();
|
||||
}
|
||||
|
||||
public async runCommand(electronApp: ElectronApplication, command: string) {
|
||||
if (!command.startsWith(':')) {
|
||||
command = `:${command}`;
|
||||
}
|
||||
|
||||
await this.open(electronApp);
|
||||
await this.inputLocator.fill(command);
|
||||
await this.containerLocator.locator('.match-highlight').first().waitFor();
|
||||
await this.inputLocator.press('Enter');
|
||||
await this.expectToBeClosed();
|
||||
}
|
||||
}
|
||||
|
@@ -1,5 +1,6 @@
|
||||
|
||||
import { Locator, Page } from '@playwright/test';
|
||||
import { expect } from '../util/test';
|
||||
|
||||
export default class NoteEditorPage {
|
||||
public readonly codeMirrorEditor: Locator;
|
||||
@@ -31,6 +32,31 @@ export default class NoteEditorPage {
|
||||
return this.containerLocator.getByRole('button', { name: title });
|
||||
}
|
||||
|
||||
public async contentLocator() {
|
||||
const richTextBody = this.getRichTextFrameLocator().locator('body');
|
||||
const markdownEditor = this.codeMirrorEditor;
|
||||
|
||||
// Work around an issue where .or doesn't work with frameLocators.
|
||||
// See https://github.com/microsoft/playwright/issues/27688#issuecomment-1771403495
|
||||
await Promise.race([
|
||||
richTextBody.waitFor({ state: 'visible' }).catch(()=>{}),
|
||||
markdownEditor.waitFor({ state: 'visible' }).catch(()=>{}),
|
||||
]);
|
||||
if (await richTextBody.isVisible()) {
|
||||
return richTextBody;
|
||||
} else {
|
||||
return markdownEditor;
|
||||
}
|
||||
}
|
||||
|
||||
public async expectToHaveText(content: string) {
|
||||
// expect(...).toHaveText can fail in the Rich Text Editor (perhaps due to frame locators).
|
||||
// Using expect.poll refreshes the locator on each attempt, which seems to prevent flakiness.
|
||||
await expect.poll(
|
||||
async () => (await this.contentLocator()).textContent(),
|
||||
).toBe(content);
|
||||
}
|
||||
|
||||
public getNoteViewerFrameLocator() {
|
||||
// The note viewer can change content when the note re-renders. As such,
|
||||
// a new locator needs to be created after re-renders (and this can't be a
|
||||
@@ -38,7 +64,7 @@ export default class NoteEditorPage {
|
||||
return this.noteViewerContainer.frameLocator(':scope');
|
||||
}
|
||||
|
||||
public getTinyMCEFrameLocator() {
|
||||
public getRichTextFrameLocator() {
|
||||
// We use frameLocator(':scope') to convert the richTextEditor Locator into
|
||||
// a FrameLocator. (:scope selects the locator itself).
|
||||
// https://playwright.dev/docs/api/class-framelocator
|
||||
@@ -53,4 +79,10 @@ export default class NoteEditorPage {
|
||||
await this.noteTitleInput.waitFor();
|
||||
await this.toggleEditorsButton.waitFor();
|
||||
}
|
||||
|
||||
public async goBack() {
|
||||
const backButton = this.toolbarButtonLocator('Back');
|
||||
await expect(backButton).not.toBeDisabled();
|
||||
await backButton.click();
|
||||
}
|
||||
}
|
||||
|
32
packages/app-desktop/integration-tests/pluginApi.spec.ts
Normal file
32
packages/app-desktop/integration-tests/pluginApi.spec.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
|
||||
import { test } from './util/test';
|
||||
import MainScreen from './models/MainScreen';
|
||||
|
||||
test.describe('pluginApi', () => {
|
||||
for (const richTextEditor of [false, true]) {
|
||||
test(`the editor.setText command should update the current note (use RTE: ${richTextEditor})`, async ({ startAppWithPlugins }) => {
|
||||
const { app, mainWindow } = await startAppWithPlugins(['resources/test-plugins/execCommand.js']);
|
||||
const mainScreen = new MainScreen(mainWindow);
|
||||
await mainScreen.createNewNote('First note');
|
||||
const editor = mainScreen.noteEditor;
|
||||
|
||||
await editor.focusCodeMirrorEditor();
|
||||
await mainWindow.keyboard.type('This content should be overwritten.');
|
||||
|
||||
if (richTextEditor) {
|
||||
await editor.toggleEditorsButton.click();
|
||||
await editor.richTextEditor.click();
|
||||
}
|
||||
|
||||
await mainScreen.goToAnything.runCommand(app, 'testUpdateEditorText');
|
||||
await editor.expectToHaveText('PASS');
|
||||
|
||||
// Should still have the same text after switching notes:
|
||||
await mainScreen.createNewNote('Second note');
|
||||
await editor.goBack();
|
||||
|
||||
await editor.expectToHaveText('PASS');
|
||||
});
|
||||
}
|
||||
});
|
||||
|
@@ -0,0 +1,31 @@
|
||||
// Allows referencing the Joplin global:
|
||||
/* eslint-disable no-undef */
|
||||
|
||||
// Allows the `joplin-manifest` block comment:
|
||||
/* eslint-disable multiline-comment-style */
|
||||
|
||||
/* joplin-manifest:
|
||||
{
|
||||
"id": "org.joplinapp.plugins.example.execCommand",
|
||||
"manifest_version": 1,
|
||||
"app_min_version": "3.1",
|
||||
"name": "JS Bundle test",
|
||||
"description": "JS Bundle Test plugin",
|
||||
"version": "1.0.0",
|
||||
"author": "",
|
||||
"homepage_url": "https://joplinapp.org"
|
||||
}
|
||||
*/
|
||||
|
||||
joplin.plugins.register({
|
||||
onStart: async function() {
|
||||
await joplin.commands.register({
|
||||
name: 'testUpdateEditorText',
|
||||
label: 'Test setting the editor\'s text with editor.setText',
|
||||
iconName: 'fas fa-drum',
|
||||
execute: async () => {
|
||||
await joplin.commands.execute('editor.setText', 'PASS');
|
||||
},
|
||||
});
|
||||
},
|
||||
});
|
@@ -38,7 +38,7 @@ test.describe('richTextEditor', () => {
|
||||
await editor.richTextEditor.waitFor();
|
||||
|
||||
// Edit the note to cause the original content to update
|
||||
await editor.getTinyMCEFrameLocator().locator('a').click();
|
||||
await editor.getRichTextFrameLocator().locator('a').click();
|
||||
await mainWindow.keyboard.type('Test...');
|
||||
|
||||
await editor.toggleEditorsButton.click();
|
||||
@@ -70,7 +70,7 @@ test.describe('richTextEditor', () => {
|
||||
|
||||
// Click on the attached file URL
|
||||
const openPathResult = waitForNextOpenPath(electronApp);
|
||||
const targetLink = editor.getTinyMCEFrameLocator().getByRole('link', { name: basename(pathToAttach) });
|
||||
const targetLink = editor.getRichTextFrameLocator().getByRole('link', { name: basename(pathToAttach) });
|
||||
if (process.platform === 'darwin') {
|
||||
await targetLink.click({ modifiers: ['Meta'] });
|
||||
} else {
|
||||
|
@@ -6,10 +6,12 @@ import createStartupArgs from './createStartupArgs';
|
||||
import firstNonDevToolsWindow from './firstNonDevToolsWindow';
|
||||
|
||||
|
||||
type StartWithPluginsResult = { app: ElectronApplication; mainWindow: Page };
|
||||
|
||||
type JoplinFixtures = {
|
||||
profileDirectory: string;
|
||||
electronApp: ElectronApplication;
|
||||
startAppWithPlugins: (pluginPaths: string[])=> Promise<StartWithPluginsResult>;
|
||||
startupPluginsLoaded: Promise<void>;
|
||||
mainWindow: Page;
|
||||
};
|
||||
@@ -17,6 +19,20 @@ type JoplinFixtures = {
|
||||
// A custom fixture that loads an electron app. See
|
||||
// https://playwright.dev/docs/test-fixtures
|
||||
|
||||
const getAndResizeMainWindow = async (electronApp: ElectronApplication) => {
|
||||
const mainWindow = await firstNonDevToolsWindow(electronApp);
|
||||
|
||||
// Setting the viewport size helps keep test environments consistent.
|
||||
await mainWindow.setViewportSize({
|
||||
width: 1200,
|
||||
height: 800,
|
||||
});
|
||||
|
||||
return mainWindow;
|
||||
};
|
||||
|
||||
const testDir = dirname(__dirname);
|
||||
|
||||
export const test = base.extend<JoplinFixtures>({
|
||||
// Playwright fails if we don't use the object destructuring
|
||||
// pattern in the first argument.
|
||||
@@ -25,7 +41,7 @@ export const test = base.extend<JoplinFixtures>({
|
||||
//
|
||||
// eslint-disable-next-line no-empty-pattern
|
||||
profileDirectory: async ({ }, use) => {
|
||||
const profilePath = resolve(join(dirname(__dirname), 'test-profile'));
|
||||
const profilePath = resolve(join(testDir, 'test-profile'));
|
||||
const profileSubdir = join(profilePath, uuid.createNano());
|
||||
await mkdirp(profileSubdir);
|
||||
|
||||
@@ -44,6 +60,34 @@ export const test = base.extend<JoplinFixtures>({
|
||||
await electronApp.close();
|
||||
},
|
||||
|
||||
startAppWithPlugins: async ({ profileDirectory }, use) => {
|
||||
const startupArgs = createStartupArgs(profileDirectory);
|
||||
let electronApp: ElectronApplication;
|
||||
|
||||
await use(async (pluginPaths: string[]) => {
|
||||
if (electronApp) {
|
||||
throw new Error('Electron app already created');
|
||||
}
|
||||
electronApp = await electron.launch({
|
||||
args: [
|
||||
...startupArgs,
|
||||
'--dev-plugins',
|
||||
pluginPaths.map(path => resolve(testDir, path)).join(','),
|
||||
],
|
||||
});
|
||||
|
||||
return {
|
||||
app: electronApp,
|
||||
mainWindow: await getAndResizeMainWindow(electronApp),
|
||||
};
|
||||
});
|
||||
|
||||
if (electronApp) {
|
||||
await electronApp.firstWindow();
|
||||
await electronApp.close();
|
||||
}
|
||||
},
|
||||
|
||||
startupPluginsLoaded: async ({ electronApp }, use) => {
|
||||
const startupPluginsLoadedPromise = electronApp.evaluate(({ ipcMain }) => {
|
||||
return new Promise<void>(resolve => {
|
||||
@@ -55,15 +99,7 @@ export const test = base.extend<JoplinFixtures>({
|
||||
},
|
||||
|
||||
mainWindow: async ({ electronApp }, use) => {
|
||||
const mainWindow = await firstNonDevToolsWindow(electronApp);
|
||||
|
||||
// Setting the viewport size helps keep test environments consistent.
|
||||
await mainWindow.setViewportSize({
|
||||
width: 1200,
|
||||
height: 800,
|
||||
});
|
||||
|
||||
await use(mainWindow);
|
||||
await use(await getAndResizeMainWindow(electronApp));
|
||||
},
|
||||
});
|
||||
|
||||
|
@@ -24,9 +24,9 @@ jest.mock('@electron/remote', () => {
|
||||
|
||||
// Import after mocking problematic libraries
|
||||
const { afterEachCleanUp, afterAllCleanUp } = require('@joplin/lib/testing/test-utils.js');
|
||||
const React = require('react');
|
||||
|
||||
|
||||
shimInit({ nodeSqlite: sqlite3 });
|
||||
shimInit({ nodeSqlite: sqlite3, React });
|
||||
|
||||
afterEach(async () => {
|
||||
await afterEachCleanUp();
|
||||
|
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@joplin/app-desktop",
|
||||
"version": "3.1.16",
|
||||
"version": "3.1.21",
|
||||
"description": "Joplin for Desktop",
|
||||
"main": "main.js",
|
||||
"private": true,
|
||||
@@ -124,12 +124,12 @@
|
||||
"homepage": "https://github.com/laurent22/joplin#readme",
|
||||
"devDependencies": {
|
||||
"7zip-bin": "5.2.0",
|
||||
"@electron/rebuild": "3.3.0",
|
||||
"@electron/rebuild": "3.6.0",
|
||||
"@joplin/default-plugins": "~3.1",
|
||||
"@joplin/tools": "~3.1",
|
||||
"@playwright/test": "1.44.1",
|
||||
"@testing-library/react-hooks": "8.0.1",
|
||||
"@types/jest": "29.5.8",
|
||||
"@types/jest": "29.5.12",
|
||||
"@types/node": "18.19.39",
|
||||
"@types/react": "18.3.3",
|
||||
"@types/react-dom": "18.3.0",
|
||||
@@ -139,19 +139,19 @@
|
||||
"axios": "^1.7.7",
|
||||
"electron": "29.4.5",
|
||||
"electron-builder": "24.13.3",
|
||||
"glob": "10.4.2",
|
||||
"glob": "10.4.5",
|
||||
"gulp": "4.0.2",
|
||||
"jest": "29.7.0",
|
||||
"jest-environment-jsdom": "29.7.0",
|
||||
"js-sha512": "0.9.0",
|
||||
"nan": "2.19.0",
|
||||
"react-test-renderer": "18.3.1",
|
||||
"ts-jest": "29.1.1",
|
||||
"ts-jest": "29.1.5",
|
||||
"ts-node": "10.9.2",
|
||||
"typescript": "5.2.2"
|
||||
"typescript": "5.4.5"
|
||||
},
|
||||
"dependencies": {
|
||||
"@electron/notarize": "2.1.0",
|
||||
"@electron/notarize": "2.3.2",
|
||||
"@electron/remote": "2.1.2",
|
||||
"@fortawesome/fontawesome-free": "5.15.4",
|
||||
"@joeattardi/emoji-button": "4.6.4",
|
||||
@@ -159,7 +159,7 @@
|
||||
"@joplin/lib": "~3.1",
|
||||
"@joplin/renderer": "~3.1",
|
||||
"@joplin/utils": "~3.1",
|
||||
"@sentry/electron": "4.17.0",
|
||||
"@sentry/electron": "4.24.0",
|
||||
"@types/mustache": "4.2.5",
|
||||
"async-mutex": "0.5.0",
|
||||
"codemirror": "5.65.9",
|
||||
|
@@ -543,11 +543,22 @@ class DialogComponent extends React.PureComponent<Props, State> {
|
||||
const resultId = getResultId(item);
|
||||
const isSelected = resultId === this.state.selectedItemId;
|
||||
const rowStyle = isSelected ? style.rowSelected : style.row;
|
||||
|
||||
const wrapKeywordMatches = (unescapedContent: string) => {
|
||||
return surroundKeywords(
|
||||
this.state.keywords,
|
||||
unescapedContent,
|
||||
`<span class="match-highlight" style="font-weight: bold; color: ${theme.searchMarkerColor}; background-color: ${theme.searchMarkerBackgroundColor}">`,
|
||||
'</span>',
|
||||
{ escapeHtml: true },
|
||||
);
|
||||
};
|
||||
|
||||
const titleHtml = item.fragments
|
||||
? `<span style="font-weight: bold; color: ${theme.color};">${item.title}</span>`
|
||||
: surroundKeywords(this.state.keywords, item.title, `<span style="font-weight: bold; color: ${theme.searchMarkerColor}; background-color: ${theme.searchMarkerBackgroundColor}">`, '</span>', { escapeHtml: true });
|
||||
: wrapKeywordMatches(item.title);
|
||||
|
||||
const fragmentsHtml = !item.fragments ? null : surroundKeywords(this.state.keywords, item.fragments, `<span style="color: ${theme.searchMarkerColor}; background-color: ${theme.searchMarkerBackgroundColor}">`, '</span>', { escapeHtml: true });
|
||||
const fragmentsHtml = !item.fragments ? null : wrapKeywordMatches(item.fragments);
|
||||
|
||||
const folderIcon = <i style={{ fontSize: theme.fontSize, marginRight: 2 }} className="fa fa-book" role='img' aria-label={_('Notebook')} />;
|
||||
const pathComp = !item.path ? null : <div style={style.rowPath}>{folderIcon} {item.path}</div>;
|
||||
|
@@ -1,5 +1,5 @@
|
||||
import * as React from 'react';
|
||||
import { useRef, useImperativeHandle, forwardRef, useEffect } from 'react';
|
||||
import { useRef, useImperativeHandle, forwardRef, useEffect, useMemo } from 'react';
|
||||
import useViewIsReady from './hooks/useViewIsReady';
|
||||
import useThemeCss from './hooks/useThemeCss';
|
||||
import useContentSize from './hooks/useContentSize';
|
||||
@@ -8,14 +8,10 @@ import useHtmlLoader from './hooks/useHtmlLoader';
|
||||
import useWebviewToPluginMessages from './hooks/useWebviewToPluginMessages';
|
||||
import useScriptLoader from './hooks/useScriptLoader';
|
||||
import Logger from '@joplin/utils/Logger';
|
||||
import styled from 'styled-components';
|
||||
import { focus } from '@joplin/lib/utils/focusHandler';
|
||||
|
||||
const logger = Logger.create('UserWebview');
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
|
||||
type StyleProps = any;
|
||||
|
||||
export interface Props {
|
||||
html: string;
|
||||
scripts: string[];
|
||||
@@ -36,15 +32,6 @@ export interface Props {
|
||||
onReady?: Function;
|
||||
}
|
||||
|
||||
const StyledFrame = styled.iframe<{ fitToContent: boolean; borderBottom: boolean }>`
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
width: ${(props: StyleProps) => props.fitToContent ? `${props.width}px` : '100%'};
|
||||
height: ${(props: StyleProps) => props.fitToContent ? `${props.height}px` : '100%'};
|
||||
border: none;
|
||||
border-bottom: ${(props: StyleProps) => props.borderBottom ? `1px solid ${props.theme.dividerColor}` : 'none'};
|
||||
`;
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
|
||||
function serializeForm(form: any) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
|
||||
@@ -153,15 +140,18 @@ function UserWebview(props: Props, ref: any) {
|
||||
cssFilePath,
|
||||
);
|
||||
|
||||
return <StyledFrame
|
||||
const style = useMemo(() => ({
|
||||
'--content-width': `${contentSize.width}px`,
|
||||
'--content-height': `${contentSize.height}px`,
|
||||
} as React.CSSProperties), [contentSize.width, contentSize.height]);
|
||||
|
||||
return <iframe
|
||||
id={props.viewId}
|
||||
width={contentSize.width}
|
||||
height={contentSize.height}
|
||||
fitToContent={props.fitToContent}
|
||||
style={style}
|
||||
className={`plugin-user-webview ${props.fitToContent ? '-fit-to-content' : ''} ${props.borderBottom ? '-border-bottom' : ''}`}
|
||||
ref={viewRef}
|
||||
src="services/plugins/UserWebviewIndex.html"
|
||||
borderBottom={props.borderBottom}
|
||||
></StyledFrame>;
|
||||
></iframe>;
|
||||
}
|
||||
|
||||
export default forwardRef(UserWebview);
|
||||
|
@@ -7,18 +7,12 @@ import UserWebview, { Props as UserWebviewProps } from './UserWebview';
|
||||
import UserWebviewDialogButtonBar from './UserWebviewDialogButtonBar';
|
||||
import { focus } from '@joplin/lib/utils/focusHandler';
|
||||
import Dialog from '../../gui/Dialog';
|
||||
const styled = require('styled-components').default;
|
||||
|
||||
interface Props extends UserWebviewProps {
|
||||
buttons: ButtonSpec[];
|
||||
fitToContent: boolean;
|
||||
}
|
||||
|
||||
const UserWebViewWrapper = styled.div`
|
||||
display: flex;
|
||||
flex: 1;
|
||||
`;
|
||||
|
||||
function defaultButtons(): ButtonSpec[] {
|
||||
return [
|
||||
{
|
||||
@@ -84,7 +78,7 @@ export default function UserWebviewDialog(props: Props) {
|
||||
|
||||
return (
|
||||
<Dialog className={`user-webview-dialog ${props.fitToContent ? '-fit' : ''}`}>
|
||||
<UserWebViewWrapper>
|
||||
<div className='user-dialog-wrapper'>
|
||||
<UserWebview
|
||||
ref={webviewRef}
|
||||
html={props.html}
|
||||
@@ -98,7 +92,7 @@ export default function UserWebviewDialog(props: Props) {
|
||||
onDismiss={onDismiss}
|
||||
onReady={onReady}
|
||||
/>
|
||||
</UserWebViewWrapper>
|
||||
</div>
|
||||
<UserWebviewDialogButtonBar buttons={buttons}/>
|
||||
</Dialog>
|
||||
);
|
||||
|
@@ -5,20 +5,10 @@ import { ButtonSpec } from '@joplin/lib/services/plugins/api/types';
|
||||
const styled = require('styled-components').default;
|
||||
const { space } = require('styled-system');
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
|
||||
type StyleProps = any;
|
||||
|
||||
interface Props {
|
||||
buttons: ButtonSpec[];
|
||||
}
|
||||
|
||||
const StyledRoot = styled.div`
|
||||
display: flex;
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
justify-content: flex-end;
|
||||
padding-top: ${(props: StyleProps) => props.theme.mainPadding}px;
|
||||
`;
|
||||
|
||||
const StyledButton = styled(Button)`${space}`;
|
||||
|
||||
@@ -48,8 +38,8 @@ export default function UserWebviewDialogButtonBar(props: Props) {
|
||||
}
|
||||
|
||||
return (
|
||||
<StyledRoot>
|
||||
<div className='user-dialog-button-bar'>
|
||||
{renderButtons()}
|
||||
</StyledRoot>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
@@ -51,6 +51,7 @@ const webviewApi = {
|
||||
|
||||
docReady(() => {
|
||||
const rootElement = document.createElement('div');
|
||||
rootElement.setAttribute('id', 'joplin-plugin-content-root');
|
||||
document.getElementsByTagName('body')[0].appendChild(rootElement);
|
||||
|
||||
const contentElement = document.createElement('div');
|
||||
|
3
packages/app-desktop/services/plugins/styles/index.scss
Normal file
3
packages/app-desktop/services/plugins/styles/index.scss
Normal file
@@ -0,0 +1,3 @@
|
||||
@use './plugin-user-webview.scss';
|
||||
@use './user-dialog-wrapper.scss';
|
||||
@use './user-dialog-button-bar.scss';
|
@@ -0,0 +1,17 @@
|
||||
|
||||
.plugin-user-webview {
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
border: none;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
|
||||
&.-border-bottom {
|
||||
border-bottom: 1px solid var(--joplin-divider-color);
|
||||
}
|
||||
|
||||
&.-fit-to-content {
|
||||
width: var(--content-width);
|
||||
height: var(--content-height);
|
||||
}
|
||||
}
|
@@ -0,0 +1,8 @@
|
||||
|
||||
.user-dialog-button-bar {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
justify-content: flex-end;
|
||||
padding-top: var(--joplin-main-padding);
|
||||
}
|
@@ -0,0 +1,5 @@
|
||||
|
||||
.user-dialog-wrapper {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
}
|
@@ -11,6 +11,7 @@
|
||||
@use 'gui/UpdateNotification/style.scss' as update-notification;
|
||||
@use 'gui/TrashNotification/style.scss' as trash-notification;
|
||||
@use 'gui/Sidebar/style.scss' as sidebar-styles;
|
||||
@use 'gui/styles/index.scss';
|
||||
@use 'gui/NoteEditor/style.scss';
|
||||
@use 'gui/NoteEditor/style.scss' as note-editor-styles;
|
||||
@use 'services/plugins/styles/index.scss' as plugins-styles;
|
||||
@use 'gui/styles/index.scss' as gui-styles;
|
||||
@use 'main.scss' as main;
|
||||
|
@@ -16,9 +16,13 @@ if [[ $NEED_COMPILING == 1 ]]; then
|
||||
echo "Copying from: $PLUGIN_PATH"
|
||||
echo "To: $TEMP_PLUGIN_PATH"
|
||||
|
||||
rsync -a --delete "$PLUGIN_PATH/" "$TEMP_PLUGIN_PATH/"
|
||||
rsync -a --exclude "cache/" --exclude "node_modules" --delete "$PLUGIN_PATH/" "$TEMP_PLUGIN_PATH/"
|
||||
|
||||
NODE_OPTIONS=--openssl-legacy-provider npm install --prefix="$TEMP_PLUGIN_PATH" && yarn start --dev-plugins "$TEMP_PLUGIN_PATH"
|
||||
cd "$TEMP_PLUGIN_PATH/"
|
||||
NODE_OPTIONS=--openssl-legacy-provider npm install
|
||||
|
||||
cd "$SCRIPT_DIR"
|
||||
yarn start --dev-plugins "$TEMP_PLUGIN_PATH"
|
||||
else
|
||||
yarn start --dev-plugins "$PLUGIN_PATH"
|
||||
fi
|
||||
|
@@ -42,21 +42,29 @@ const setUpProtocolHandler = () => {
|
||||
return { protocolHandler, onRequestListener };
|
||||
};
|
||||
|
||||
interface ExpectBlockedOptions {
|
||||
host?: string;
|
||||
}
|
||||
|
||||
// Although none of the paths in this test suite point to real files, file paths must be in
|
||||
// a certain format on Windows to avoid invalid path exceptions.
|
||||
const toPlatformPath = (path: string) => process.platform === 'win32' ? `C:/${path}` : path;
|
||||
|
||||
const expectPathToBeBlocked = async (onRequestListener: ProtocolHandler, filePath: string) => {
|
||||
const url = `joplin-content://note-viewer/${toPlatformPath(filePath)}`;
|
||||
|
||||
await expect(
|
||||
async () => await onRequestListener(new Request(url)),
|
||||
).rejects.toThrowError('Read access not granted for URL');
|
||||
const toAccessUrl = (path: string, { host = 'note-viewer' }: ExpectBlockedOptions = {}) => {
|
||||
return `joplin-content://${host}/${toPlatformPath(path)}`;
|
||||
};
|
||||
|
||||
const expectPathToBeUnblocked = async (onRequestListener: ProtocolHandler, filePath: string) => {
|
||||
const expectPathToBeBlocked = async (onRequestListener: ProtocolHandler, filePath: string, options?: ExpectBlockedOptions) => {
|
||||
const url = toAccessUrl(filePath, options);
|
||||
await expect(
|
||||
async () => await onRequestListener(new Request(url)),
|
||||
).rejects.toThrow(/Read access not granted for URL|Invalid or missing media access key|Media access denied/);
|
||||
};
|
||||
|
||||
const expectPathToBeUnblocked = async (onRequestListener: ProtocolHandler, filePath: string, options?: ExpectBlockedOptions) => {
|
||||
const url = toAccessUrl(filePath, options);
|
||||
const handleRequestResult = await onRequestListener(
|
||||
new Request(`joplin-content://note-viewer/${toPlatformPath(filePath)}`),
|
||||
new Request(url),
|
||||
);
|
||||
expect(handleRequestResult.body).toBeTruthy();
|
||||
};
|
||||
@@ -107,6 +115,34 @@ describe('handleCustomProtocols', () => {
|
||||
await expectPathToBeBlocked(onRequestListener, '/test/path/a.txt');
|
||||
});
|
||||
|
||||
test('should only allow access to file-media/ URLs when given the correct access key', async () => {
|
||||
const { protocolHandler, onRequestListener } = setUpProtocolHandler();
|
||||
const expectBlocked = (path: string) => {
|
||||
return expectPathToBeBlocked(onRequestListener, path, { host: 'file-media' });
|
||||
};
|
||||
const expectUnblocked = (path: string) => {
|
||||
return expectPathToBeUnblocked(onRequestListener, path, { host: 'file-media' });
|
||||
};
|
||||
|
||||
fetchMock.mockImplementation(async (_url: string) => {
|
||||
return new Response('', { headers: { 'Content-Type': 'image/jpeg' } });
|
||||
});
|
||||
|
||||
|
||||
const testPath = join(supportDir, 'photo.jpg');
|
||||
await expectBlocked(testPath);
|
||||
await expectBlocked(`${testPath}?access-key=wrongKey`);
|
||||
await expectBlocked(`${testPath}?access-key=false`);
|
||||
|
||||
protocolHandler.setMediaAccessEnabled(true);
|
||||
const key = protocolHandler.getMediaAccessKey();
|
||||
await expectUnblocked(`${testPath}?access-key=${key}`);
|
||||
await expectBlocked(`${testPath}?access-key=null`);
|
||||
protocolHandler.setMediaAccessEnabled(false);
|
||||
|
||||
await expectBlocked(`${testPath}?access-key=${key}`);
|
||||
});
|
||||
|
||||
test('should allow requesting part of a file', async () => {
|
||||
const { protocolHandler, onRequestListener } = setUpProtocolHandler();
|
||||
|
||||
|
@@ -7,10 +7,20 @@ import { LoggerWrapper } from '@joplin/utils/Logger';
|
||||
import * as fs from 'fs-extra';
|
||||
import { createReadStream } from 'fs';
|
||||
import { fromFilename } from '@joplin/lib/mime-utils';
|
||||
import { createSecureRandom } from '@joplin/lib/uuid';
|
||||
|
||||
export interface AccessController {
|
||||
remove(): void;
|
||||
}
|
||||
|
||||
export interface CustomProtocolHandler {
|
||||
// note-viewer/ URLs
|
||||
allowReadAccessToDirectory(path: string): void;
|
||||
allowReadAccessToFile(path: string): { remove(): void };
|
||||
allowReadAccessToFile(path: string): AccessController;
|
||||
|
||||
// file-media/ URLs
|
||||
setMediaAccessEnabled(enabled: boolean): void;
|
||||
getMediaAccessKey(): string;
|
||||
}
|
||||
|
||||
|
||||
@@ -125,8 +135,16 @@ const handleRangeRequest = async (request: Request, targetPath: string) => {
|
||||
// TODO: Use Logger.create (doesn't work for now because Logger is only initialized
|
||||
// in the main process.)
|
||||
const handleCustomProtocols = (logger: LoggerWrapper): CustomProtocolHandler => {
|
||||
logger = {
|
||||
...logger,
|
||||
debug: () => {},
|
||||
};
|
||||
|
||||
// Allow-listed files/directories for joplin-content://note-viewer/
|
||||
const readableDirectories: string[] = [];
|
||||
const readableFiles = new Map<string, number>();
|
||||
// Access for joplin-content://file-media/
|
||||
let mediaAccessKey: string|false = false;
|
||||
|
||||
// See also the protocol.handle example: https://www.electronjs.org/docs/latest/api/protocol#protocolhandlescheme-handler
|
||||
protocol.handle(contentProtocolName, async request => {
|
||||
@@ -142,10 +160,9 @@ const handleCustomProtocols = (logger: LoggerWrapper): CustomProtocolHandler =>
|
||||
|
||||
pathname = resolve(appBundleDirectory, pathname);
|
||||
|
||||
const allowedHosts = ['note-viewer'];
|
||||
|
||||
let canRead = false;
|
||||
if (allowedHosts.includes(host)) {
|
||||
let mediaOnly = true;
|
||||
if (host === 'note-viewer') {
|
||||
if (readableFiles.has(pathname)) {
|
||||
canRead = true;
|
||||
} else {
|
||||
@@ -156,6 +173,20 @@ const handleCustomProtocols = (logger: LoggerWrapper): CustomProtocolHandler =>
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
mediaOnly = false;
|
||||
} else if (host === 'file-media') {
|
||||
if (!mediaAccessKey) {
|
||||
throw new Error('Media access denied. This must be enabled with .setMediaAccessEnabled');
|
||||
}
|
||||
|
||||
canRead = true;
|
||||
mediaOnly = true;
|
||||
|
||||
const accessKey = url.searchParams.get('access-key');
|
||||
if (accessKey !== mediaAccessKey) {
|
||||
throw new Error(`Invalid or missing media access key (was ${accessKey}). An allow-listed ?access-key= parameter must be provided.`);
|
||||
}
|
||||
} else {
|
||||
throw new Error(`Invalid URL ${request.url}`);
|
||||
}
|
||||
@@ -168,12 +199,26 @@ const handleCustomProtocols = (logger: LoggerWrapper): CustomProtocolHandler =>
|
||||
logger.debug('protocol handler: Fetch file URL', asFileUrl);
|
||||
|
||||
const rangeHeader = request.headers.get('Range');
|
||||
let response;
|
||||
if (!rangeHeader) {
|
||||
const response = await net.fetch(asFileUrl);
|
||||
return response;
|
||||
response = await net.fetch(asFileUrl);
|
||||
} else {
|
||||
return handleRangeRequest(request, pathname);
|
||||
response = await handleRangeRequest(request, pathname);
|
||||
}
|
||||
|
||||
if (mediaOnly) {
|
||||
// Tells the browser to avoid MIME confusion attacks. See
|
||||
// https://blog.mozilla.org/security/2016/08/26/mitigating-mime-confusion-attacks-in-firefox/
|
||||
response.headers.set('X-Content-Type-Options', 'nosniff');
|
||||
|
||||
// This is an extra check to prevent loading text/html and arbitrary non-media content from the URL.
|
||||
const contentType = response.headers.get('Content-Type');
|
||||
if (!contentType || !contentType.match(/^(image|video|audio)\//)) {
|
||||
throw new Error(`Attempted to access non-media file from ${request.url}, which is media-only. Content type was ${contentType}.`);
|
||||
}
|
||||
}
|
||||
|
||||
return response;
|
||||
});
|
||||
|
||||
const appBundleDirectory = dirname(dirname(__dirname));
|
||||
@@ -205,6 +250,18 @@ const handleCustomProtocols = (logger: LoggerWrapper): CustomProtocolHandler =>
|
||||
},
|
||||
};
|
||||
},
|
||||
setMediaAccessEnabled: (enabled: boolean) => {
|
||||
if (enabled) {
|
||||
mediaAccessKey ||= createSecureRandom();
|
||||
} else {
|
||||
mediaAccessKey = false;
|
||||
}
|
||||
},
|
||||
// Allows access to all local media files, provided a matching ?access-key=<key> is added
|
||||
// to the request URL.
|
||||
getMediaAccessKey: () => {
|
||||
return mediaAccessKey || null;
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
|
@@ -79,8 +79,8 @@ android {
|
||||
applicationId "net.cozic.joplin"
|
||||
minSdkVersion rootProject.ext.minSdkVersion
|
||||
targetSdkVersion rootProject.ext.targetSdkVersion
|
||||
versionCode 2097752
|
||||
versionName "3.1.4"
|
||||
versionCode 2097755
|
||||
versionName "3.1.7"
|
||||
ndk {
|
||||
abiFilters "armeabi-v7a", "x86", "arm64-v8a", "x86_64"
|
||||
}
|
||||
|
@@ -93,7 +93,7 @@ const ExtendedWebView = (props: Props, ref: Ref<WebViewControl>) => {
|
||||
}, [dom]);
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- HACK: Allow wrapper testing logic to access the DOM.
|
||||
const additionalProps: any = { document: dom?.window?.document };
|
||||
const additionalProps: any = { window: dom?.window };
|
||||
return (
|
||||
<View style={props.style} testID={props.testID} {...additionalProps}/>
|
||||
);
|
||||
|
@@ -569,6 +569,7 @@ function NoteEditor(props: Props, ref: any) {
|
||||
}}>
|
||||
<ExtendedWebView
|
||||
webviewInstanceId='NoteEditor'
|
||||
testID='NoteEditor'
|
||||
scrollEnabled={true}
|
||||
ref={webviewRef}
|
||||
html={html}
|
||||
|
@@ -139,6 +139,7 @@ const MenuComponent: React.FC<Props> = props => {
|
||||
style={styles.menuContentScroller}
|
||||
aria-modal={true}
|
||||
accessibilityViewIsModal={true}
|
||||
testID={`menu-content-${refocusCounter ? 'refocusing' : ''}`}
|
||||
>{menuOptionComponents}</ScrollView>
|
||||
</MenuOptions>
|
||||
</Menu>
|
||||
|
@@ -100,6 +100,9 @@ const FloatingActionButton = (props: ActionButtonProps) => {
|
||||
onStateChange={onMenuToggled}
|
||||
actions={actions}
|
||||
onPress={props.mainButton?.onPress ?? defaultOnPress}
|
||||
// The long press delay is too short by default (and we don't use the long press event). See https://github.com/laurent22/joplin/issues/11183.
|
||||
// Increase to a large value:
|
||||
delayLongPress={10_000}
|
||||
visible={true}
|
||||
/>;
|
||||
const mainMenu = isWeb ? (
|
||||
|
@@ -30,6 +30,7 @@ export type ThemeStyle = BaseTheme & typeof baseStyle & {
|
||||
headerStyle: TextStyle;
|
||||
headerWrapperStyle: ViewStyle;
|
||||
rootStyle: ViewStyle;
|
||||
hiddenRootStyle: ViewStyle;
|
||||
keyboardAppearance: 'light'|'dark';
|
||||
};
|
||||
|
||||
@@ -87,6 +88,11 @@ function extraStyles(theme: BaseTheme) {
|
||||
backgroundColor: theme.backgroundColor,
|
||||
};
|
||||
|
||||
const hiddenRootStyle: ViewStyle = {
|
||||
...rootStyle,
|
||||
flex: 0.001, // This is a bit of a hack but it seems to work fine - it makes the component invisible but without unmounting it
|
||||
};
|
||||
|
||||
return {
|
||||
marginRight: baseStyle.margin,
|
||||
marginLeft: baseStyle.margin,
|
||||
@@ -101,6 +107,7 @@ function extraStyles(theme: BaseTheme) {
|
||||
headerStyle,
|
||||
headerWrapperStyle,
|
||||
rootStyle,
|
||||
hiddenRootStyle,
|
||||
|
||||
keyboardAppearance: theme.appearance,
|
||||
color5: theme.color5 ?? theme.backgroundColor4,
|
||||
|
@@ -1,13 +1,14 @@
|
||||
import * as React from 'react';
|
||||
|
||||
import { describe, it, beforeEach } from '@jest/globals';
|
||||
import { fireEvent, render, screen, userEvent, waitFor } from '@testing-library/react-native';
|
||||
import { act, fireEvent, render, screen, userEvent, waitFor } from '@testing-library/react-native';
|
||||
import '@testing-library/jest-native/extend-expect';
|
||||
import { Provider } from 'react-redux';
|
||||
|
||||
import NoteScreen from './Note';
|
||||
import { MenuProvider } from 'react-native-popup-menu';
|
||||
import { runWithFakeTimers, setupDatabaseAndSynchronizer, switchClient, simulateReadOnlyShareEnv } from '@joplin/lib/testing/test-utils';
|
||||
import { setupDatabaseAndSynchronizer, switchClient, simulateReadOnlyShareEnv, supportDir, synchronizerStart, resourceFetcher, runWithFakeTimers } from '@joplin/lib/testing/test-utils';
|
||||
import { waitFor as waitForWithRealTimers } from '@joplin/lib/testing/test-utils';
|
||||
import Note from '@joplin/lib/models/Note';
|
||||
import { AppState } from '../../utils/types';
|
||||
import { Store } from 'redux';
|
||||
@@ -24,6 +25,10 @@ import { getDisplayParentId } from '@joplin/lib/services/trash';
|
||||
import { itemIsReadOnlySync, ItemSlice } from '@joplin/lib/models/utils/readOnly';
|
||||
import { LayoutChangeEvent } from 'react-native';
|
||||
import shim from '@joplin/lib/shim';
|
||||
import getWebViewWindowById from '../../utils/testing/getWebViewWindowById';
|
||||
import CodeMirrorControl from '@joplin/editor/CodeMirror/CodeMirrorControl';
|
||||
import Setting from '@joplin/lib/models/Setting';
|
||||
import Resource from '@joplin/lib/models/Resource';
|
||||
|
||||
interface WrapperProps {
|
||||
}
|
||||
@@ -44,12 +49,29 @@ const getNoteViewerDom = async () => {
|
||||
return await getWebViewDomById('NoteBodyViewer');
|
||||
};
|
||||
|
||||
const openNewNote = async (noteProperties: NoteEntity) => {
|
||||
const note = await Note.save({
|
||||
parent_id: (await Folder.defaultFolder()).id,
|
||||
...noteProperties,
|
||||
const getNoteEditorControl = async () => {
|
||||
const noteEditor = await getWebViewWindowById('NoteEditor');
|
||||
const getEditorControl = () => {
|
||||
if ('cm' in noteEditor.window && noteEditor.window.cm) {
|
||||
return noteEditor.window.cm as CodeMirrorControl;
|
||||
}
|
||||
return null;
|
||||
};
|
||||
await waitFor(async () => {
|
||||
expect(getEditorControl()).toBeTruthy();
|
||||
});
|
||||
return getEditorControl();
|
||||
};
|
||||
|
||||
const waitForNoteToMatch = async (noteId: string, note: Partial<NoteEntity>) => {
|
||||
await act(() => waitForWithRealTimers(async () => {
|
||||
const loadedNote = await Note.load(noteId);
|
||||
expect(loadedNote).toMatchObject(note);
|
||||
}));
|
||||
};
|
||||
|
||||
const openExistingNote = async (noteId: string) => {
|
||||
const note = await Note.load(noteId);
|
||||
const displayParentId = getDisplayParentId(note, await Folder.load(note.parent_id));
|
||||
|
||||
store.dispatch({
|
||||
@@ -62,6 +84,17 @@ const openNewNote = async (noteProperties: NoteEntity) => {
|
||||
id: note.id,
|
||||
folderId: displayParentId,
|
||||
});
|
||||
};
|
||||
|
||||
const openNewNote = async (noteProperties: NoteEntity) => {
|
||||
const note = await Note.save({
|
||||
parent_id: (await Folder.defaultFolder()).id,
|
||||
...noteProperties,
|
||||
});
|
||||
|
||||
await openExistingNote(note.id);
|
||||
await waitForNoteToMatch(note.id, { parent_id: note.parent_id, title: note.title, body: note.body });
|
||||
|
||||
return note.id;
|
||||
};
|
||||
|
||||
@@ -80,11 +113,33 @@ const openNoteActionsMenu = async () => {
|
||||
cursor = cursor.parent;
|
||||
}
|
||||
|
||||
await runWithFakeTimers(() => userEvent.press(actionMenuButton));
|
||||
// Wrap in act(...) -- this tells the test library that component state is intended to update (prevents
|
||||
// warnings).
|
||||
await act(async () => {
|
||||
await runWithFakeTimers(async () => {
|
||||
await userEvent.press(actionMenuButton);
|
||||
});
|
||||
|
||||
// State can update until the menu content is marked as in the process of refocusing (part of the
|
||||
// menu transition).
|
||||
await waitFor(async () => {
|
||||
expect(await screen.findByTestId('menu-content-refocusing')).toBeVisible();
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
describe('Note', () => {
|
||||
const openEditor = async () => {
|
||||
const editButton = await screen.findByLabelText('Edit');
|
||||
|
||||
fireEvent.press(editButton);
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByLabelText('Edit')).toBeNull();
|
||||
});
|
||||
};
|
||||
|
||||
describe('screens/Note', () => {
|
||||
beforeEach(async () => {
|
||||
await setupDatabaseAndSynchronizer(1);
|
||||
await setupDatabaseAndSynchronizer(0);
|
||||
await switchClient(0);
|
||||
|
||||
@@ -113,21 +168,64 @@ describe('Note', () => {
|
||||
|
||||
it('changing the note title input should update the note\'s title', async () => {
|
||||
const noteId = await openNewNote({ title: 'Change me!', body: 'Unchanged body' });
|
||||
|
||||
render(<WrappedNoteScreen />);
|
||||
|
||||
const titleInput = await screen.findByDisplayValue('Change me!');
|
||||
// We need to use fake timers while using userEvent to avoid warnings:
|
||||
await runWithFakeTimers(async () => {
|
||||
const user = userEvent.setup();
|
||||
await user.clear(titleInput);
|
||||
await user.type(titleInput, 'New title');
|
||||
});
|
||||
|
||||
await waitFor(async () => {
|
||||
expect(await Note.load(noteId)).toMatchObject({ title: 'New title', body: 'Unchanged body' });
|
||||
const user = userEvent.setup();
|
||||
await user.clear(titleInput);
|
||||
await user.type(titleInput, 'New title');
|
||||
|
||||
await waitForNoteToMatch(noteId, { title: 'New title', body: 'Unchanged body' });
|
||||
|
||||
// Use fake timers to allow advancing timers without pausing the test
|
||||
await runWithFakeTimers(async () => {
|
||||
let expectedTitle = 'New title';
|
||||
for (let i = 0; i <= 10; i++) {
|
||||
for (const chunk of ['!', ' test', '!!!', ' Testing']) {
|
||||
jest.advanceTimersByTime(i % 5);
|
||||
await user.type(titleInput, chunk);
|
||||
expectedTitle += chunk;
|
||||
|
||||
// Don't verify after each input event -- this allows the save action queue to fill.
|
||||
if (i % 4 === 0) {
|
||||
await waitForNoteToMatch(noteId, { title: expectedTitle });
|
||||
}
|
||||
}
|
||||
await waitForNoteToMatch(noteId, { title: expectedTitle });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
it('changing the note body in the editor should update the note\'s body', async () => {
|
||||
const defaultBody = 'Change me!';
|
||||
const noteId = await openNewNote({ title: 'Unchanged title', body: defaultBody });
|
||||
|
||||
const noteScreen = render(<WrappedNoteScreen />);
|
||||
await act(async () => await runWithFakeTimers(async () => {
|
||||
await openEditor();
|
||||
const editor = await getNoteEditorControl();
|
||||
editor.select(defaultBody.length, defaultBody.length);
|
||||
|
||||
editor.insertText(' Testing!!!');
|
||||
await waitForNoteToMatch(noteId, { body: 'Change me! Testing!!!' });
|
||||
|
||||
editor.insertText(' This is a test.');
|
||||
await waitForNoteToMatch(noteId, { body: 'Change me! Testing!!! This is a test.' });
|
||||
|
||||
// should also save changes made shortly before unmounting
|
||||
editor.insertText(' Test!');
|
||||
|
||||
// TODO: Decreasing this below 100 causes the test to fail.
|
||||
// See issue #11125.
|
||||
await jest.advanceTimersByTimeAsync(450);
|
||||
|
||||
noteScreen.unmount();
|
||||
await waitForNoteToMatch(noteId, { body: 'Change me! Testing!!! This is a test. Test!' });
|
||||
}));
|
||||
});
|
||||
|
||||
it('pressing "delete" should move the note to the trash', async () => {
|
||||
const noteId = await openNewNote({ title: 'To be deleted', body: '...' });
|
||||
render(<WrappedNoteScreen />);
|
||||
@@ -189,4 +287,48 @@ describe('Note', () => {
|
||||
|
||||
cleanup();
|
||||
});
|
||||
|
||||
it.each([
|
||||
'auto',
|
||||
'manual',
|
||||
])('should correctly auto-download or not auto-download resources in %j mode', async (downloadMode) => {
|
||||
let note = await Note.save({ title: 'Note 1', parent_id: (await Folder.defaultFolder()).id });
|
||||
note = await shim.attachFileToNote(note, `${supportDir}/photo.jpg`);
|
||||
|
||||
await synchronizerStart();
|
||||
await switchClient(1);
|
||||
Setting.setValue('sync.resourceDownloadMode', downloadMode);
|
||||
await synchronizerStart();
|
||||
|
||||
// Before opening the note, the resource should not be marked for download
|
||||
const allResources = await Resource.all();
|
||||
expect(allResources.length).toBe(1);
|
||||
const resource = allResources[0];
|
||||
expect(await Resource.localState(resource)).toMatchObject({ fetch_status: Resource.FETCH_STATUS_IDLE });
|
||||
|
||||
await openExistingNote(note.id);
|
||||
|
||||
render(<WrappedNoteScreen />);
|
||||
|
||||
// Note should render
|
||||
const titleInput = await screen.findByDisplayValue('Note 1');
|
||||
expect(titleInput).toBeVisible();
|
||||
|
||||
// Wrap in act() -- the component may update in the background during this.
|
||||
await act(async () => {
|
||||
await resourceFetcher().waitForAllFinished();
|
||||
|
||||
// After opening the note, the resource should be marked for download only in automatic mode
|
||||
if (downloadMode === 'auto') {
|
||||
await waitFor(async () => {
|
||||
expect(await Resource.localState(resource.id)).toMatchObject({ fetch_status: Resource.FETCH_STATUS_DONE });
|
||||
});
|
||||
} else if (downloadMode === 'manual') {
|
||||
// In manual mode, should not mark for download
|
||||
expect(await Resource.localState(resource)).toMatchObject({ fetch_status: Resource.FETCH_STATUS_IDLE });
|
||||
} else {
|
||||
throw new Error(`Should not be testing downloadMode: ${downloadMode}.`);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
@@ -493,11 +493,6 @@ class NoteScreenComponent extends BaseScreenComponent<Props, State> implements B
|
||||
this.undoRedoService_ = new UndoRedoService();
|
||||
this.undoRedoService_.on('stackChange', this.undoRedoService_stackChange);
|
||||
|
||||
if (this.state.note && this.state.note.body && Setting.value('sync.resourceDownloadMode') === 'auto') {
|
||||
const resourceIds = await Note.linkedResourceIds(this.state.note.body);
|
||||
await ResourceFetcher.instance().markForDownload(resourceIds);
|
||||
}
|
||||
|
||||
// Although it is async, we don't wait for the answer so that if permission
|
||||
// has already been granted, it doesn't slow down opening the note. If it hasn't
|
||||
// been granted, the popup will open anyway.
|
||||
@@ -509,8 +504,12 @@ class NoteScreenComponent extends BaseScreenComponent<Props, State> implements B
|
||||
void ResourceFetcher.instance().markForDownload(event.resourceId);
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
|
||||
public componentDidUpdate(prevProps: any, prevState: any) {
|
||||
public async markAllAttachedResourcesForDownload() {
|
||||
const resourceIds = await Note.linkedResourceIds(this.state.note.body);
|
||||
await ResourceFetcher.instance().markForDownload(resourceIds);
|
||||
}
|
||||
|
||||
public componentDidUpdate(prevProps: Props, prevState: State) {
|
||||
if (this.doFocusUpdate_) {
|
||||
this.doFocusUpdate_ = false;
|
||||
this.scheduleFocusUpdate();
|
||||
@@ -528,6 +527,11 @@ class NoteScreenComponent extends BaseScreenComponent<Props, State> implements B
|
||||
void promptRestoreAutosave((drawingData: string) => {
|
||||
void this.attachNewDrawing(drawingData);
|
||||
});
|
||||
|
||||
// Handle automatic resource downloading
|
||||
if (this.state.note?.body && Setting.value('sync.resourceDownloadMode') === 'auto') {
|
||||
void this.markAllAttachedResourcesForDownload();
|
||||
}
|
||||
}
|
||||
|
||||
// Disable opening/closing the side menu with touch gestures
|
||||
|
@@ -167,6 +167,8 @@ class NotesScreenComponent extends BaseScreenComponent<Props, State> {
|
||||
});
|
||||
|
||||
if (source === props.notesSource) return;
|
||||
// For now, search refresh is handled by the search screen.
|
||||
if (props.notesParentType === 'Search') return;
|
||||
|
||||
let notes: NoteEntity[] = [];
|
||||
if (props.notesParentType === 'Folder') {
|
||||
@@ -234,14 +236,7 @@ class NotesScreenComponent extends BaseScreenComponent<Props, State> {
|
||||
const parent = this.parentItem();
|
||||
const theme = themeStyle(this.props.themeId);
|
||||
|
||||
const rootStyle = {
|
||||
flex: 1,
|
||||
backgroundColor: theme.backgroundColor,
|
||||
};
|
||||
|
||||
if (!this.props.visible) {
|
||||
rootStyle.flex = 0.001; // This is a bit of a hack but it seems to work fine - it makes the component invisible but without unmounting it
|
||||
}
|
||||
const rootStyle = this.props.visible ? theme.rootStyle : theme.hiddenRootStyle;
|
||||
|
||||
const title = parent ? parent.title : null;
|
||||
if (!parent) {
|
||||
|
@@ -10,6 +10,7 @@ import { Dispatch } from 'redux';
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import IconButton from '../../IconButton';
|
||||
import SearchResults from './SearchResults';
|
||||
import AccessibleView from '../../accessibility/AccessibleView';
|
||||
|
||||
interface Props {
|
||||
themeId: number;
|
||||
@@ -21,7 +22,7 @@ interface Props {
|
||||
ftsEnabled: number;
|
||||
}
|
||||
|
||||
const useStyles = (theme: ThemeStyle) => {
|
||||
const useStyles = (theme: ThemeStyle, visible: boolean) => {
|
||||
return useMemo(() => {
|
||||
return StyleSheet.create({
|
||||
body: {
|
||||
@@ -46,13 +47,14 @@ const useStyles = (theme: ThemeStyle) => {
|
||||
paddingRight: theme.marginRight,
|
||||
backgroundColor: theme.backgroundColor,
|
||||
},
|
||||
rootStyle: visible ? theme.rootStyle : theme.hiddenRootStyle,
|
||||
});
|
||||
}, [theme]);
|
||||
}, [theme, visible]);
|
||||
};
|
||||
|
||||
const SearchScreenComponent: React.FC<Props> = props => {
|
||||
const theme = themeStyle(props.themeId);
|
||||
const styles = useStyles(theme);
|
||||
const styles = useStyles(theme, props.visible);
|
||||
|
||||
const [query, setQuery] = useState(props.query);
|
||||
|
||||
@@ -79,7 +81,7 @@ const SearchScreenComponent: React.FC<Props> = props => {
|
||||
}, [props.dispatch]);
|
||||
|
||||
return (
|
||||
<View style={theme.rootStyle}>
|
||||
<AccessibleView style={styles.rootStyle} inert={!props.visible}>
|
||||
<ScreenHeader
|
||||
title={_('Search')}
|
||||
folderPickerOptions={{
|
||||
@@ -115,7 +117,7 @@ const SearchScreenComponent: React.FC<Props> = props => {
|
||||
onHighlightedWordsChange={onHighlightedWordsChange}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
</AccessibleView>
|
||||
);
|
||||
};
|
||||
|
||||
|
@@ -404,6 +404,7 @@
|
||||
);
|
||||
inputPaths = (
|
||||
"${PODS_ROOT}/Target Support Files/Pods-Joplin/Pods-Joplin-resources.sh",
|
||||
"${PODS_CONFIGURATION_BUILD_DIR}/RNDeviceInfo/RNDeviceInfoPrivacyInfo.bundle",
|
||||
"${PODS_ROOT}/../../node_modules/react-native-vector-icons/Fonts/AntDesign.ttf",
|
||||
"${PODS_ROOT}/../../node_modules/react-native-vector-icons/Fonts/Entypo.ttf",
|
||||
"${PODS_ROOT}/../../node_modules/react-native-vector-icons/Fonts/EvilIcons.ttf",
|
||||
@@ -427,6 +428,7 @@
|
||||
);
|
||||
name = "[CP] Copy Pods Resources";
|
||||
outputPaths = (
|
||||
"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/RNDeviceInfoPrivacyInfo.bundle",
|
||||
"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/AntDesign.ttf",
|
||||
"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/Entypo.ttf",
|
||||
"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/EvilIcons.ttf",
|
||||
@@ -503,13 +505,13 @@
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||
CLANG_ENABLE_MODULES = YES;
|
||||
CODE_SIGN_ENTITLEMENTS = Joplin/Joplin.entitlements;
|
||||
CURRENT_PROJECT_VERSION = 124;
|
||||
CURRENT_PROJECT_VERSION = 127;
|
||||
DEVELOPMENT_TEAM = A9BXAFS6CT;
|
||||
ENABLE_BITCODE = NO;
|
||||
INFOPLIST_FILE = Joplin/Info.plist;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 13.4;
|
||||
LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks";
|
||||
MARKETING_VERSION = 13.1.3;
|
||||
MARKETING_VERSION = 13.1.6;
|
||||
OTHER_LDFLAGS = (
|
||||
"$(inherited)",
|
||||
"-ObjC",
|
||||
@@ -534,12 +536,12 @@
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||
CLANG_ENABLE_MODULES = YES;
|
||||
CODE_SIGN_ENTITLEMENTS = Joplin/Joplin.entitlements;
|
||||
CURRENT_PROJECT_VERSION = 124;
|
||||
CURRENT_PROJECT_VERSION = 127;
|
||||
DEVELOPMENT_TEAM = A9BXAFS6CT;
|
||||
INFOPLIST_FILE = Joplin/Info.plist;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 13.4;
|
||||
LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks";
|
||||
MARKETING_VERSION = 13.1.3;
|
||||
MARKETING_VERSION = 13.1.6;
|
||||
OTHER_LDFLAGS = (
|
||||
"$(inherited)",
|
||||
"-ObjC",
|
||||
@@ -724,14 +726,14 @@
|
||||
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
|
||||
CODE_SIGN_ENTITLEMENTS = ShareExtension/ShareExtension.entitlements;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 124;
|
||||
CURRENT_PROJECT_VERSION = 127;
|
||||
DEBUG_INFORMATION_FORMAT = dwarf;
|
||||
DEVELOPMENT_TEAM = A9BXAFS6CT;
|
||||
GCC_C_LANGUAGE_STANDARD = gnu11;
|
||||
INFOPLIST_FILE = ShareExtension/Info.plist;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 13.4;
|
||||
LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks @executable_path/../../Frameworks";
|
||||
MARKETING_VERSION = 13.1.3;
|
||||
MARKETING_VERSION = 13.1.6;
|
||||
MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
|
||||
MTL_FAST_MATH = YES;
|
||||
OTHER_LDFLAGS = (
|
||||
@@ -762,14 +764,14 @@
|
||||
CODE_SIGN_ENTITLEMENTS = ShareExtension/ShareExtension.entitlements;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
COPY_PHASE_STRIP = NO;
|
||||
CURRENT_PROJECT_VERSION = 124;
|
||||
CURRENT_PROJECT_VERSION = 127;
|
||||
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
|
||||
DEVELOPMENT_TEAM = A9BXAFS6CT;
|
||||
GCC_C_LANGUAGE_STANDARD = gnu11;
|
||||
INFOPLIST_FILE = ShareExtension/Info.plist;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 13.4;
|
||||
LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks @executable_path/../../Frameworks";
|
||||
MARKETING_VERSION = 13.1.3;
|
||||
MARKETING_VERSION = 13.1.6;
|
||||
MTL_FAST_MATH = YES;
|
||||
OTHER_LDFLAGS = (
|
||||
"$(inherited)",
|
||||
|
@@ -949,7 +949,7 @@ PODS:
|
||||
- React-Core
|
||||
- react-native-camera/RN (4.2.1):
|
||||
- React-Core
|
||||
- react-native-document-picker (9.1.1):
|
||||
- react-native-document-picker (9.3.0):
|
||||
- React-Core
|
||||
- react-native-fingerprint-scanner (6.0.0):
|
||||
- React
|
||||
@@ -997,15 +997,15 @@ PODS:
|
||||
- ReactCommon/turbomodule/bridging
|
||||
- ReactCommon/turbomodule/core
|
||||
- Yoga
|
||||
- react-native-image-resizer (3.0.9):
|
||||
- react-native-image-resizer (3.0.10):
|
||||
- React-Core
|
||||
- react-native-netinfo (11.3.1):
|
||||
- react-native-netinfo (11.3.2):
|
||||
- React-Core
|
||||
- react-native-rsa-native (2.0.5):
|
||||
- React
|
||||
- react-native-saf-x (3.1.0):
|
||||
- React-Core
|
||||
- react-native-safe-area-context (4.10.1):
|
||||
- react-native-safe-area-context (4.10.7):
|
||||
- React-Core
|
||||
- react-native-slider (4.4.4):
|
||||
- DoubleConversion
|
||||
@@ -1032,7 +1032,7 @@ PODS:
|
||||
- React-Core
|
||||
- react-native-version-info (1.1.1):
|
||||
- React-Core
|
||||
- react-native-webview (13.8.6):
|
||||
- react-native-webview (13.10.4):
|
||||
- DoubleConversion
|
||||
- glog
|
||||
- hermes-engine
|
||||
@@ -1284,13 +1284,13 @@ PODS:
|
||||
- React-utils (= 0.74.1)
|
||||
- rn-fetch-blob (0.12.0):
|
||||
- React-Core
|
||||
- RNCClipboard (1.13.2):
|
||||
- RNCClipboard (1.14.1):
|
||||
- React-Core
|
||||
- RNCPushNotificationIOS (1.11.0):
|
||||
- React-Core
|
||||
- RNDateTimePicker (8.0.0):
|
||||
- RNDateTimePicker (8.0.1):
|
||||
- React-Core
|
||||
- RNDeviceInfo (10.13.1):
|
||||
- RNDeviceInfo (10.14.0):
|
||||
- React-Core
|
||||
- RNExitApp (2.0.0):
|
||||
- React-Core
|
||||
@@ -1298,13 +1298,13 @@ PODS:
|
||||
- React-Core
|
||||
- RNFS (2.20.0):
|
||||
- React-Core
|
||||
- RNLocalize (3.0.6):
|
||||
- RNLocalize (3.1.0):
|
||||
- React-Core
|
||||
- RNQuickAction (0.3.13):
|
||||
- React
|
||||
- RNSecureRandom (1.0.1):
|
||||
- React
|
||||
- RNShare (10.0.2):
|
||||
- RNShare (10.2.1):
|
||||
- React-Core
|
||||
- RNVectorIcons (10.1.0):
|
||||
- DoubleConversion
|
||||
@@ -1327,11 +1327,11 @@ PODS:
|
||||
- ReactCommon/turbomodule/bridging
|
||||
- ReactCommon/turbomodule/core
|
||||
- Yoga
|
||||
- RNZipArchive (6.1.0):
|
||||
- RNZipArchive (6.1.2):
|
||||
- React-Core
|
||||
- RNZipArchive/Core (= 6.1.0)
|
||||
- RNZipArchive/Core (= 6.1.2)
|
||||
- SSZipArchive (~> 2.2)
|
||||
- RNZipArchive/Core (6.1.0):
|
||||
- RNZipArchive/Core (6.1.2):
|
||||
- React-Core
|
||||
- SSZipArchive (~> 2.2)
|
||||
- SocketRocket (0.7.0)
|
||||
@@ -1643,20 +1643,20 @@ SPEC CHECKSUMS:
|
||||
React-Mapbuffer: 11029dcd47c5c9e057a4092ab9c2a8d10a496a33
|
||||
react-native-alarm-notification: 43183613222c563c071f2c726624f9f6f06e605d
|
||||
react-native-camera: 3eae183c1d111103963f3dd913b65d01aef8110f
|
||||
react-native-document-picker: 3599b238843369026201d2ef466df53f77ae0452
|
||||
react-native-document-picker: 5b97e24a7f1a1e4a50a72c540a043f32d29a70a2
|
||||
react-native-fingerprint-scanner: ac6656f18c8e45a7459302b84da41a44ad96dbbe
|
||||
react-native-geolocation: fe0562c94eb0b6334f266aea717448dfd9b08cd0
|
||||
react-native-get-random-values: 21325b2244dfa6b58878f51f9aa42821e7ba3d06
|
||||
react-native-image-picker: d3a65af2538ac5407e5329e50f057fb2456f15f8
|
||||
react-native-image-resizer: 669454edae94399b11e49c840e4da14482302293
|
||||
react-native-netinfo: bdb108d340cdb41875c9ced535977cac6d2ff321
|
||||
react-native-image-resizer: fd0c333eca55147bd55c5e054cac95dcd0da6814
|
||||
react-native-netinfo: 076df4f9b07f6670acf4ce9a75aac8d34c2e2ccc
|
||||
react-native-rsa-native: 12132eb627797529fdb1f0d22fd0f8f9678df64a
|
||||
react-native-saf-x: 7dfb7e614512c82dba2dea3401509e1c44f3d1f9
|
||||
react-native-safe-area-context: dcab599c527c2d7de2d76507a523d20a0b83823d
|
||||
react-native-safe-area-context: 422017db8bcabbada9ad607d010996c56713234c
|
||||
react-native-slider: 03f213d3ffbf919b16298c7896c1b60101d8e137
|
||||
react-native-sqlite-storage: f6d515e1c446d1e6d026aa5352908a25d4de3261
|
||||
react-native-version-info: a106f23009ac0db4ee00de39574eb546682579b9
|
||||
react-native-webview: 05bae3a03a1e4f59568dfc05286c0ebf8954106c
|
||||
react-native-webview: 596fb33d67a3cde5a74bf1f6b4c28d3543477fdd
|
||||
React-nativeconfig: b0073a590774e8b35192fead188a36d1dca23dec
|
||||
React-NativeModulesApple: df46ff3e3de5b842b30b4ca8a6caae6d7c8ab09f
|
||||
React-perflogger: 3d31e0d1e8ad891e43a09ac70b7b17a79773003a
|
||||
@@ -1681,19 +1681,19 @@ SPEC CHECKSUMS:
|
||||
React-utils: 3285151c9d1e3a28a9586571fc81d521678c196d
|
||||
ReactCommon: f42444e384d82ab89184aed5d6f3142748b54768
|
||||
rn-fetch-blob: f065bb7ab7fb48dd002629f8bdcb0336602d3cba
|
||||
RNCClipboard: 60fed4b71560d7bfe40e9d35dea9762b024da86d
|
||||
RNCClipboard: 0a720adef5ec193aa0e3de24c3977222c7e52a37
|
||||
RNCPushNotificationIOS: 64218f3c776c03d7408284a819b2abfda1834bc8
|
||||
RNDateTimePicker: cd42eda5f315fc320f0b359413bd598957f7e601
|
||||
RNDeviceInfo: 4f9c7cfd6b9db1b05eb919620a001cf35b536423
|
||||
RNDateTimePicker: b6a9b35a785ecbe12b4e7d6de5439d0aa4614146
|
||||
RNDeviceInfo: 59344c19152c4b2b32283005f9737c5c64b42fba
|
||||
RNExitApp: 00036cabe7bacbb413d276d5520bf74ba39afa6a
|
||||
RNFileViewer: ce7ca3ac370e18554d35d6355cffd7c30437c592
|
||||
RNFS: 4ac0f0ea233904cb798630b3c077808c06931688
|
||||
RNLocalize: 4222a3756cdbe2dc9a5bdf445765a4d2572107cb
|
||||
RNLocalize: e8694475db034bf601e17bd3dfa8986565e769eb
|
||||
RNQuickAction: 6d404a869dc872cde841ad3147416a670d13fa93
|
||||
RNSecureRandom: 07efbdf2cd99efe13497433668e54acd7df49fef
|
||||
RNShare: 859ff710211285676b0bcedd156c12437ea1d564
|
||||
RNShare: 0fad69ae2d71de9d1f7b9a43acf876886a6cb99c
|
||||
RNVectorIcons: 2a2f79274248390b80684ea3c4400bd374a15c90
|
||||
RNZipArchive: ef9451b849c45a29509bf44e65b788829ab07801
|
||||
RNZipArchive: 6d736ee4e286dbbd9d81206b7a4da355596ca04a
|
||||
SocketRocket: abac6f5de4d4d62d24e11868d7a2f427e0ef940d
|
||||
SSZipArchive: fe6a26b2a54d5a0890f2567b5cc6de5caa600aef
|
||||
Yoga: 348f8b538c3ed4423eb58a8e5730feec50bce372
|
||||
|
@@ -6,10 +6,10 @@
|
||||
<array>
|
||||
<dict>
|
||||
<key>NSPrivacyAccessedAPIType</key>
|
||||
<string>NSPrivacyAccessedAPICategoryFileTimestamp</string>
|
||||
<string>NSPrivacyAccessedAPICategorySystemBootTime</string>
|
||||
<key>NSPrivacyAccessedAPITypeReasons</key>
|
||||
<array>
|
||||
<string>C617.1</string>
|
||||
<string>35F9.1</string>
|
||||
</array>
|
||||
</dict>
|
||||
<dict>
|
||||
@@ -22,10 +22,10 @@
|
||||
</dict>
|
||||
<dict>
|
||||
<key>NSPrivacyAccessedAPIType</key>
|
||||
<string>NSPrivacyAccessedAPICategorySystemBootTime</string>
|
||||
<string>NSPrivacyAccessedAPICategoryFileTimestamp</string>
|
||||
<key>NSPrivacyAccessedAPITypeReasons</key>
|
||||
<array>
|
||||
<string>35F9.1</string>
|
||||
<string>C617.1</string>
|
||||
</array>
|
||||
</dict>
|
||||
<dict>
|
||||
@@ -33,6 +33,7 @@
|
||||
<string>NSPrivacyAccessedAPICategoryDiskSpace</string>
|
||||
<key>NSPrivacyAccessedAPITypeReasons</key>
|
||||
<array>
|
||||
<string>85F4.1</string>
|
||||
<string>E174.1</string>
|
||||
</array>
|
||||
</dict>
|
||||
|
@@ -49,7 +49,7 @@
|
||||
"react-native-camera": "4.2.1",
|
||||
"react-native-device-info": "10.14.0",
|
||||
"react-native-dialogbox": "0.6.10",
|
||||
"react-native-document-picker": "9.2.0",
|
||||
"react-native-document-picker": "9.3.0",
|
||||
"react-native-dropdownalert": "5.1.0",
|
||||
"react-native-exit-app": "2.0.0",
|
||||
"react-native-file-viewer": "2.1.5",
|
||||
@@ -63,7 +63,7 @@
|
||||
"react-native-popup-menu": "0.16.1",
|
||||
"react-native-quick-actions": "0.3.13",
|
||||
"react-native-rsa-native": "2.0.5",
|
||||
"react-native-safe-area-context": "4.10.5",
|
||||
"react-native-safe-area-context": "4.10.7",
|
||||
"react-native-securerandom": "1.0.1",
|
||||
"react-native-share": "10.2.1",
|
||||
"react-native-sqlite-storage": "6.0.1",
|
||||
@@ -76,7 +76,7 @@
|
||||
"react-redux": "8.1.3",
|
||||
"redux": "4.2.1",
|
||||
"rn-fetch-blob": "0.12.0",
|
||||
"stream": "0.0.2",
|
||||
"stream": "0.0.3",
|
||||
"stream-browserify": "3.0.0",
|
||||
"string-natural-compare": "3.0.1",
|
||||
"tar-stream": "3.1.7",
|
||||
@@ -90,18 +90,18 @@
|
||||
"@babel/runtime": "7.24.7",
|
||||
"@joplin/tools": "~3.1",
|
||||
"@js-draw/material-icons": "1.20.3",
|
||||
"@react-native/babel-preset": "0.74.84",
|
||||
"@react-native/metro-config": "0.74.84",
|
||||
"@react-native/babel-preset": "0.74.85",
|
||||
"@react-native/metro-config": "0.74.85",
|
||||
"@sqlite.org/sqlite-wasm": "3.46.0-build2",
|
||||
"@testing-library/jest-native": "5.4.3",
|
||||
"@testing-library/react-native": "12.3.3",
|
||||
"@tsconfig/react-native": "2.0.2",
|
||||
"@types/fs-extra": "11.0.4",
|
||||
"@types/jest": "29.5.8",
|
||||
"@types/jest": "29.5.12",
|
||||
"@types/react": "18.3.3",
|
||||
"@types/react-native": "0.70.6",
|
||||
"@types/react-redux": "7.1.33",
|
||||
"@types/serviceworker": "0.0.88",
|
||||
"@types/serviceworker": "0.0.89",
|
||||
"@types/tar-stream": "3.1.3",
|
||||
"babel-jest": "29.7.0",
|
||||
"babel-loader": "9.1.3",
|
||||
@@ -114,7 +114,7 @@
|
||||
"jetifier": "2.0.0",
|
||||
"js-draw": "1.20.3",
|
||||
"jsdom": "24.1.0",
|
||||
"nodemon": "3.0.3",
|
||||
"nodemon": "3.1.7",
|
||||
"punycode": "2.3.1",
|
||||
"react-dom": "18.3.1",
|
||||
"react-native-web": "0.19.12",
|
||||
@@ -122,10 +122,10 @@
|
||||
"sharp": "0.33.4",
|
||||
"sqlite3": "5.1.6",
|
||||
"timers-browserify": "2.0.12",
|
||||
"ts-jest": "29.1.1",
|
||||
"ts-jest": "29.1.5",
|
||||
"ts-loader": "9.5.1",
|
||||
"ts-node": "10.9.2",
|
||||
"typescript": "5.2.2",
|
||||
"typescript": "5.4.5",
|
||||
"uglify-js": "3.17.4",
|
||||
"url-loader": "4.1.1",
|
||||
"webpack": "5.84.0",
|
||||
|
@@ -1,5 +1,5 @@
|
||||
module.exports = {
|
||||
hash:"d7216e17c44aad8a0b1c3cf2e01a0135", files: {
|
||||
hash:"addedbac5508e231800fe0f97c326075", files: {
|
||||
'highlight.js/atom-one-dark-reasonable.css': { data: require('./highlight.js/atom-one-dark-reasonable.css.base64.js'), mime: 'text/css', encoding: 'base64' },
|
||||
'highlight.js/atom-one-light.css': { data: require('./highlight.js/atom-one-light.css.base64.js'), mime: 'text/css', encoding: 'base64' },
|
||||
'katex/fonts/KaTeX_AMS-Regular.woff2': { data: require('./katex/fonts/KaTeX_AMS-Regular.woff2.base64.js'), mime: 'application/octet-stream', encoding: 'base64' },
|
||||
|
@@ -1 +1 @@
|
||||
module.exports = {"hash":"d7216e17c44aad8a0b1c3cf2e01a0135","files":["highlight.js/atom-one-dark-reasonable.css","highlight.js/atom-one-light.css","katex/fonts/KaTeX_AMS-Regular.woff2","katex/fonts/KaTeX_Caligraphic-Bold.woff2","katex/fonts/KaTeX_Caligraphic-Regular.woff2","katex/fonts/KaTeX_Fraktur-Bold.woff2","katex/fonts/KaTeX_Fraktur-Regular.woff2","katex/fonts/KaTeX_Main-Bold.woff2","katex/fonts/KaTeX_Main-BoldItalic.woff2","katex/fonts/KaTeX_Main-Italic.woff2","katex/fonts/KaTeX_Main-Regular.woff2","katex/fonts/KaTeX_Math-BoldItalic.woff2","katex/fonts/KaTeX_Math-Italic.woff2","katex/fonts/KaTeX_SansSerif-Bold.woff2","katex/fonts/KaTeX_SansSerif-Italic.woff2","katex/fonts/KaTeX_SansSerif-Regular.woff2","katex/fonts/KaTeX_Script-Regular.woff2","katex/fonts/KaTeX_Size1-Regular.woff2","katex/fonts/KaTeX_Size2-Regular.woff2","katex/fonts/KaTeX_Size3-Regular.woff2","katex/fonts/KaTeX_Size4-Regular.woff2","katex/fonts/KaTeX_Typewriter-Regular.woff2","katex/katex.css","mermaid/mermaid.min.js","mermaid/mermaid_render.js"]}
|
||||
module.exports = {"hash":"addedbac5508e231800fe0f97c326075","files":["highlight.js/atom-one-dark-reasonable.css","highlight.js/atom-one-light.css","katex/fonts/KaTeX_AMS-Regular.woff2","katex/fonts/KaTeX_Caligraphic-Bold.woff2","katex/fonts/KaTeX_Caligraphic-Regular.woff2","katex/fonts/KaTeX_Fraktur-Bold.woff2","katex/fonts/KaTeX_Fraktur-Regular.woff2","katex/fonts/KaTeX_Main-Bold.woff2","katex/fonts/KaTeX_Main-BoldItalic.woff2","katex/fonts/KaTeX_Main-Italic.woff2","katex/fonts/KaTeX_Main-Regular.woff2","katex/fonts/KaTeX_Math-BoldItalic.woff2","katex/fonts/KaTeX_Math-Italic.woff2","katex/fonts/KaTeX_SansSerif-Bold.woff2","katex/fonts/KaTeX_SansSerif-Italic.woff2","katex/fonts/KaTeX_SansSerif-Regular.woff2","katex/fonts/KaTeX_Script-Regular.woff2","katex/fonts/KaTeX_Size1-Regular.woff2","katex/fonts/KaTeX_Size2-Regular.woff2","katex/fonts/KaTeX_Size3-Regular.woff2","katex/fonts/KaTeX_Size4-Regular.woff2","katex/fonts/KaTeX_Typewriter-Regular.woff2","katex/katex.css","mermaid/mermaid.min.js","mermaid/mermaid_render.js"]}
|
File diff suppressed because one or more lines are too long
@@ -308,6 +308,10 @@ const appReducer = (state = appDefaultState, action: any) => {
|
||||
|
||||
newState.selectedNoteHash = '';
|
||||
|
||||
if (action.routeName === 'Search') {
|
||||
newState.notesParentType = 'Search';
|
||||
}
|
||||
|
||||
if ('noteId' in action) {
|
||||
newState.selectedNoteIds = action.noteId ? [action.noteId] : [];
|
||||
}
|
||||
@@ -344,6 +348,8 @@ const appReducer = (state = appDefaultState, action: any) => {
|
||||
|
||||
newState.route = action;
|
||||
newState.historyCanGoBack = !!navHistory.length;
|
||||
|
||||
logger.debug('Navigated to route:', newState.route?.routeName, 'with notesParentType:', newState.notesParentType);
|
||||
}
|
||||
break;
|
||||
|
||||
|
@@ -3,15 +3,18 @@ const execa = require('execa');
|
||||
module.exports = async function() {
|
||||
if (process.platform !== 'darwin') return Promise.resolve();
|
||||
|
||||
if (!process.env.RUN_POD_INSTALL) {
|
||||
// We almost never need to run `pod install` either because it has
|
||||
// already been done, or because we are not building the iOS app, yet
|
||||
// it's taking most of the build time (3 min out of the 5 min needed to
|
||||
// build the entire monorepo). If it needs to be ran, XCode will tell us
|
||||
// anyway.
|
||||
console.warn('**Not** running `pod install` - set `RUN_POD_INSTALL` to `1` to do so');
|
||||
return Promise.resolve();
|
||||
}
|
||||
// 2024-10-11: Seems running `pod install` is not so slow anymore, and at least not the
|
||||
// bottleneck when running `yarn install` so we should run it every time.
|
||||
|
||||
// if (!process.env.RUN_POD_INSTALL) {
|
||||
// // We almost never need to run `pod install` either because it has
|
||||
// // already been done, or because we are not building the iOS app, yet
|
||||
// // it's taking most of the build time (3 min out of the 5 min needed to
|
||||
// // build the entire monorepo). If it needs to be ran, XCode will tell us
|
||||
// // anyway.
|
||||
// console.warn('**Not** running `pod install` - set `RUN_POD_INSTALL` to `1` to do so');
|
||||
// return Promise.resolve();
|
||||
// }
|
||||
|
||||
try {
|
||||
const promise = execa('pod', ['install'], { cwd: `${__dirname}/../ios` });
|
||||
|
@@ -1,15 +1,7 @@
|
||||
import { screen, waitFor } from '@testing-library/react-native';
|
||||
import getWebViewWindowById from './getWebViewWindowById';
|
||||
|
||||
const getWebViewDomById = async (id: string): Promise<Document> => {
|
||||
const webviewContent = await screen.findByTestId(id);
|
||||
expect(webviewContent).toBeVisible();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(!!webviewContent.props.document).toBe(true);
|
||||
});
|
||||
|
||||
// Return the composite ExtendedWebView component
|
||||
// See https://callstack.github.io/react-native-testing-library/docs/advanced/testing-env#tree-navigation
|
||||
return webviewContent.props.document;
|
||||
return (await getWebViewWindowById(id)).document;
|
||||
};
|
||||
|
||||
export default getWebViewDomById;
|
||||
|
15
packages/app-mobile/utils/testing/getWebViewWindowById.ts
Normal file
15
packages/app-mobile/utils/testing/getWebViewWindowById.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import { screen, waitFor } from '@testing-library/react-native';
|
||||
|
||||
const getWebViewWindowById = async (id: string): Promise<Window> => {
|
||||
const webviewContent = await screen.findByTestId(id);
|
||||
expect(webviewContent).toBeVisible();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(!!webviewContent.props.window).toBe(true);
|
||||
});
|
||||
|
||||
const webviewWindow = webviewContent.props.window;
|
||||
return webviewWindow;
|
||||
};
|
||||
|
||||
export default getWebViewWindowById;
|
@@ -15,7 +15,7 @@
|
||||
"devDependencies": {
|
||||
"@types/yargs": "17.0.32",
|
||||
"ts-node": "10.9.2",
|
||||
"typescript": "5.2.2"
|
||||
"typescript": "5.4.5"
|
||||
},
|
||||
"dependencies": {
|
||||
"@joplin/utils": "~3.1",
|
||||
|
@@ -6,6 +6,7 @@ import { classHighlighter } from '@lezer/highlight';
|
||||
|
||||
import {
|
||||
EditorView, drawSelection, highlightSpecialChars, ViewUpdate, Command, rectangularSelection,
|
||||
dropCursor,
|
||||
} from '@codemirror/view';
|
||||
import { history, undoDepth, redoDepth, standardKeymap } from '@codemirror/commands';
|
||||
|
||||
@@ -253,6 +254,8 @@ const createEditor = (
|
||||
|
||||
// Apply styles to entire lines (block-display decorations)
|
||||
decoratorExtension,
|
||||
dropCursor(),
|
||||
|
||||
biDirectionalTextExtension,
|
||||
|
||||
// Adds additional CSS classes to tokens (the default CSS classes are
|
||||
|
@@ -84,6 +84,18 @@ const tableDelimiterDecoration = Decoration.line({
|
||||
attributes: { class: 'cm-tableDelimiter' },
|
||||
});
|
||||
|
||||
const orderedListDecoration = Decoration.line({
|
||||
attributes: { class: 'cm-orderedList' },
|
||||
});
|
||||
|
||||
const unorderedListDecoration = Decoration.line({
|
||||
attributes: { class: 'cm-unorderedList' },
|
||||
});
|
||||
|
||||
const listItemDecoration = Decoration.line({
|
||||
attributes: { class: 'cm-listItem' },
|
||||
});
|
||||
|
||||
const horizontalRuleDecoration = Decoration.mark({
|
||||
attributes: { class: 'cm-hr' },
|
||||
});
|
||||
@@ -97,6 +109,10 @@ const nodeNameToLineDecoration: Record<string, Decoration> = {
|
||||
'CodeBlock': codeBlockDecoration,
|
||||
'BlockMath': mathBlockDecoration,
|
||||
'Blockquote': blockQuoteDecoration,
|
||||
'OrderedList': orderedListDecoration,
|
||||
'BulletList': unorderedListDecoration,
|
||||
|
||||
'ListItem': listItemDecoration,
|
||||
|
||||
'SetextHeading1': header1LineDecoration,
|
||||
'ATXHeading1': header1LineDecoration,
|
||||
@@ -122,6 +138,14 @@ const nodeNameToMarkDecoration: Record<string, Decoration> = {
|
||||
'TaskMarker': taskMarkerDecoration,
|
||||
};
|
||||
|
||||
const multilineNodes = {
|
||||
'FencedCode': true,
|
||||
'CodeBlock': true,
|
||||
'BlockMath': true,
|
||||
'Blockquote': true,
|
||||
'OrderedList': true,
|
||||
'BulletList': true,
|
||||
};
|
||||
|
||||
type DecorationDescription = { pos: number; length: number; decoration: Decoration };
|
||||
|
||||
@@ -179,8 +203,8 @@ const computeDecorations = (view: EditorView) => {
|
||||
addDecorationToRange(viewFrom, viewTo, decoration);
|
||||
}
|
||||
|
||||
// Only block decorations will have differing first and last lines
|
||||
if (blockDecorated) {
|
||||
// Only certain block decorations will have differing first and last lines
|
||||
if (blockDecorated && multilineNodes.hasOwnProperty(node.name)) {
|
||||
// Allow different styles for the first, last lines in a block.
|
||||
if (viewFrom === node.from) {
|
||||
addDecorationToLines(viewFrom, viewFrom, regionStartDecoration);
|
||||
|
@@ -232,4 +232,19 @@ describe('markdownCommands.toggleList', () => {
|
||||
);
|
||||
expect(editor.state.selection.main.from).toBe(preSubListText.length);
|
||||
});
|
||||
|
||||
it('should not treat a list of IP addresses as a numbered list', async () => {
|
||||
const initialDocText = '192.168.1.1. This\n127.0.0.1. is\n0.0.0.0. a list';
|
||||
|
||||
const editor = await createTestEditor(
|
||||
initialDocText,
|
||||
EditorSelection.range(0, initialDocText.length),
|
||||
[],
|
||||
);
|
||||
|
||||
toggleList(ListType.UnorderedList)(editor);
|
||||
expect(editor.state.doc.toString()).toBe(
|
||||
'- 192.168.1.1. This\n- 127.0.0.1. is\n- 0.0.0.0. a list',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
@@ -132,9 +132,9 @@ export const toggleList = (listType: ListType): Command => {
|
||||
// RegExps for different list types. The regular expressions MUST
|
||||
// be mutually exclusive.
|
||||
// `(?!\[[ xX]+\])` means "not followed by [x] or [ ]".
|
||||
const bulletedRegex = /^\s*([-*])\s(?!\[[ xX]+\])/;
|
||||
const checklistRegex = /^\s*[-*]\s\[[ xX]+\]\s?/;
|
||||
const numberedRegex = /^\s*\d+\.\s?/;
|
||||
const bulletedRegex = /^\s*([-*])\s(?!\[[ xX]+\]\s)/;
|
||||
const checklistRegex = /^\s*[-*]\s\[[ xX]+\]\s/;
|
||||
const numberedRegex = /^\s*\d+\.\s/;
|
||||
|
||||
const listRegexes: Record<ListType, RegExp> = {
|
||||
[ListType.OrderedList]: numberedRegex,
|
||||
|
10
packages/editor/CodeMirror/theme.ts
vendored
10
packages/editor/CodeMirror/theme.ts
vendored
@@ -79,6 +79,10 @@ const createTheme = (theme: EditorTheme): Extension[] => {
|
||||
// be at least this specific.
|
||||
const selectionBackgroundSelector = '&.cm-focused > .cm-scroller > .cm-selectionLayer .cm-selectionBackground';
|
||||
|
||||
// Matches the editor only when there are no gutters (e.g. line numbers) added by
|
||||
// plugins
|
||||
const editorNoGuttersSelector = '&:not(:has(> .cm-scroller > .cm-gutters))';
|
||||
|
||||
const baseHeadingStyle = {
|
||||
fontWeight: 'bold',
|
||||
fontFamily: theme.fontFamily,
|
||||
@@ -180,6 +184,12 @@ const createTheme = (theme: EditorTheme): Extension[] => {
|
||||
marginRight: 'auto',
|
||||
} : undefined,
|
||||
|
||||
// Allows editor content to be left-aligned with the toolbar on desktop.
|
||||
// See https://github.com/laurent22/joplin/issues/11279
|
||||
[`${editorNoGuttersSelector} .cm-line`]: theme.isDesktop ? {
|
||||
paddingLeft: 0,
|
||||
} : undefined,
|
||||
|
||||
// Override the default URL style when the URL is within a link
|
||||
'& .tok-url.tok-link, & .tok-link.tok-meta, & .tok-link.tok-string': {
|
||||
opacity: 0.6,
|
||||
|
@@ -16,30 +16,30 @@
|
||||
"devDependencies": {
|
||||
"@joplin/lib": "~3.1",
|
||||
"@testing-library/react-hooks": "8.0.1",
|
||||
"@types/jest": "29.5.8",
|
||||
"@types/jest": "29.5.12",
|
||||
"@types/react": "18.3.3",
|
||||
"@types/react-redux": "7.1.33",
|
||||
"@types/styled-components": "5.1.32",
|
||||
"jest": "29.7.0",
|
||||
"jest-environment-jsdom": "29.7.0",
|
||||
"ts-jest": "29.1.1",
|
||||
"typescript": "5.2.2"
|
||||
"ts-jest": "29.1.5",
|
||||
"typescript": "5.4.5"
|
||||
},
|
||||
"dependencies": {
|
||||
"@codemirror/autocomplete": "6.18.0",
|
||||
"@codemirror/commands": "6.6.1",
|
||||
"@codemirror/lang-html": "6.4.9",
|
||||
"@codemirror/lang-markdown": "6.2.5",
|
||||
"@codemirror/language": "6.10.2",
|
||||
"@codemirror/autocomplete": "6.13.0",
|
||||
"@codemirror/commands": "6.3.3",
|
||||
"@codemirror/lang-html": "6.4.8",
|
||||
"@codemirror/lang-markdown": "6.2.4",
|
||||
"@codemirror/language": "6.10.1",
|
||||
"@codemirror/language-data": "6.3.1",
|
||||
"@codemirror/legacy-modes": "6.4.1",
|
||||
"@codemirror/lint": "6.8.1",
|
||||
"@codemirror/legacy-modes": "6.3.3",
|
||||
"@codemirror/lint": "6.5.0",
|
||||
"@codemirror/search": "6.5.6",
|
||||
"@codemirror/state": "6.4.1",
|
||||
"@codemirror/view": "6.33.0",
|
||||
"@codemirror/view": "6.26.3",
|
||||
"@lezer/common": "1.2.1",
|
||||
"@lezer/highlight": "1.2.1",
|
||||
"@lezer/markdown": "1.3.1",
|
||||
"@lezer/highlight": "1.2.0",
|
||||
"@lezer/markdown": "1.2.0",
|
||||
"@replit/codemirror-vim": "6.2.0"
|
||||
}
|
||||
}
|
||||
|
@@ -90,6 +90,7 @@ export interface ContentScriptData {
|
||||
// Intended to correspond with https://codemirror.net/docs/ref/#state.Transaction%5EuserEvent
|
||||
export enum UserEventSource {
|
||||
Paste = 'input.paste',
|
||||
Drop = 'input.drop',
|
||||
}
|
||||
|
||||
export interface EditorControl {
|
||||
|
@@ -45,16 +45,16 @@
|
||||
"entities": "2.2.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/jest": "29.5.8",
|
||||
"@types/jest": "29.5.12",
|
||||
"@types/node": "18.19.39",
|
||||
"@typescript-eslint/eslint-plugin": "6.8.0",
|
||||
"@typescript-eslint/parser": "6.8.0",
|
||||
"@typescript-eslint/eslint-plugin": "6.21.0",
|
||||
"@typescript-eslint/parser": "6.21.0",
|
||||
"coveralls": "3.1.1",
|
||||
"eslint": "8.52.0",
|
||||
"eslint": "8.57.0",
|
||||
"jest": "29.7.0",
|
||||
"prettier": "3.0.3",
|
||||
"ts-jest": "29.1.1",
|
||||
"typescript": "5.2.2"
|
||||
"prettier": "3.3.2",
|
||||
"ts-jest": "29.1.5",
|
||||
"typescript": "5.4.5"
|
||||
},
|
||||
"jest": {
|
||||
"preset": "ts-jest",
|
||||
|
@@ -7,13 +7,15 @@ export const isInsideContainer = (node: any, className: string): boolean => {
|
||||
return false;
|
||||
};
|
||||
|
||||
interface CancelEvent { cancelled: boolean }
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
|
||||
export const waitForElement = async (parent: any, id: string): Promise<any> => {
|
||||
export const waitForElement = async (parent: any, id: string, cancelEvent?: CancelEvent): Promise<any> => {
|
||||
return new Promise((resolve, reject) => {
|
||||
const iid = setInterval(() => {
|
||||
try {
|
||||
const element = parent.getElementById(id);
|
||||
if (element) {
|
||||
if (element || cancelEvent?.cancelled) {
|
||||
clearInterval(iid);
|
||||
resolve(element);
|
||||
}
|
||||
|
@@ -926,6 +926,18 @@ const builtInMetadata = (Setting: typeof SettingType) => {
|
||||
'markdown.plugin.insert': { storage: SettingStorage.File, isGlobal: true, value: false, type: SettingItemType.Bool, section: 'markdownPlugins', public: true, appTypes: [AppType.Mobile, AppType.Desktop], label: () => `${_('Enable ++insert++ syntax')}${wysiwygYes}` },
|
||||
'markdown.plugin.multitable': { storage: SettingStorage.File, isGlobal: true, value: false, type: SettingItemType.Bool, section: 'markdownPlugins', public: true, appTypes: [AppType.Mobile, AppType.Desktop], label: () => `${_('Enable multimarkdown table extension')}${wysiwygNo}` },
|
||||
|
||||
// For now, applies only to the Markdown viewer
|
||||
'renderer.fileUrls': {
|
||||
storage: SettingStorage.File,
|
||||
isGlobal: true,
|
||||
value: false,
|
||||
type: SettingItemType.Bool,
|
||||
section: 'markdownPlugins',
|
||||
public: true,
|
||||
appTypes: [AppType.Desktop],
|
||||
label: () => `${_('Enable file:// URLs for images and videos')}${wysiwygYes}`,
|
||||
},
|
||||
|
||||
// Tray icon (called AppIndicator) doesn't work in Ubuntu
|
||||
// http://www.webupd8.org/2017/04/fix-appindicator-not-working-for.html
|
||||
// Might be fixed in Electron 18.x but no non-beta release yet. So for now
|
||||
@@ -1127,7 +1139,7 @@ const builtInMetadata = (Setting: typeof SettingType) => {
|
||||
},
|
||||
|
||||
|
||||
autoUpdateEnabled: { value: false, type: SettingItemType.Bool, storage: SettingStorage.File, isGlobal: true, section: 'application', public: false, appTypes: [AppType.Desktop], label: () => _('Automatically check for updates') },
|
||||
autoUpdateEnabled: { value: true, type: SettingItemType.Bool, storage: SettingStorage.File, isGlobal: true, section: 'application', public: platform !== 'linux', appTypes: [AppType.Desktop], label: () => _('Automatically check for updates') },
|
||||
'autoUpdate.includePreReleases': { value: false, type: SettingItemType.Bool, section: 'application', storage: SettingStorage.File, isGlobal: true, public: true, appTypes: [AppType.Desktop], label: () => _('Get pre-releases when checking for updates'), description: () => _('See the pre-release page for more details: %s', 'https://joplinapp.org/help/about/prereleases') },
|
||||
|
||||
'autoUploadCrashDumps': {
|
||||
@@ -1576,6 +1588,20 @@ const builtInMetadata = (Setting: typeof SettingType) => {
|
||||
isGlobal: true,
|
||||
},
|
||||
|
||||
'featureFlag.linuxKeychain': {
|
||||
value: false,
|
||||
type: SettingItemType.Bool,
|
||||
public: true,
|
||||
storage: SettingStorage.File,
|
||||
appTypes: [AppType.Desktop],
|
||||
label: () => 'Enable keychain support',
|
||||
description: () => 'This is an experimental setting to enable keychain support on Linux',
|
||||
show: () => shim.isLinux(),
|
||||
section: 'general',
|
||||
isGlobal: true,
|
||||
advanced: true,
|
||||
},
|
||||
|
||||
|
||||
// 'featureFlag.syncAccurateTimestamps': {
|
||||
// value: false,
|
||||
|
@@ -18,7 +18,7 @@
|
||||
"devDependencies": {
|
||||
"@testing-library/react-hooks": "8.0.1",
|
||||
"@types/fs-extra": "11.0.4",
|
||||
"@types/jest": "29.5.8",
|
||||
"@types/jest": "29.5.12",
|
||||
"@types/js-yaml": "4.0.9",
|
||||
"@types/markdown-it": "13.0.8",
|
||||
"@types/mustache": "4.2.5",
|
||||
@@ -34,7 +34,7 @@
|
||||
"react-test-renderer": "18.3.1",
|
||||
"sharp": "0.33.4",
|
||||
"tesseract.js": "5.1.0",
|
||||
"typescript": "5.2.2"
|
||||
"typescript": "5.4.5"
|
||||
},
|
||||
"dependencies": {
|
||||
"@adobe/css-tools": "4.3.3",
|
||||
|
@@ -34,6 +34,13 @@ export async function loadKeychainServiceAndSettings(keychainServiceDrivers: Key
|
||||
Setting.setKeychainService(KeychainService.instance());
|
||||
await Setting.load();
|
||||
|
||||
// Using Linux with the keychain has been observed to cause all secure settings to be lost
|
||||
// on Fedora 40 + GNOME. (This may have been related to running multiple Joplin instances).
|
||||
// For now, make saving to the keychain opt-in until more feedback is received.
|
||||
if (shim.isLinux() && !Setting.value('featureFlag.linuxKeychain')) {
|
||||
KeychainService.instance().readOnly = true;
|
||||
}
|
||||
|
||||
// This is part of the migration to the new sync target info. It needs to be
|
||||
// set as early as possible since it's used to tell if E2EE is enabled, it
|
||||
// contains the master keys, etc. Once it has been set, it becomes a noop
|
||||
|
@@ -4,7 +4,7 @@ import Logger from '@joplin/utils/Logger';
|
||||
import KvStore from '../KvStore';
|
||||
import Setting from '../../models/Setting';
|
||||
|
||||
const logger = Logger.create('KeychainServiceDriver.node');
|
||||
const logger = Logger.create('KeychainServiceDriver.electron');
|
||||
|
||||
const canUseSafeStorage = () => {
|
||||
return !!shim.electronBridge?.()?.safeStorage?.isEncryptionAvailable();
|
||||
|
@@ -147,6 +147,20 @@ export default class JoplinSettings {
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets setting values (only applies to setting you registered from your plugin)
|
||||
*/
|
||||
public async values(keys: string[] | string): Promise<Record<string, unknown>> {
|
||||
if (typeof keys === 'string') keys = [keys];
|
||||
const output: Record<string, unknown> = {};
|
||||
for (const key of keys) {
|
||||
output[key] = Setting.value(getPluginNamespacedSettingKey(this.plugin_.id, key));
|
||||
}
|
||||
return output;
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated Use joplin.settings.values()
|
||||
*
|
||||
* Gets a setting value (only applies to setting you registered from your plugin)
|
||||
*/
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
|
||||
|
@@ -286,6 +286,7 @@ async function switchClient(id: number, options: any = null) {
|
||||
BaseItem.encryptionService_ = encryptionServices_[id];
|
||||
Resource.encryptionService_ = encryptionServices_[id];
|
||||
BaseItem.revisionService_ = revisionServices_[id];
|
||||
ResourceFetcher.instance_ = resourceFetchers_[id];
|
||||
|
||||
await Setting.reset();
|
||||
Setting.settingFilename = settingFilename(id);
|
||||
@@ -1092,12 +1093,41 @@ export const mockMobilePlatform = (platform: string) => {
|
||||
};
|
||||
};
|
||||
|
||||
// Waits for callback to not throw. Similar to react-native-testing-library's waitFor, but works better
|
||||
// with Joplin's mix of real and fake Jest timers.
|
||||
const realSetTimeout = setTimeout;
|
||||
export const waitFor = async (callback: ()=> Promise<void>) => {
|
||||
const timeout = 10_000;
|
||||
const startTime = performance.now();
|
||||
let passed = false;
|
||||
let lastError: Error|null = null;
|
||||
|
||||
while (!passed && performance.now() - startTime < timeout) {
|
||||
try {
|
||||
await callback();
|
||||
passed = true;
|
||||
lastError = null;
|
||||
} catch (error) {
|
||||
lastError = error;
|
||||
|
||||
await new Promise<void>(resolve => {
|
||||
realSetTimeout(() => resolve(), 10);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (lastError) {
|
||||
throw lastError;
|
||||
}
|
||||
};
|
||||
|
||||
export const runWithFakeTimers = async (callback: ()=> Promise<void>) => {
|
||||
if (typeof jest === 'undefined') {
|
||||
throw new Error('Fake timers are only supported in jest.');
|
||||
}
|
||||
|
||||
jest.useFakeTimers();
|
||||
// advanceTimers: Needed by Joplin's database driver
|
||||
jest.useFakeTimers({ advanceTimers: true });
|
||||
|
||||
// The shim.setTimeout and similar functions need to be changed to
|
||||
// use fake timers.
|
||||
|
@@ -2,7 +2,7 @@ import replaceUnsupportedCharacters from './replaceUnsupportedCharacters';
|
||||
|
||||
describe('replaceUnsupportedCharacters', () => {
|
||||
test('should replace NULL characters', () => {
|
||||
expect(replaceUnsupportedCharacters('Test\x00...')).toBe('Test�...');
|
||||
expect(replaceUnsupportedCharacters('\x00Test\x00...')).toBe('�Test�...');
|
||||
expect(replaceUnsupportedCharacters('Test\x00...')).toBe('Test\uFFFD...');
|
||||
expect(replaceUnsupportedCharacters('\x00Test\x00...')).toBe('\uFFFDTest\uFFFD...');
|
||||
});
|
||||
});
|
||||
|
@@ -1,9 +1,13 @@
|
||||
import Logger from '@joplin/utils/Logger';
|
||||
import { _ } from './locale';
|
||||
import Setting from './models/Setting';
|
||||
import { reg } from './registry';
|
||||
import KeychainService from './services/keychain/KeychainService';
|
||||
import { Plugins } from './services/plugins/PluginService';
|
||||
import shim from './shim';
|
||||
|
||||
const logger = Logger.create('versionInfo');
|
||||
|
||||
export interface PackageInfo {
|
||||
name: string;
|
||||
version: string;
|
||||
@@ -70,15 +74,21 @@ export default function versionInfo(packageInfo: PackageInfo, plugins: Plugins)
|
||||
copyrightText.replace('YYYY', `${now.getFullYear()}`),
|
||||
];
|
||||
|
||||
let keychainSupported = false;
|
||||
try {
|
||||
// To allow old keys to be read, certain apps allow read-only keychain access:
|
||||
keychainSupported = Setting.value('keychain.supported') >= 1 && !KeychainService.instance().readOnly;
|
||||
} catch (error) {
|
||||
logger.error('Failed to determine if keychain is supported', error);
|
||||
}
|
||||
|
||||
const body = [
|
||||
_('%s %s (%s, %s)', p.name, p.version, Setting.value('env'), shim.platformName()),
|
||||
'',
|
||||
_('Client ID: %s', Setting.value('clientId')),
|
||||
_('Sync Version: %s', Setting.value('syncVersion')),
|
||||
_('Profile Version: %s', reg.db().version()),
|
||||
// The portable app temporarily supports read-only keychain access (but disallows
|
||||
// write).
|
||||
_('Keychain Supported: %s', (Setting.value('keychain.supported') >= 1 && !shim.isPortable()) ? _('Yes') : _('No')),
|
||||
_('Keychain Supported: %s', keychainSupported ? _('Yes') : _('No')),
|
||||
];
|
||||
|
||||
if (gitInfo) {
|
||||
|
@@ -19,7 +19,7 @@
|
||||
"author": "Joplin",
|
||||
"license": "AGPL-3.0-or-later",
|
||||
"devDependencies": {
|
||||
"@types/jest": "29.5.8",
|
||||
"@types/jest": "29.5.12",
|
||||
"@types/pdfjs-dist": "2.10.378",
|
||||
"@types/react": "18.3.3",
|
||||
"@types/react-dom": "18.3.0",
|
||||
@@ -29,9 +29,9 @@
|
||||
"jest": "29.7.0",
|
||||
"jest-environment-jsdom": "29.7.0",
|
||||
"style-loader": "3.3.4",
|
||||
"ts-jest": "29.1.1",
|
||||
"ts-jest": "29.1.5",
|
||||
"ts-loader": "9.5.1",
|
||||
"typescript": "5.2.2",
|
||||
"typescript": "5.4.5",
|
||||
"webpack": "5.74.0",
|
||||
"webpack-cli": "4.10.0"
|
||||
},
|
||||
|
@@ -29,11 +29,11 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/fs-extra": "11.0.4",
|
||||
"@types/jest": "29.5.8",
|
||||
"@types/jest": "29.5.12",
|
||||
"@types/node": "18.19.39",
|
||||
"jest": "29.7.0",
|
||||
"source-map-loader": "4.0.2",
|
||||
"typescript": "5.2.2",
|
||||
"typescript": "5.4.5",
|
||||
"webpack": "5.65.0",
|
||||
"webpack-cli": "4.10.0"
|
||||
},
|
||||
|
@@ -48,7 +48,7 @@
|
||||
"@types/react-native": "0.64.19",
|
||||
"react": "18.3.1",
|
||||
"react-native": "0.70.6",
|
||||
"typescript": "5.2.2"
|
||||
"typescript": "5.4.5"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "*",
|
||||
|
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user