1
0
mirror of https://github.com/laurent22/joplin.git synced 2026-01-20 00:46:28 +02:00

Compare commits

..

2 Commits

Author SHA1 Message Date
Laurent Cozic
812666957c update 2025-02-12 18:13:27 +00:00
Laurent Cozic
dabb7e08b4 init 2025-02-12 15:30:21 +00:00
93 changed files with 617 additions and 1525 deletions

View File

@@ -792,10 +792,7 @@ packages/app-mobile/components/screens/status.js
packages/app-mobile/components/screens/tags.js
packages/app-mobile/components/side-menu-content.js
packages/app-mobile/components/testing/TestProviderStack.js
packages/app-mobile/components/voiceTyping/AudioRecordingBanner.js
packages/app-mobile/components/voiceTyping/RecordingControls.js
packages/app-mobile/components/voiceTyping/SpeechToTextBanner.js
packages/app-mobile/components/voiceTyping/types.js
packages/app-mobile/components/voiceTyping/VoiceTypingDialog.js
packages/app-mobile/gulpfile.js
packages/app-mobile/index.web.js
packages/app-mobile/root.js
@@ -955,7 +952,6 @@ packages/editor/CodeMirror/utils/keyUpHandlerExtension.js
packages/editor/CodeMirror/utils/overwriteModeExtension.test.js
packages/editor/CodeMirror/utils/overwriteModeExtension.js
packages/editor/CodeMirror/utils/searchExtension.js
packages/editor/CodeMirror/utils/selectedNoteIdExtension.js
packages/editor/CodeMirror/utils/setupVim.js
packages/editor/SelectionFormatting.js
packages/editor/events.js
@@ -1261,7 +1257,6 @@ packages/lib/services/ocr/utils/filterOcrText.js
packages/lib/services/ocr/utils/types.js
packages/lib/services/plugins/BasePlatformImplementation.js
packages/lib/services/plugins/BasePluginRunner.js
packages/lib/services/plugins/EditorPluginHandler.js
packages/lib/services/plugins/MenuController.js
packages/lib/services/plugins/MenuItemController.js
packages/lib/services/plugins/Plugin.js

7
.gitignore vendored
View File

@@ -767,10 +767,7 @@ packages/app-mobile/components/screens/status.js
packages/app-mobile/components/screens/tags.js
packages/app-mobile/components/side-menu-content.js
packages/app-mobile/components/testing/TestProviderStack.js
packages/app-mobile/components/voiceTyping/AudioRecordingBanner.js
packages/app-mobile/components/voiceTyping/RecordingControls.js
packages/app-mobile/components/voiceTyping/SpeechToTextBanner.js
packages/app-mobile/components/voiceTyping/types.js
packages/app-mobile/components/voiceTyping/VoiceTypingDialog.js
packages/app-mobile/gulpfile.js
packages/app-mobile/index.web.js
packages/app-mobile/root.js
@@ -930,7 +927,6 @@ packages/editor/CodeMirror/utils/keyUpHandlerExtension.js
packages/editor/CodeMirror/utils/overwriteModeExtension.test.js
packages/editor/CodeMirror/utils/overwriteModeExtension.js
packages/editor/CodeMirror/utils/searchExtension.js
packages/editor/CodeMirror/utils/selectedNoteIdExtension.js
packages/editor/CodeMirror/utils/setupVim.js
packages/editor/SelectionFormatting.js
packages/editor/events.js
@@ -1236,7 +1232,6 @@ packages/lib/services/ocr/utils/filterOcrText.js
packages/lib/services/ocr/utils/types.js
packages/lib/services/plugins/BasePlatformImplementation.js
packages/lib/services/plugins/BasePluginRunner.js
packages/lib/services/plugins/EditorPluginHandler.js
packages/lib/services/plugins/MenuController.js
packages/lib/services/plugins/MenuItemController.js
packages/lib/services/plugins/Plugin.js

Binary file not shown.

Before

Width:  |  Height:  |  Size: 60 KiB

View File

@@ -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&amp;mtm_kwd=joplinapp&amp;mtm_source=joplinapp-webseite&amp;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://casinoreviews.net"><img title="Casino Reviews" width="256" src="https://joplinapp.org/images/sponsors/CasinoReviews.png"/></a> <a href="https://useviral.com.br/"><img title="Comprar seguidores Instagram" width="256" src="https://joplinapp.org/images/sponsors/Useviral.png"/></a> <a href="https://ca.edubirdie.com/"><img title="Achieve academic success with Edubirdie — your trusted partner for expert writing assistance and resources!" width="256" src="https://joplinapp.org/images/sponsors/Edubirdie.png" alt="EduBirdie"/></a> <a href="https://topagency.webflow.io"><img title="WebDesignAgency" width="256" src="https://joplinapp.org/images/sponsors/WebDesignAgency.png" alt="web design agency"/></a> <a href="https://realgambling.ca/"><img title="RealGambling.ca" width="256" src="https://joplinapp.org/images/sponsors/RealGambling.png" alt="RealGambling.ca"/></a> <a href="https://essaypro.com/"><img title="write an essay online with EssayPro" width="256" src="https://joplinapp.org/images/sponsors/EssayPro.png" alt="write an essay online with EssayPro"/></a> <a href="https://www.slotozilla.com/nz/no-deposit-bonus"><img title="casino without making any upfront cost" width="256" src="https://joplinapp.org/images/sponsors/Slotozilla.png" alt="casino without making any upfront cost"/></a>
<a href="https://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&amp;mtm_kwd=joplinapp&amp;mtm_source=joplinapp-webseite&amp;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://casinoreviews.net"><img title="Casino Reviews" width="256" src="https://joplinapp.org/images/sponsors/CasinoReviews.png"/></a> <a href="https://useviral.com.br/"><img title="Comprar seguidores Instagram" width="256" src="https://joplinapp.org/images/sponsors/Useviral.png"/></a> <a href="https://ca.edubirdie.com/"><img title="Achieve academic success with Edubirdie — your trusted partner for expert writing assistance and resources!" width="256" src="https://joplinapp.org/images/sponsors/Edubirdie.png" alt="EduBirdie"/></a> <a href="https://topagency.webflow.io"><img title="WebDesignAgency" width="256" src="https://joplinapp.org/images/sponsors/WebDesignAgency.png" alt="web design agency"/></a> <a href="https://realgambling.ca/"><img title="RealGambling.ca" width="256" src="https://joplinapp.org/images/sponsors/RealGambling.png" alt="RealGambling.ca"/></a> <a href="https://essaypro.com/"><img title="write an essay online with EssayPro" width="256" src="https://joplinapp.org/images/sponsors/EssayPro.png" alt="write an essay online with EssayPro"/></a> <a href="https://www.slotozilla.com/nz/no-deposit-bonus"><img title="casino without making any upfront cost" width="256" src="https://joplinapp.org/images/sponsors/Slotozilla.png" alt="casino without making any upfront cost"/></a>
<!-- SPONSORS-ORG -->
* * *

View File

@@ -10,36 +10,6 @@ Please [contact support](https://raw.githubusercontent.com/laurent22/joplin/dev/
For general opinions on what makes an app more or less secure, please use the forum.
## Areas outside Joplin's Threat Model
Note: we're mostly linking to Chrome's documentation since our reasoning for these exclusions is the same.
### Denial of Service (DoS)
[Reference](https://chromium.googlesource.com/chromium/src.git/+/master/docs/security/faq.md#are-denial-of-service-issues-considered-security-bugs)
### Physically-local attacks
[Reference](https://chromium.googlesource.com/chromium/src.git/+/master/docs/security/faq.md#why-arent-physically_local-attacks-in-chromes-threat-model)
### Compromised/infected machines
[Reference](https://chromium.googlesource.com/chromium/src.git/+/master/docs/security/faq.md#why-arent-compromised_infected-machines-in-chromes-threat-model)
### Is opening a file on the local machine a security vulnerability?
No - users are allowed to link to files on their local computer. This was a feature that was implemented by popular request. There are measures in place to mitigate security risks such as a dialog to confirm whether a file with an unknown file extension should be opened.
### Is DLL sideloading a security vulnerability?
No. This is an Electron issue and not one they will fix: https://github.com/electron/electron/issues/28384
See also [Physically-local attacks](https://chromium.googlesource.com/chromium/src.git/+/master/docs/security/faq.md#why-arent-physically_local-attacks-in-chromes-threat-model)
### Is local data not being encrypted a security vulnerability?
No, but you should use disk encryption. See also [Physically-local attacks](https://chromium.googlesource.com/chromium/src.git/+/master/docs/security/faq.md#why-arent-physically_local-attacks-in-chromes-threat-model)
## Bounty
We **do not** offer a bounty for discovering vulnerabilities, please do not ask. We can however credit you and link to your website in the changelog and release announcement.

View File

@@ -1 +0,0 @@
Додаток для заміток і завдань із синхронізацією між Linux, macOS, Windows і мобільними пристроями

View File

@@ -63,70 +63,23 @@ const Dialog: React.FC<Props> = props => {
</div>;
};
// We keep track of the mouse events to allow the action to be cancellable on the mouseup
// If dialogElement is the source of the mouse event it means
// that the user clicked in the dimmed background and not in the content of the dialog
const useClickedOutsideContent = (dialogElement: HTMLDialogElement|null) => {
const mouseDownOutsideContent = useRef(false);
mouseDownOutsideContent.current = false;
const [clickedOutsideContent, setClickedOutsideContent] = useState(false);
useEffect(() => {
if (!dialogElement) return () => {};
const mouseDownListener = (event: MouseEvent) => {
if (event.target === dialogElement) {
mouseDownOutsideContent.current = true;
} else {
mouseDownOutsideContent.current = false;
}
};
const mouseUpListener = (event: MouseEvent) => {
if (!mouseDownOutsideContent.current) return;
if (mouseDownOutsideContent.current && event.target === dialogElement) {
setClickedOutsideContent(true);
mouseDownOutsideContent.current = false;
} else {
setClickedOutsideContent(false);
mouseDownOutsideContent.current = false;
}
};
dialogElement.addEventListener('mousedown', mouseDownListener);
dialogElement.addEventListener('mouseup', mouseUpListener);
return () => {
dialogElement.removeEventListener('mousedown', mouseDownListener);
dialogElement.removeEventListener('mouseup', mouseUpListener);
};
}, [dialogElement]);
return [clickedOutsideContent, setClickedOutsideContent] as const;
};
const useDialogElement = (containerDocument: Document, onCancel: undefined|OnCancelListener) => {
const [dialogElement, setDialogElement] = useState<HTMLDialogElement|null>(null);
const onCancelRef = useRef(onCancel);
onCancelRef.current = onCancel;
const [clickedOutsideContent, setClickedOutsideContent] = useClickedOutsideContent(dialogElement);
useEffect(() => {
if (clickedOutsideContent) {
const onCancel = onCancelRef.current;
if (onCancel) {
onCancel();
} else {
setClickedOutsideContent(false);
}
}
}, [clickedOutsideContent, setClickedOutsideContent]);
useEffect(() => {
if (!containerDocument) return () => {};
const dialog = containerDocument.createElement('dialog');
dialog.addEventListener('click', event => {
const onCancel = onCancelRef.current;
const isBackgroundClick = event.target === dialog;
if (isBackgroundClick && onCancel) {
onCancel();
}
});
dialog.classList.add('dialog-modal-layer');
dialog.addEventListener('cancel', event => {
const canCancel = !!onCancelRef.current;

View File

@@ -383,13 +383,10 @@ const CodeMirror = (props: NoteBodyEditorProps, ref: ForwardedRef<NoteBodyEditor
// Update the editor's value
useEffect(() => {
// Include the noteId in the update props to give plugins access
// to the current note ID.
const updateProps = { noteId: props.noteId };
if (editorRef.current?.updateBody(props.content, updateProps)) {
if (editorRef.current?.updateBody(props.content)) {
editorRef.current?.clearHistory();
}
}, [props.content, props.noteId]);
}, [props.content]);
const renderEditor = () => {
return (
@@ -397,7 +394,6 @@ const CodeMirror = (props: NoteBodyEditorProps, ref: ForwardedRef<NoteBodyEditor
<Editor
style={styles.editor}
initialText={props.content}
initialNoteId={props.noteId}
ref={editorRef}
settings={editorSettings}
pluginStates={props.plugins}

View File

@@ -1344,9 +1344,7 @@ const TinyMCE = (props: NoteBodyEditorProps, ref: any) => {
editor.on(TinyMceEditorEvents.KeyUp, onKeyUp);
editor.on(TinyMceEditorEvents.KeyDown, onKeyDown);
editor.on(TinyMceEditorEvents.KeyPress, onKeypress);
// Passing `true` adds the listener to the front of the listener list.
// This allows overriding TinyMCE's built-in paste handler with .preventDefault.
editor.on(TinyMceEditorEvents.Paste, onPaste, true);
editor.on(TinyMceEditorEvents.Paste, onPaste);
editor.on(TinyMceEditorEvents.PasteAsText, onPasteAsText);
editor.on(TinyMceEditorEvents.Copy, onCopy);
// `compositionend` means that a user has finished entering a Chinese

View File

@@ -8,7 +8,7 @@ import { menuItems } from '../../../utils/contextMenu';
import MenuUtils from '@joplin/lib/services/commands/MenuUtils';
import CommandService from '@joplin/lib/services/CommandService';
import Setting from '@joplin/lib/models/Setting';
import type { Event as ElectronEvent, MenuItemConstructorOptions } from 'electron';
import type { Event as ElectronEvent } from 'electron';
import Resource from '@joplin/lib/models/Resource';
import { TinyMceEditorEvents } from './types';
@@ -17,7 +17,6 @@ import { Editor } from 'tinymce';
import { EditDialogControl } from './useEditDialog';
import { Dispatch } from 'redux';
import { _ } from '@joplin/lib/locale';
import type { MenuItem as MenuItemType } from 'electron';
const Menu = bridge().Menu;
const MenuItem = bridge().MenuItem;
@@ -138,20 +137,13 @@ export default function(editor: Editor, plugins: PluginStates, dispatch: Dispatc
event.preventDefault();
const menu = new Menu();
const menuItems: MenuItemType[] = [];
const toMenuItems = (specs: MenuItemConstructorOptions[]) => {
return specs.map(spec => new MenuItem(spec));
};
const menuItems = [];
menuItems.push(...makeEditableMenuItems(element));
menuItems.push(...makeMainMenuItems(element));
const spellCheckerMenuItems = SpellCheckerService.instance().contextMenuItems(params.misspelledWord, params.dictionarySuggestions);
menuItems.push(
...toMenuItems(spellCheckerMenuItems),
);
menuItems.push(
...toMenuItems(menuUtils.pluginContextMenuItems(plugins, MenuItemLocation.EditorContextMenu)),
);
menuItems.push(...spellCheckerMenuItems);
menuItems.push(...menuUtils.pluginContextMenuItems(plugins, MenuItemLocation.EditorContextMenu));
for (const item of menuItems) {
menu.append(item);

View File

@@ -52,8 +52,10 @@ import Logger from '@joplin/utils/Logger';
import usePluginEditorView from './utils/usePluginEditorView';
import { stateUtils } from '@joplin/lib/reducer';
import { WindowIdContext } from '../NewWindowOrIFrame';
import { EditorActivationCheckFilterObject } from '@joplin/lib/services/plugins/api/types';
import PluginService from '@joplin/lib/services/plugins/PluginService';
import EditorPluginHandler from '@joplin/lib/services/plugins/EditorPluginHandler';
import WebviewController from '@joplin/lib/services/plugins/WebviewController';
import AsyncActionQueue, { IntervalType } from '@joplin/lib/AsyncActionQueue';
import useResourceUnwatcher from './utils/useResourceUnwatcher';
import StatusBar from './StatusBar';
@@ -70,6 +72,15 @@ const toolbarButtonUtils = new ToolbarButtonUtils(CommandService.instance());
const onDragOver: React.DragEventHandler = event => event.preventDefault();
let editorIdCounter = 0;
const makeNoteUpdateAction = (shownEditorViewIds: string[]) => {
return async () => {
for (const viewId of shownEditorViewIds) {
const controller = PluginService.instance().viewControllerByViewId(viewId) as WebviewController;
if (controller) controller.emitUpdate();
}
};
};
function NoteEditorContent(props: NoteEditorProps) {
const [showRevisions, setShowRevisions] = useState(false);
const [titleHasBeenManuallyChanged, setTitleHasBeenManuallyChanged] = useState(false);
@@ -79,10 +90,7 @@ function NoteEditorContent(props: NoteEditorProps) {
const titleInputRef = useRef<HTMLInputElement>();
const isMountedRef = useRef(true);
const noteSearchBarRef = useRef(null);
const editorPluginHandler = useMemo(() => {
return new EditorPluginHandler(PluginService.instance());
}, []);
const viewUpdateAsyncQueue_ = useRef<AsyncActionQueue>(new AsyncActionQueue(100, IntervalType.Fixed));
const shownEditorViewIds = props['plugins.shownEditorViewIds'];
@@ -106,15 +114,25 @@ function NoteEditorContent(props: NoteEditorProps) {
const effectiveNoteId = useEffectiveNoteId(props);
useAsyncEffect(async (_event) => {
useAsyncEffect(async (event) => {
if (!props.startupPluginsLoaded) return;
await editorPluginHandler.emitActivationCheck();
}, [effectiveNoteId, editorPluginHandler, props.startupPluginsLoaded]);
let filterObject: EditorActivationCheckFilterObject = {
activatedEditors: [],
};
filterObject = await eventManager.filterEmit('editorActivationCheck', filterObject);
if (event.cancelled) return;
for (const editor of filterObject.activatedEditors) {
const controller = PluginService.instance().pluginById(editor.pluginId).viewController(editor.viewId) as WebviewController;
controller.setActive(editor.isActive);
}
}, [effectiveNoteId, props.startupPluginsLoaded]);
useEffect(() => {
if (!props.startupPluginsLoaded) return;
editorPluginHandler.emitUpdate(shownEditorViewIds);
}, [effectiveNoteId, editorPluginHandler, shownEditorViewIds, props.startupPluginsLoaded]);
viewUpdateAsyncQueue_.current.push(makeNoteUpdateAction(shownEditorViewIds));
}, [effectiveNoteId, shownEditorViewIds, props.startupPluginsLoaded]);
const { editorPlugin, editorView } = usePluginEditorView(props.plugins, shownEditorViewIds);
const builtInEditorVisible = !editorPlugin;

View File

@@ -118,8 +118,6 @@ const NoteList = (props: Props) => {
props.notes.length,
listRenderer.flow,
itemsPerLine,
props.showCompletedTodos,
props.uncompletedTodosOnTop,
);
useItemCss(listRenderer.itemCss);

View File

@@ -23,8 +23,6 @@ const useOnKeyDown = (
noteCount: number,
flow: ItemFlow,
itemsPerLine: number,
showCompletedTodos: boolean,
uncompletedTodosOnTop: boolean,
) => {
const scrollNoteIndex = useCallback((visibleItemCount: number, key: KeyboardEventKey, ctrlKey: boolean, metaKey: boolean, noteIndex: number) => {
if (flow === ItemFlow.TopToBottom) {
@@ -144,32 +142,13 @@ const useOnKeyDown = (
const todos = selectedNotes.filter(n => !!n.is_todo);
if (!todos.length) return;
const firstNoteIndex = notes.findIndex(n => n.id === todos[0].id);
let nextSelectedNoteIndex = firstNoteIndex + 1;
if (nextSelectedNoteIndex > notes.length - 1) nextSelectedNoteIndex = notes.length - 1;
const nextSelectedNote = nextSelectedNoteIndex >= 0 ? notes[nextSelectedNoteIndex] : todos[0];
for (let i = 0; i < todos.length; i++) {
const toggledTodo = Note.toggleTodoCompleted(todos[i]);
await Note.save(toggledTodo);
}
// When the settings `uncompletedTodosOnTop` or `showCompletedTodos` are enabled, the
// note that got set as completed or uncompleted is going to disappear from view,
// possibly hidden or moved to the top or bottom of the note list. It is assumed that
// the user does not want to keep that note selected since the to-do is indeed
// "completed". And by keeping that selection, the cursor would jump, making you lose
// context if you have multiple to-dos that need to be ticked. For that reason we set
// the selection to the next note in the list, which also ensures that the scroll
// position doesn't change. This is the same behaviour as when deleting a note.
const maintainScrollPosition = !showCompletedTodos || uncompletedTodosOnTop;
if (maintainScrollPosition) {
dispatch({ type: 'NOTE_SELECT', noteId: nextSelectedNote.id });
}
dispatch({ type: 'NOTE_SORT' });
if (!maintainScrollPosition) focusNote(todos[0].id);
focusNote(todos[0].id);
const wasCompleted = !!todos[0].todo_completed;
announceForAccessibility(!wasCompleted ? _('Complete') : _('Incomplete'));
}
@@ -192,7 +171,7 @@ const useOnKeyDown = (
type: 'NOTE_SELECT_ALL',
});
}
}, [moveNote, focusNote, visibleItemCount, scrollNoteIndex, makeItemIndexVisible, notes, selectedNoteIds, activeNoteId, dispatch, flow, itemsPerLine, showCompletedTodos, uncompletedTodosOnTop]);
}, [moveNote, focusNote, visibleItemCount, scrollNoteIndex, makeItemIndexVisible, notes, selectedNoteIds, activeNoteId, dispatch, flow, itemsPerLine]);
return onKeyDown;

View File

@@ -1,6 +1,7 @@
import * as React from 'react';
import shim from '@joplin/lib/shim';
import { Size } from '@joplin/utils/types';
import { useCallback, useState, useRef, useMemo } from 'react';
import { useCallback, useState, useRef, useEffect, useMemo } from 'react';
const useScroll = (itemsPerLine: number, noteCount: number, itemSize: Size, listSize: Size, listRef: React.MutableRefObject<HTMLDivElement>) => {
const [scrollTop, setScrollTop] = useState(0);
@@ -28,36 +29,36 @@ const useScroll = (itemsPerLine: number, noteCount: number, itemSize: Size, list
// but still fails now and then. Setting it after 500ms would probably work
// reliably but it's too slow so it makes sense to do it in an interval.
// const setScrollTopLikeYouMeanItTimer = useRef(null);
// const setScrollTopLikeYouMeanItStartTime = useRef(0);
// const setScrollTopLikeYouMeanIt = useCallback((newScrollTop: number) => {
// if (setScrollTopLikeYouMeanItTimer.current) shim.clearInterval(setScrollTopLikeYouMeanItTimer.current);
// setScrollTopLikeYouMeanItStartTime.current = Date.now();
const setScrollTopLikeYouMeanItTimer = useRef(null);
const setScrollTopLikeYouMeanItStartTime = useRef(0);
const setScrollTopLikeYouMeanIt = useCallback((newScrollTop: number) => {
if (setScrollTopLikeYouMeanItTimer.current) shim.clearInterval(setScrollTopLikeYouMeanItTimer.current);
setScrollTopLikeYouMeanItStartTime.current = Date.now();
// listRef.current.scrollTop = newScrollTop;
// lastScrollSetTime.current = Date.now();
listRef.current.scrollTop = newScrollTop;
lastScrollSetTime.current = Date.now();
// setScrollTopLikeYouMeanItTimer.current = shim.setInterval(() => {
// if (!listRef.current) {
// shim.clearInterval(setScrollTopLikeYouMeanItTimer.current);
// setScrollTopLikeYouMeanItTimer.current = null;
// return;
// }
setScrollTopLikeYouMeanItTimer.current = shim.setInterval(() => {
if (!listRef.current) {
shim.clearInterval(setScrollTopLikeYouMeanItTimer.current);
setScrollTopLikeYouMeanItTimer.current = null;
return;
}
// listRef.current.scrollTop = newScrollTop;
// lastScrollSetTime.current = Date.now();
listRef.current.scrollTop = newScrollTop;
lastScrollSetTime.current = Date.now();
// if (Date.now() - setScrollTopLikeYouMeanItStartTime.current > 1000) {
// shim.clearInterval(setScrollTopLikeYouMeanItTimer.current);
// setScrollTopLikeYouMeanItTimer.current = null;
// }
// }, 10);
// }, [listRef]);
if (Date.now() - setScrollTopLikeYouMeanItStartTime.current > 1000) {
shim.clearInterval(setScrollTopLikeYouMeanItTimer.current);
setScrollTopLikeYouMeanItTimer.current = null;
}
}, 10);
}, [listRef]);
// useEffect(() => {
// if (setScrollTopLikeYouMeanItTimer.current) shim.clearInterval(setScrollTopLikeYouMeanItTimer.current);
// setScrollTopLikeYouMeanItTimer.current = null;
// }, []);
useEffect(() => {
if (setScrollTopLikeYouMeanItTimer.current) shim.clearInterval(setScrollTopLikeYouMeanItTimer.current);
setScrollTopLikeYouMeanItTimer.current = null;
}, []);
const makeItemIndexVisible = useCallback((itemIndex: number) => {
const lineTopFloat = scrollTop / itemSize.height;
@@ -82,17 +83,13 @@ const useScroll = (itemsPerLine: number, noteCount: number, itemSize: Size, list
if (newScrollTop > maxScrollTop) newScrollTop = maxScrollTop;
setScrollTop(newScrollTop);
listRef.current.scrollTop = newScrollTop;
lastScrollSetTime.current = Date.now();
// setScrollTopLikeYouMeanIt(newScrollTop);
}, [itemsPerLine, noteCount, itemSize.height, scrollTop, listSize.height, maxScrollTop, listRef]); // , setScrollTopLikeYouMeanIt]);
setScrollTopLikeYouMeanIt(newScrollTop);
}, [itemsPerLine, noteCount, itemSize.height, scrollTop, listSize.height, maxScrollTop, setScrollTopLikeYouMeanIt]);
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
const onScroll = useCallback((event: any) => {
// console.info('ON SCROLL', event.target.scrollTop, 'Ignore:', Date.now() - lastScrollSetTime.current < 500);
// Ignore the scroll event if it has just been set programmatically.
if (Date.now() - lastScrollSetTime.current < 10) return;
if (Date.now() - lastScrollSetTime.current < 500) return;
setScrollTop(event.target.scrollTop);
}, []);

View File

@@ -40,12 +40,6 @@ const useVisibleRange = (itemsPerLine: number, scrollTop: number, listSize: Size
return Math.ceil(noteCount / itemsPerLine);
}, [noteCount, itemsPerLine]);
// Note: Leave this here to test the note list scroll behaviour. Also add "item.index" to the
// rows in defaultListRenderer to check whether the value here matches what's being displayed.
// `useScroll` can also be changed to display the effective scroll value.
// console.info('=======================================');
// console.info('scrollTop', scrollTop);
// console.info('itemsPerLine', itemsPerLine);
// console.info('listSize.height', listSize.height);
// console.info('itemSize.height', itemSize.height);
@@ -58,7 +52,6 @@ const useVisibleRange = (itemsPerLine: number, scrollTop: number, listSize: Size
// console.info('endLineIndex', endLineIndex);
// console.info('totalLineCount', totalLineCount);
// console.info('visibleItemCount', visibleItemCount);
// console.info('=======================================');
return [startNoteIndex, endNoteIndex, startLineIndex, endLineIndex, totalLineCount, visibleItemCount];
};

View File

@@ -33,10 +33,6 @@ export const SearchInput = styled(StyledInput)`
padding-right: 20px;
flex: 1;
width: 10px;
&::-webkit-search-cancel-button {
display: none;
}
`;
interface Props {

View File

@@ -1,6 +1,6 @@
{
"name": "@joplin/app-desktop",
"version": "3.3.2",
"version": "3.3.0",
"description": "Joplin for Desktop",
"main": "main.js",
"private": true,

View File

@@ -6,7 +6,7 @@
SCRIPT_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" >/dev/null 2>&1 && pwd )"
TEMP_PATH=~/src/plugin-tests
NEED_COMPILING=1
PLUGIN_PATH=~/src/plugin-yesyoucan
PLUGIN_PATH=~/src/joplin/packages/app-cli/tests/support/plugins/toast
if [[ $NEED_COMPILING == 1 ]]; then
mkdir -p "$TEMP_PATH"

View File

@@ -6,6 +6,7 @@ import { ToolbarButtonInfo, ToolbarItem } from '@joplin/lib/services/commands/To
import toolbarButtonsFromState from './utils/toolbarButtonsFromState';
import { useCallback, useMemo, useRef, useState } from 'react';
import { themeStyle } from '../global-style';
import ToggleSpaceButton from '../ToggleSpaceButton';
import ToolbarEditorDialog from './ToolbarEditorDialog';
import { EditorState } from './types';
import ToolbarButton from './ToolbarButton';
@@ -126,17 +127,19 @@ const EditorToolbar: React.FC<Props> = props => {
/>;
return <>
<ScrollView
ref={scrollViewRef}
horizontal={true}
style={styles.content}
contentContainerStyle={styles.contentContainer}
onLayout={onContainerLayout}
>
{buttonInfos.map(renderButton)}
<View style={styles.spacer}/>
{settingsButton}
</ScrollView>
<ToggleSpaceButton themeId={props.themeId}>
<ScrollView
ref={scrollViewRef}
horizontal={true}
style={styles.content}
contentContainerStyle={styles.contentContainer}
onLayout={onContainerLayout}
>
{buttonInfos.map(renderButton)}
<View style={styles.spacer}/>
{settingsButton}
</ScrollView>
</ToggleSpaceButton>
<ToolbarEditorDialog visible={settingsVisible} onDismiss={onDismissSettingsDialog} />
</>;
};

View File

@@ -19,14 +19,12 @@ import { focus } from '@joplin/lib/utils/focusHandler';
export const initCodeMirror = (
parentElement: HTMLElement,
initialText: string,
initialNoteId: string,
settings: EditorSettings,
): CodeMirrorControl => {
const messenger = new WebViewToRNMessenger<CodeMirrorControl, WebViewToEditorApi>('editor', null);
const control = createEditor(parentElement, {
initialText,
initialNoteId,
settings,
onPasteFile: async (data) => {

View File

@@ -48,7 +48,6 @@ describe('NoteEditor', () => {
<NoteEditor
themeId={Setting.THEME_ARITIM_DARK}
initialText='Testing...'
noteId=''
style={{}}
toolbarEnabled={true}
readOnly={false}

View File

@@ -41,7 +41,6 @@ const logger = Logger.create('NoteEditor');
interface Props {
themeId: number;
initialText: string;
noteId: string;
initialSelection?: SelectionRange;
style: ViewStyle;
toolbarEnabled: boolean;
@@ -384,12 +383,7 @@ function NoteEditor(props: Props, ref: any) {
const initialText = ${JSON.stringify(props.initialText)};
const settings = ${JSON.stringify(editorSettings)};
window.cm = codeMirrorBundle.initCodeMirror(
parentElement,
initialText,
${JSON.stringify(props.noteId)},
settings
);
window.cm = codeMirrorBundle.initCodeMirror(parentElement, initialText, settings);
${setInitialSelectionJS}

View File

@@ -432,6 +432,9 @@ class ScreenHeaderComponent extends PureComponent<ScreenHeaderProps, ScreenHeade
themeId={themeId}
description={_('Toggle plugin editor')}
accessibilityHint={
disabled ? null : _('Toggle plugin editor')
}
contentWrapperStyle={disabled ? styles.iconButtonDisabled : styles.iconButton}
iconName='ionicon eye'
@@ -625,14 +628,12 @@ class ScreenHeaderComponent extends PureComponent<ScreenHeaderProps, ScreenHeade
const restoreButtonComp = selectedFolderInTrash && this.props.noteSelectionEnabled ? restoreButton(this.styles(), () => this.restoreButton_press(), headerItemDisabled) : null;
const duplicateButtonComp = !selectedFolderInTrash && this.props.noteSelectionEnabled ? duplicateButton(this.styles(), () => this.duplicateButton_press(), headerItemDisabled) : null;
const sortButtonComp = !this.props.noteSelectionEnabled && this.props.sortButton_press ? sortButton(this.styles(), () => this.props.sortButton_press()) : null;
const togglePluginEditorButton = renderTogglePluginEditorButton(this.styles(), () => CommandService.instance().execute('toggleEditorPlugin'), false);
// To allow the notebook dropdown (and perhaps other components) to have sufficient
// space while in use, we allow certain buttons to be hidden.
const hideableRightComponents = <>
{pluginPanelsComp}
{betaIconComp}
{togglePluginEditorButton}
</>;
const titleComp = createTitleComponent(hideableRightComponents);
@@ -654,6 +655,8 @@ class ScreenHeaderComponent extends PureComponent<ScreenHeaderProps, ScreenHeade
</Menu>
);
const togglePluginEditorButton = renderTogglePluginEditorButton(this.styles(), () => CommandService.instance().execute('toggleEditorPlugin'), false);
return (
<View style={this.styles().container}>
<View style={{ flexDirection: 'row', alignItems: 'center' }}>
@@ -676,6 +679,7 @@ class ScreenHeaderComponent extends PureComponent<ScreenHeaderProps, ScreenHeade
{restoreButtonComp}
{duplicateButtonComp}
{sortButtonComp}
{togglePluginEditorButton}
{menuComp}
</View>
<WarningBanner

View File

@@ -12,7 +12,7 @@ import { themeStyle } from '@joplin/lib/theme';
import * as React from 'react';
import { ReactNode, useCallback, useState, useEffect } from 'react';
import { Platform, useWindowDimensions, View, ViewStyle } from 'react-native';
import { Platform, View, ViewStyle } from 'react-native';
import IconButton from './IconButton';
import useKeyboardVisible from '../utils/hooks/useKeyboardVisible';
@@ -77,9 +77,7 @@ const ToggleSpaceButton = (props: Props) => {
);
const { keyboardVisible } = useKeyboardVisible();
const windowSize = useWindowDimensions();
const isPortrait = windowSize.height > windowSize.width;
const spaceApplicable = keyboardVisible && Platform.OS === 'ios' && isPortrait;
const spaceApplicable = keyboardVisible && Platform.OS === 'ios';
const style: ViewStyle = {
marginBottom: spaceApplicable ? additionalSpace : 0,

View File

@@ -28,7 +28,7 @@ const PluginDialogManager: React.FC<Props> = props => {
const dialogs: ReactElement[] = [];
for (const viewInfo of viewInfos) {
if (viewInfo.view.containerType !== ContainerType.Dialog || !viewInfo.view.opened) {
if (viewInfo.view.containerType === ContainerType.Panel || viewInfo.view.containerType === ContainerType.Editor || !viewInfo.view.opened) {
continue;
}

View File

@@ -39,15 +39,11 @@ const useFileSystemPath = (settingId: string, updateSettingValue: UpdateSettingV
if (shim.mobilePlatform() === 'web') {
// Directory picker IDs can't include certain characters.
const pickerId = `setting-${settingId}`.replace(/[^a-zA-Z]/g, '_');
try {
const handle = await self.showDirectoryPicker({ id: pickerId, mode: accessMode });
const fsDriver = shim.fsDriver() as FsDriverWeb;
const uri = await fsDriver.mountExternalDirectory(handle, pickerId, accessMode);
await updateSettingValue(settingId, uri);
setFileSystemPath(uri);
} catch (error) {
if (error.name !== 'AbortError') throw error;
}
const handle = await self.showDirectoryPicker({ id: pickerId, mode: accessMode });
const fsDriver = shim.fsDriver() as FsDriverWeb;
const uri = await fsDriver.mountExternalDirectory(handle, pickerId, accessMode);
await updateSettingValue(settingId, uri);
setFileSystemPath(uri);
} else {
try {
const doc = await openDocumentTree(true);

View File

@@ -1,4 +1,4 @@
import AsyncActionQueue from '@joplin/lib/AsyncActionQueue';
import AsyncActionQueue, { IntervalType } from '@joplin/lib/AsyncActionQueue';
import uuid from '@joplin/lib/uuid';
import Setting from '@joplin/lib/models/Setting';
import shim from '@joplin/lib/shim';
@@ -40,6 +40,8 @@ import Logger from '@joplin/utils/Logger';
import ImageEditor from '../../NoteEditor/ImageEditor/ImageEditor';
import promptRestoreAutosave from '../../NoteEditor/ImageEditor/promptRestoreAutosave';
import isEditableResource from '../../NoteEditor/ImageEditor/isEditableResource';
import VoiceTypingDialog from '../../voiceTyping/VoiceTypingDialog';
import { isSupportedLanguage } from '../../../services/voiceTyping/vosk';
import { ChangeEvent as EditorChangeEvent, SelectionRangeChangeEvent, UndoRedoDepthChangeEvent } from '@joplin/editor/events';
import { join } from 'path';
import { Dispatch } from 'redux';
@@ -61,14 +63,14 @@ import { DialogContext, DialogControl } from '../../DialogManager';
import { CommandRuntimeProps, EditorMode, PickerResponse } from './types';
import commands from './commands';
import { AttachFileAction, AttachFileOptions } from './commands/attachFile';
import ToggleSpaceButton from '../../ToggleSpaceButton';
import { EditorActivationCheckFilterObject } from '@joplin/lib/services/plugins/api/types';
import eventManager from '@joplin/lib/eventManager';
import PluginService from '@joplin/lib/services/plugins/PluginService';
import WebviewController from '@joplin/lib/services/plugins/WebviewController';
import PluginUserWebView from '../../plugins/dialogs/PluginUserWebView';
import getShownPluginEditorView from '@joplin/lib/services/plugins/utils/getShownPluginEditorView';
import getActivePluginEditorView from '@joplin/lib/services/plugins/utils/getActivePluginEditorView';
import EditorPluginHandler from '@joplin/lib/services/plugins/EditorPluginHandler';
import AudioRecordingBanner from '../../voiceTyping/AudioRecordingBanner';
import SpeechToTextBanner from '../../voiceTyping/SpeechToTextBanner';
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
const emptyArray: any[] = [];
@@ -127,7 +129,6 @@ interface State {
fromShare: boolean;
showCamera: boolean;
showImageEditor: boolean;
showAudioRecorder: boolean;
imageEditorResource: ResourceEntity;
imageEditorResourceFilepath: string;
noteResources: Record<string, ResourceInfo>;
@@ -139,9 +140,23 @@ interface State {
canRedo: boolean;
};
showSpeechToTextDialog: boolean;
voiceTypingDialogShown: boolean;
}
// TODO: COPIED FROM DESKTOP
const makeNoteUpdateAction = (shownEditorViewIds: string[]) => {
return async () => {
for (const viewId of shownEditorViewIds) {
const controller = PluginService.instance().viewControllerByViewId(viewId) as WebviewController;
if (controller) controller.emitUpdate();
}
};
};
class NoteScreenComponent extends BaseScreenComponent<ComponentProps, State> implements BaseNoteScreenComponent {
// This isn't in this.state because we don't want changing scroll to trigger
// a re-render.
@@ -173,7 +188,8 @@ class NoteScreenComponent extends BaseScreenComponent<ComponentProps, State> imp
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
public dialogbox: any;
private commandRegistration_: RegisteredRuntime|null = null;
private editorPluginHandler_ = new EditorPluginHandler(PluginService.instance());
private viewUpdateAsyncQueue_ = new AsyncActionQueue(100, IntervalType.Fixed);
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
public static navigationOptions(): any {
@@ -197,7 +213,6 @@ class NoteScreenComponent extends BaseScreenComponent<ComponentProps, State> imp
fromShare: false,
showCamera: false,
showImageEditor: false,
showAudioRecorder: false,
imageEditorResource: null,
noteResources: {},
imageEditorResourceFilepath: null,
@@ -209,7 +224,7 @@ class NoteScreenComponent extends BaseScreenComponent<ComponentProps, State> imp
canRedo: false,
},
showSpeechToTextDialog: false,
voiceTypingDialogShown: false,
};
this.titleTextFieldRef = React.createRef();
@@ -326,8 +341,8 @@ class NoteScreenComponent extends BaseScreenComponent<ComponentProps, State> imp
this.screenHeader_redoButtonPress = this.screenHeader_redoButtonPress.bind(this);
this.onBodyViewerCheckboxChange = this.onBodyViewerCheckboxChange.bind(this);
this.onUndoRedoDepthChange = this.onUndoRedoDepthChange.bind(this);
this.speechToTextDialog_onText = this.speechToTextDialog_onText.bind(this);
this.audioRecorderDialog_onDismiss = this.audioRecorderDialog_onDismiss.bind(this);
this.voiceTypingDialog_onText = this.voiceTypingDialog_onText.bind(this);
this.voiceTypingDialog_onDismiss = this.voiceTypingDialog_onDismiss.bind(this);
}
private registerCommands() {
@@ -356,9 +371,6 @@ class NoteScreenComponent extends BaseScreenComponent<ComponentProps, State> imp
this.setState({ noteTagDialogShown: visible });
},
setAudioRecorderVisible: (visible) => {
this.setState({ showAudioRecorder: visible });
},
getMode: () => this.state.mode,
setMode: (mode: 'view'|'edit') => {
this.setState({ mode });
@@ -466,9 +478,6 @@ class NoteScreenComponent extends BaseScreenComponent<ComponentProps, State> imp
noteBodyViewer: {
flex: 1,
},
toggleSpaceButtonContent: {
flex: 1,
},
checkbox: {
color: theme.color,
paddingRight: 10,
@@ -572,11 +581,8 @@ class NoteScreenComponent extends BaseScreenComponent<ComponentProps, State> imp
}, 100);
}
await this.editorPluginHandler_.emitActivationCheck();
setTimeout(() => {
this.editorPluginHandler_.emitUpdate(this.props['plugins.shownEditorViewIds']);
}, 300);
// TODO: set shownEditorViewIds
this.viewUpdateAsyncQueue_.push(makeNoteUpdateAction(['plugin-view-org.joplinapp.plugins.YesYouKan-kanbanBoard']));
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
@@ -644,9 +650,20 @@ class NoteScreenComponent extends BaseScreenComponent<ComponentProps, State> imp
}
}
if (prevProps.noteId && this.props.noteId && prevProps.noteId !== this.props.noteId) {
void this.editorPluginHandler_.emitActivationCheck();
}
// **************** TODO: REUSED FROM DESKTOP APP
setTimeout(async () => {
let filterObject: EditorActivationCheckFilterObject = {
activatedEditors: [],
};
filterObject = await eventManager.filterEmit('editorActivationCheck', filterObject);
for (const editor of filterObject.activatedEditors) {
const controller = PluginService.instance().pluginById(editor.pluginId).viewController(editor.viewId) as WebviewController;
controller.setActive(editor.isActive);
}
}, 50);
// **************** REUSED FROM DESKTOP APP
}
public componentWillUnmount() {
@@ -1231,13 +1248,13 @@ class NoteScreenComponent extends BaseScreenComponent<ComponentProps, State> imp
});
}
const voiceTypingSupported = Platform.OS === 'android';
if (voiceTypingSupported) {
// Voice typing is enabled only on Android for now
if (shim.mobilePlatform() === 'android' && isSupportedLanguage(currentLocale())) {
output.push({
title: _('Voice typing...'),
onPress: () => {
// this.voiceRecording_onPress();
this.setState({ showSpeechToTextDialog: true });
this.setState({ voiceTypingDialogShown: true });
},
disabled: readOnly,
});
@@ -1425,7 +1442,7 @@ class NoteScreenComponent extends BaseScreenComponent<ComponentProps, State> imp
void this.saveOneProperty('body', newBody);
}
private speechToTextDialog_onText(text: string) {
private voiceTypingDialog_onText(text: string) {
if (this.state.mode === 'view') {
const newNote: NoteEntity = { ...this.state.note };
newNote.body = `${newNote.body} ${text}`;
@@ -1442,17 +1459,9 @@ class NoteScreenComponent extends BaseScreenComponent<ComponentProps, State> imp
}
}
private audioRecordingDialog_onFile = (file: PickerResponse) => {
return this.attachFile(file, 'audio');
};
private audioRecorderDialog_onDismiss = () => {
this.setState({ showSpeechToTextDialog: false, showAudioRecorder: false });
};
private speechToTextDialog_onDismiss = () => {
this.setState({ showSpeechToTextDialog: false });
};
private voiceTypingDialog_onDismiss() {
this.setState({ voiceTypingDialogShown: false });
}
private noteEditorVisible() {
return !this.state.showCamera && !this.state.showImageEditor;
@@ -1585,7 +1594,6 @@ class NoteScreenComponent extends BaseScreenComponent<ComponentProps, State> imp
ref={this.editorRef}
toolbarEnabled={this.props.toolbarEnabled}
themeId={this.props.themeId}
noteId={this.props.noteId}
initialText={note.body}
initialSelection={this.selection}
onChange={this.onMarkdownEditorTextChange}
@@ -1606,9 +1614,8 @@ class NoteScreenComponent extends BaseScreenComponent<ComponentProps, State> imp
}
}
const voiceTypingDialogShown = this.state.showSpeechToTextDialog || this.state.showAudioRecorder;
const renderActionButton = () => {
if (voiceTypingDialogShown) return null;
if (this.state.voiceTypingDialogShown) return null;
if (!this.state.note || !!this.state.note.deleted_time) return null;
const editButton = {
@@ -1655,37 +1662,9 @@ class NoteScreenComponent extends BaseScreenComponent<ComponentProps, State> imp
const noteTagDialog = !this.state.noteTagDialogShown ? null : <NoteTagsDialog onCloseRequested={this.noteTagDialog_closeRequested} />;
const renderVoiceTypingDialogs = () => {
const result = [];
if (this.state.showAudioRecorder) {
result.push(<AudioRecordingBanner
key='audio-recorder'
onFileSaved={this.audioRecordingDialog_onFile}
onDismiss={this.audioRecorderDialog_onDismiss}
/>);
}
if (this.state.showSpeechToTextDialog) {
result.push(<SpeechToTextBanner
key='speech-to-text'
locale={currentLocale()}
onText={this.speechToTextDialog_onText}
onDismiss={this.speechToTextDialog_onDismiss}
/>);
}
return result;
};
const renderWrappedContent = () => {
const content = <>
{bodyComponent}
{renderVoiceTypingDialogs()}
</>;
return this.state.mode === 'edit' ? (
<ToggleSpaceButton themeId={this.props.themeId} style={this.styles().toggleSpaceButtonContent}>
{content}
</ToggleSpaceButton>
) : content;
const renderVoiceTypingDialog = () => {
if (!this.state.voiceTypingDialogShown) return null;
return <VoiceTypingDialog locale={currentLocale()} onText={this.voiceTypingDialog_onText} onDismiss={this.voiceTypingDialog_onDismiss}/>;
};
const { editorPlugin: activeEditorPlugin } = getActivePluginEditorView(this.props.plugins);
@@ -1709,8 +1688,9 @@ class NoteScreenComponent extends BaseScreenComponent<ComponentProps, State> imp
title={getDisplayParentTitle(this.state.note, this.state.folder)}
/>
{titleComp}
{renderWrappedContent()}
{bodyComponent}
{renderActionButton()}
{renderVoiceTypingDialog()}
<SelectDateTimeDialog themeId={this.props.themeId} shown={this.state.alarmDialogShown} date={dueDate} onAccept={this.onAlarmDialogAccept} onReject={this.onAlarmDialogReject} />

View File

@@ -5,7 +5,6 @@ import { Platform } from 'react-native';
import pickDocument from '../../../../utils/pickDocument';
import { ImagePickerResponse, launchImageLibrary } from 'react-native-image-picker';
import Logger from '@joplin/utils/Logger';
import { msleep } from '@joplin/utils/time';
const logger = Logger.create('attachFile');
@@ -14,7 +13,6 @@ export enum AttachFileAction {
AttachFile = 'attachFile',
AttachPhoto = 'attachPhoto',
AttachDrawing = 'attachDrawing',
RecordAudio = 'attachRecording',
}
export interface AttachFileOptions {
@@ -46,13 +44,7 @@ export const runtime = (props: CommandRuntimeProps): CommandRuntime => {
};
const attachPhoto = async () => {
// the selection Limit should be specified. I think 200 is enough?
const response: ImagePickerResponse = await launchImageLibrary({
mediaType: 'mixed',
videoQuality: 'low',
formatAsMp4: true,
includeBase64: false,
selectionLimit: 200,
});
const response: ImagePickerResponse = await launchImageLibrary({ mediaType: 'photo', includeBase64: false, selectionLimit: 200 });
if (response.errorCode) {
logger.warn('Got error from picker', response.errorCode);
@@ -65,14 +57,10 @@ export const runtime = (props: CommandRuntimeProps): CommandRuntime => {
}
for (const asset of response.assets) {
await props.attachFile(asset, asset.type);
await props.attachFile(asset, 'image');
}
};
const recordAudio = async () => {
props.setAudioRecorderVisible(true);
};
const showAttachMenu = async (action: AttachFileAction = null) => {
props.hideKeyboard();
@@ -88,7 +76,6 @@ export const runtime = (props: CommandRuntimeProps): CommandRuntime => {
//
// On Android, it will depend on the phone, but usually it will allow browsing all files and photos.
buttons.push({ text: _('Attach file'), id: AttachFileAction.AttachFile });
buttons.push({ text: _('Record audio'), id: AttachFileAction.RecordAudio });
// Disabled on Android because it doesn't work due to permission issues, but enabled on iOS
// because that's only way to browse photos from the camera roll.
@@ -96,19 +83,11 @@ export const runtime = (props: CommandRuntimeProps): CommandRuntime => {
buttons.push({ text: _('Take photo'), id: AttachFileAction.TakePhoto });
buttonId = await props.dialogs.showMenu(_('Choose an option'), buttons) as AttachFileAction;
if (Platform.OS === 'ios') {
// Fixes an issue: The first time "attach file" or "attach photo" is chosen after starting Joplin
// on iOS, no attach dialog was shown. Adding a brief delay after the "choose an option" dialog is
// dismissed seems to fix the issue.
await msleep(1);
}
}
if (buttonId === AttachFileAction.TakePhoto) await takePhoto();
if (buttonId === AttachFileAction.AttachFile) await attachFile();
if (buttonId === AttachFileAction.AttachPhoto) await attachPhoto();
if (buttonId === AttachFileAction.RecordAudio) await recordAudio();
};
return {

View File

@@ -18,6 +18,5 @@ export interface CommandRuntimeProps {
setMode(mode: EditorMode): void;
setCameraVisible(visible: boolean): void;
setTagDialogVisible(visible: boolean): void;
setAudioRecorderVisible(visible: boolean): void;
dialogs: DialogControl;
}

View File

@@ -1,235 +0,0 @@
import * as React from 'react';
import { PrimaryButton, SecondaryButton } from '../buttons';
import { _ } from '@joplin/lib/locale';
import { useCallback, useEffect, useRef, useState } from 'react';
import { Audio, InterruptionModeIOS } from 'expo-av';
import Logger from '@joplin/utils/Logger';
import { OnFileSavedCallback, RecorderState } from './types';
import { Platform } from 'react-native';
import shim from '@joplin/lib/shim';
import FsDriverWeb from '../../utils/fs-driver/fs-driver-rn.web';
import uuid from '@joplin/lib/uuid';
import RecordingControls from './RecordingControls';
import { Text } from 'react-native-paper';
import { AndroidAudioEncoder, AndroidOutputFormat, IOSAudioQuality, IOSOutputFormat, RecordingOptions } from 'expo-av/build/Audio';
import time from '@joplin/lib/time';
import { toFileExtension } from '@joplin/lib/mime-utils';
import { formatMsToDurationLocal } from '@joplin/utils/time';
const logger = Logger.create('AudioRecording');
interface Props {
onFileSaved: OnFileSavedCallback;
onDismiss: ()=> void;
}
// Modified from the Expo default recording options to create
// .m4a recordings on both Android and iOS (rather than .3gp on Android).
const recordingOptions = (): RecordingOptions => ({
isMeteringEnabled: true,
android: {
extension: '.m4a',
outputFormat: AndroidOutputFormat.MPEG_4,
audioEncoder: AndroidAudioEncoder.AAC,
sampleRate: 44100,
numberOfChannels: 2,
bitRate: 64000,
},
ios: {
extension: '.m4a',
audioQuality: IOSAudioQuality.MIN,
outputFormat: IOSOutputFormat.MPEG4AAC,
sampleRate: 44100,
numberOfChannels: 2,
bitRate: 64000,
linearPCMBitDepth: 16,
linearPCMIsBigEndian: false,
linearPCMIsFloat: false,
},
web: Platform.OS === 'web' ? {
mimeType: [
// Different browsers support different audio formats.
// In most cases, prefer audio/ogg and audio/mp4 to audio/webm because
// Chrome and Firefox create .webm files without duration information.
// See https://issues.chromium.org/issues/40482588
'audio/ogg', 'audio/mp4', 'audio/webm',
].find(type => MediaRecorder.isTypeSupported(type)) ?? 'audio/webm',
bitsPerSecond: 128000,
} : {},
});
const getRecordingFileName = (extension: string) => {
return `recording-${time.formatDateToLocal(new Date())}${extension}`;
};
const recordingToSaveData = async (recording: Audio.Recording) => {
let uri = recording.getURI();
let type: string|undefined;
let fileName;
if (Platform.OS === 'web') {
// On web, we need to fetch the result (which is a blob URL) and save it in our
// virtual file system so that it can be processed elsewhere.
const fetchResult = await fetch(uri);
const blob = await fetchResult.blob();
type = recordingOptions().web.mimeType;
const extension = `.${toFileExtension(type)}`;
fileName = getRecordingFileName(extension);
const file = new File([blob], fileName);
const path = `/tmp/${uuid.create()}-${fileName}`;
await (shim.fsDriver() as FsDriverWeb).createReadOnlyVirtualFile(path, file);
uri = path;
} else {
const options = recordingOptions();
const extension = Platform.select({
android: options.android.extension,
ios: options.ios.extension,
default: '',
});
fileName = getRecordingFileName(extension);
}
return { uri, fileName, type };
};
const resetAudioMode = async () => {
await Audio.setAudioModeAsync({
// When enabled, iOS may use the small (phone call) speaker
// instead of the default one, so it's disabled when not recording:
allowsRecordingIOS: false,
playsInSilentModeIOS: false,
});
};
const useAudioRecorder = (onFileSaved: OnFileSavedCallback, onDismiss: ()=> void) => {
const [permissionResponse, requestPermissions] = Audio.usePermissions();
const [recordingState, setRecordingState] = useState<RecorderState>(RecorderState.Idle);
const [error, setError] = useState('');
const [duration, setDuration] = useState(0);
const recordingRef = useRef<Audio.Recording|null>();
const onStartRecording = useCallback(async () => {
try {
setRecordingState(RecorderState.Loading);
if (permissionResponse?.status !== 'granted') {
const response = await requestPermissions();
if (!response.granted) {
throw new Error(_('Missing permission to record audio.'));
}
}
await Audio.setAudioModeAsync({
allowsRecordingIOS: true,
playsInSilentModeIOS: true,
// Fixes an issue where opening a recording in the iOS audio player
// breaks creating new recordings.
// See https://github.com/expo/expo/issues/31152#issuecomment-2341811087
interruptionModeIOS: InterruptionModeIOS.DoNotMix,
});
setRecordingState(RecorderState.Recording);
const recording = new Audio.Recording();
await recording.prepareToRecordAsync(recordingOptions());
recording.setOnRecordingStatusUpdate(status => {
setDuration(status.durationMillis);
});
recordingRef.current = recording;
await recording.startAsync();
} catch (error) {
logger.error('Error starting recording:', error);
setError(`Recording error: ${error}`);
setRecordingState(RecorderState.Error);
void recordingRef.current?.stopAndUnloadAsync();
recordingRef.current = null;
}
}, [permissionResponse, requestPermissions]);
const onStopRecording = useCallback(async () => {
const recording = recordingRef.current;
recordingRef.current = null;
try {
setRecordingState(RecorderState.Processing);
await recording.stopAndUnloadAsync();
await resetAudioMode();
const saveEvent = await recordingToSaveData(recording);
onFileSaved(saveEvent);
onDismiss();
} catch (error) {
logger.error('Error saving recording:', error);
setError(`Save error: ${error}`);
setRecordingState(RecorderState.Error);
}
}, [onFileSaved, onDismiss]);
const onStartStopRecording = useCallback(async () => {
if (recordingState === RecorderState.Idle) {
await onStartRecording();
} else if (recordingState === RecorderState.Recording && recordingRef.current) {
await onStopRecording();
}
}, [recordingState, onStartRecording, onStopRecording]);
useEffect(() => () => {
if (recordingRef.current) {
void recordingRef.current?.stopAndUnloadAsync();
recordingRef.current = null;
void resetAudioMode();
}
}, []);
return { onStartStopRecording, error, duration, recordingState };
};
const AudioRecordingBanner: React.FC<Props> = props => {
const { recordingState, onStartStopRecording, duration, error } = useAudioRecorder(props.onFileSaved, props.onDismiss);
const onCancelPress = useCallback(async () => {
if (recordingState === RecorderState.Recording) {
const message = _('Cancelling will discard the recording. This cannot be undone. Are you sure you want to proceed?');
if (!await shim.showConfirmationDialog(message)) return;
}
props.onDismiss();
}, [recordingState, props.onDismiss]);
const startStopButtonLabel = recordingState === RecorderState.Idle ? _('Start recording') : _('Done');
const allowStartStop = recordingState === RecorderState.Idle || recordingState === RecorderState.Recording;
const actions = <>
<SecondaryButton onPress={onCancelPress}>{_('Cancel')}</SecondaryButton>
<PrimaryButton
disabled={!allowStartStop}
onPress={onStartStopRecording}
// Add additional accessibility information to make it clear that "Done" is
// associated with the voice recording banner:
accessibilityHint={recordingState === RecorderState.Recording ? _('Finishes recording') : undefined}
>{startStopButtonLabel}</PrimaryButton>
</>;
const renderDuration = () => {
if (recordingState !== RecorderState.Recording) return null;
const durationValue = formatMsToDurationLocal(duration);
return <Text
accessibilityLabel={_('Duration: %s', durationValue)}
accessibilityRole='timer'
>{durationValue}</Text>;
};
return <RecordingControls
recorderState={recordingState}
heading={recordingState === RecorderState.Recording ? _('Recording...') : _('Voice recorder')}
content={
recordingState === RecorderState.Idle
? _('Click "start" to attach a new voice memo to the note.')
: error
}
preview={renderDuration()}
actions={actions}
/>;
};
export default AudioRecordingBanner;

View File

@@ -1,94 +0,0 @@
import * as React from 'react';
import { View, StyleSheet } from 'react-native';
import { ActivityIndicator, Icon, Surface, Text } from 'react-native-paper';
import { IconSource } from 'react-native-paper/lib/typescript/components/Icon';
import AccessibleView from '../accessibility/AccessibleView';
import { RecorderState } from './types';
interface Props {
recorderState: RecorderState;
heading: string;
content: React.ReactNode|string;
preview: React.ReactNode;
actions: React.ReactNode;
}
const styles = StyleSheet.create({
container: {
paddingHorizontal: 10,
width: 680,
flexShrink: 1,
maxWidth: '100%',
alignSelf: 'center',
},
contentWrapper: {
flexDirection: 'row',
},
iconWrapper: {
margin: 8,
marginTop: 16,
},
content: {
flexShrink: 1,
marginTop: 16,
marginHorizontal: 8,
},
actionContainer: {
flexDirection: 'row',
justifyContent: 'flex-end',
gap: 6,
marginBottom: 6,
},
});
const RecordingControls: React.FC<Props> = props => {
const renderIcon = () => {
const components: Record<RecorderState, IconSource> = {
[RecorderState.Loading]: ({ size }: { size: number }) => <ActivityIndicator animating={true} style={{ width: size, height: size }} />,
[RecorderState.Recording]: 'microphone',
[RecorderState.Idle]: 'microphone',
[RecorderState.Processing]: 'microphone',
[RecorderState.Downloading]: ({ size }: { size: number }) => <ActivityIndicator animating={true} style={{ width: size, height: size }} />,
[RecorderState.Error]: 'alert-circle-outline',
};
return components[props.recorderState];
};
return <Surface>
<View style={styles.container}>
<View style={styles.contentWrapper}>
<View style={styles.iconWrapper}>
<Icon source={renderIcon()} size={40}/>
</View>
<View style={styles.content}>
<AccessibleView
// Auto-focus
refocusCounter={1}
aria-live='polite'
role='heading'
>
<Text variant='bodyMedium'>
{props.heading}
</Text>
</AccessibleView>
<Text
variant='bodyMedium'
// role="status" might fit better here. However, react-native
// doesn't seem to support it.
role='alert'
// Although on web, role=alert should imply aria-live=polite,
// this does not seem to be the case for React Native:
accessibilityLiveRegion='polite'
>{props.content}</Text>
{props.preview}
</View>
</View>
<View style={styles.actionContainer}>
{props.actions}
</View>
</View>
</Surface>;
};
export default RecordingControls;

View File

@@ -1,17 +1,17 @@
import * as React from 'react';
import { useState, useEffect, useCallback, useRef, useMemo } from 'react';
import { Text, Button } from 'react-native-paper';
import { Icon, ActivityIndicator, Text, Surface, Button } from 'react-native-paper';
import { _, languageName } from '@joplin/lib/locale';
import useAsyncEffect, { AsyncEffectEvent } from '@joplin/lib/hooks/useAsyncEffect';
import { IconSource } from 'react-native-paper/lib/typescript/components/Icon';
import VoiceTyping, { OnTextCallback, VoiceTypingSession } from '../../services/voiceTyping/VoiceTyping';
import whisper from '../../services/voiceTyping/whisper';
import vosk from '../../services/voiceTyping/vosk';
import { AppState } from '../../utils/types';
import { connect } from 'react-redux';
import { View, StyleSheet } from 'react-native';
import AccessibleView from '../accessibility/AccessibleView';
import Logger from '@joplin/utils/Logger';
import { RecorderState } from './types';
import RecordingControls from './RecordingControls';
import { PrimaryButton } from '../buttons';
const logger = Logger.create('VoiceTypingDialog');
@@ -22,6 +22,14 @@ interface Props {
onText: (text: string)=> void;
}
enum RecorderState {
Loading = 1,
Recording = 2,
Processing = 3,
Error = 4,
Downloading = 5,
}
interface UseVoiceTypingProps {
locale: string;
provider: string;
@@ -101,7 +109,31 @@ const useVoiceTyping = ({ locale, provider, onSetPreview, onText }: UseVoiceTypi
};
};
const SpeechToTextComponent: React.FC<Props> = props => {
const styles = StyleSheet.create({
container: {
marginHorizontal: 1,
width: '100%',
maxWidth: 680,
alignSelf: 'center',
},
contentWrapper: {
flexDirection: 'row',
},
iconWrapper: {
margin: 8,
marginTop: 16,
},
content: {
marginTop: 16,
marginHorizontal: 8,
},
actionContainer: {
flexDirection: 'row',
justifyContent: 'flex-end',
},
});
const VoiceTypingDialog: React.FC<Props> = props => {
const [recorderState, setRecorderState] = useState<RecorderState>(RecorderState.Loading);
const [preview, setPreview] = useState<string>('');
const {
@@ -145,7 +177,6 @@ const SpeechToTextComponent: React.FC<Props> = props => {
const renderContent = () => {
const components: Record<RecorderState, ()=> string> = {
[RecorderState.Loading]: () => _('Loading...'),
[RecorderState.Idle]: () => 'Waiting...', // Not used for now
[RecorderState.Recording]: () => _('Please record your voice...'),
[RecorderState.Processing]: () => _('Converting speech to text...'),
[RecorderState.Downloading]: () => _('Downloading %s language files...', languageName(props.locale)),
@@ -155,6 +186,18 @@ const SpeechToTextComponent: React.FC<Props> = props => {
return components[recorderState]();
};
const renderIcon = () => {
const components: Record<RecorderState, IconSource> = {
[RecorderState.Loading]: ({ size }: { size: number }) => <ActivityIndicator animating={true} style={{ width: size, height: size }} />,
[RecorderState.Recording]: 'microphone',
[RecorderState.Processing]: 'microphone',
[RecorderState.Downloading]: ({ size }: { size: number }) => <ActivityIndicator animating={true} style={{ width: size, height: size }} />,
[RecorderState.Error]: 'alert-circle-outline',
};
return components[recorderState];
};
const renderPreview = () => {
return <Text variant='labelSmall'>{preview}</Text>;
};
@@ -164,23 +207,48 @@ const SpeechToTextComponent: React.FC<Props> = props => {
</Button>;
const allowReDownload = recorderState === RecorderState.Error || modelIsOutdated;
const actions = <>
{allowReDownload ? reDownloadButton : null}
<PrimaryButton
onPress={onDismiss}
accessibilityHint={_('Ends voice typing')}
>{_('Done')}</PrimaryButton>
</>;
return <RecordingControls
recorderState={recorderState}
heading={_('Voice typing...')}
content={renderContent()}
preview={renderPreview()}
actions={actions}
/>;
return (
<Surface>
<View style={styles.container}>
<View style={styles.contentWrapper}>
<View style={styles.iconWrapper}>
<Icon source={renderIcon()} size={40}/>
</View>
<View style={styles.content}>
<AccessibleView
// Auto-focus
refocusCounter={1}
aria-live='polite'
role='heading'
>
<Text variant='bodyMedium'>
{_('Voice typing...')}
</Text>
</AccessibleView>
<Text
variant='bodyMedium'
// role="status" might fit better here. However, react-native
// doesn't seem to support it.
role='alert'
// Although on web, role=alert should imply aria-live=polite,
// this does not seem to be the case for React Native:
accessibilityLiveRegion='polite'
>{renderContent()}</Text>
{renderPreview()}
</View>
</View>
<View style={styles.actionContainer}>
{allowReDownload ? reDownloadButton : null}
<Button
onPress={onDismiss}
accessibilityHint={_('Ends voice typing')}
>{_('Done')}</Button>
</View>
</View>
</Surface>
);
};
export default connect((state: AppState) => ({
provider: state.settings['voiceTyping.preferredProvider'],
}))(SpeechToTextComponent);
}))(VoiceTypingDialog);

View File

@@ -1,16 +0,0 @@
export interface OnFileEvent {
uri: string;
fileName: string;
type: string|undefined;
}
export type OnFileSavedCallback = (event: OnFileEvent)=> void;
export enum RecorderState {
Loading = 1,
Recording = 2,
Processing = 3,
Error = 4,
Downloading = 5,
Idle = 6,
}

View File

@@ -72,8 +72,6 @@
<string>The images will be displayed on your notes.</string>
<key>NSPhotoLibraryUsageDescription</key>
<string>To allow attaching images to a note</string>
<key>NSMicrophoneUsageDescription</key>
<string>To allow attaching voice recordings to a note</string>
<key>UIAppFonts</key>
<array>
<string>AntDesign.ttf</string>

View File

@@ -1,9 +1,6 @@
PODS:
- boost (1.83.0)
- DoubleConversion (1.1.6)
- EXAV (14.0.7):
- ExpoModulesCore
- ReactCommon/turbomodule/core
- EXConstants (16.0.2):
- ExpoModulesCore
- Expo (51.0.26):
@@ -1394,7 +1391,6 @@ PODS:
DEPENDENCIES:
- boost (from `../node_modules/react-native/third-party-podspecs/boost.podspec`)
- DoubleConversion (from `../node_modules/react-native/third-party-podspecs/DoubleConversion.podspec`)
- EXAV (from `../node_modules/expo-av/ios`)
- EXConstants (from `../node_modules/expo-constants/ios`)
- Expo (from `../node_modules/expo`)
- ExpoAsset (from `../node_modules/expo-asset/ios`)
@@ -1499,8 +1495,6 @@ EXTERNAL SOURCES:
:podspec: "../node_modules/react-native/third-party-podspecs/boost.podspec"
DoubleConversion:
:podspec: "../node_modules/react-native/third-party-podspecs/DoubleConversion.podspec"
EXAV:
:path: "../node_modules/expo-av/ios"
EXConstants:
:path: "../node_modules/expo-constants/ios"
Expo:
@@ -1686,7 +1680,6 @@ EXTERNAL SOURCES:
SPEC CHECKSUMS:
boost: d3f49c53809116a5d38da093a8aa78bf551aed09
DoubleConversion: 76ab83afb40bddeeee456813d9c04f67f78771b5
EXAV: afa491e598334bbbb92a92a2f4dd33d7149ad37f
EXConstants: 409690fbfd5afea964e5e9d6c4eb2c2b59222c59
Expo: f3e39cddde295c79d206e972a59693cbb329ef46
ExpoAsset: 323700f291684f110fb55f0d4022a3362ea9f875

View File

@@ -69,8 +69,6 @@ const emptyMockPackages = [
'react-native-image-picker',
'react-native-document-picker',
'@joplin/react-native-saf-x',
'expo-av',
'expo-av/build/Audio',
];
for (const packageName of emptyMockPackages) {
jest.doMock(packageName, () => {

View File

@@ -42,7 +42,6 @@
"deprecated-react-native-prop-types": "5.0.0",
"events": "3.3.0",
"expo": "51.0.26",
"expo-av": "14.0.7",
"expo-camera": "15.0.16",
"lodash": "4.17.21",
"md5": "2.3.0",

View File

@@ -1 +1 @@
module.exports = {"hash":"cfa07333af79f4db4bc9ca008fb257f8","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":"cfa07333af79f4db4bc9ca008fb257f8","files":[".DS_Store","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

View File

@@ -0,0 +1,13 @@
module.exports = {
hash: '55cc4fcf19c129e3824873d98ad417c9', files: {
'fontawesome/css/all.min.css': { data: require('./fontawesome/css/all.min.css.base64.js'), mime: 'text/css', encoding: 'base64' },
'fontawesome/webfonts/fa-brands-400.ttf': { data: require('./fontawesome/webfonts/fa-brands-400.ttf.base64.js'), mime: 'application/octet-stream', encoding: 'base64' },
'fontawesome/webfonts/fa-brands-400.woff2': { data: require('./fontawesome/webfonts/fa-brands-400.woff2.base64.js'), mime: 'application/octet-stream', encoding: 'base64' },
'fontawesome/webfonts/fa-regular-400.ttf': { data: require('./fontawesome/webfonts/fa-regular-400.ttf.base64.js'), mime: 'application/octet-stream', encoding: 'base64' },
'fontawesome/webfonts/fa-regular-400.woff2': { data: require('./fontawesome/webfonts/fa-regular-400.woff2.base64.js'), mime: 'application/octet-stream', encoding: 'base64' },
'fontawesome/webfonts/fa-solid-900.ttf': { data: require('./fontawesome/webfonts/fa-solid-900.ttf.base64.js'), mime: 'application/octet-stream', encoding: 'base64' },
'fontawesome/webfonts/fa-solid-900.woff2': { data: require('./fontawesome/webfonts/fa-solid-900.woff2.base64.js'), mime: 'application/octet-stream', encoding: 'base64' },
'fontawesome/webfonts/fa-v4compatibility.ttf': { data: require('./fontawesome/webfonts/fa-v4compatibility.ttf.base64.js'), mime: 'application/octet-stream', encoding: 'base64' },
'fontawesome/webfonts/fa-v4compatibility.woff2': { data: require('./fontawesome/webfonts/fa-v4compatibility.woff2.base64.js'), mime: 'application/octet-stream', encoding: 'base64' },
},
};

View File

@@ -0,0 +1 @@
module.exports = { 'hash': '55cc4fcf19c129e3824873d98ad417c9', 'files': ['fontawesome/css/all.min.css', 'fontawesome/webfonts/fa-brands-400.ttf', 'fontawesome/webfonts/fa-brands-400.woff2', 'fontawesome/webfonts/fa-regular-400.ttf', 'fontawesome/webfonts/fa-regular-400.woff2', 'fontawesome/webfonts/fa-solid-900.ttf', 'fontawesome/webfonts/fa-solid-900.woff2', 'fontawesome/webfonts/fa-v4compatibility.ttf', 'fontawesome/webfonts/fa-v4compatibility.woff2'] };

View File

@@ -5,7 +5,7 @@ const path = require('path');
const md5 = require('md5');
const rootDir = `${__dirname}/..`;
const outputDir = `${rootDir}/pluginAssets`;
const defaultOutputDir = `${rootDir}/pluginAssets`;
const walk = function(dir) {
let results = [];
@@ -37,7 +37,7 @@ const readAsBase64 = async (path, mime) => {
return buffer.toString('base64');
};
async function encodeFile(sourcePath, destPath) {
async function encodeFile(sourcePath, destPath, outputDir) {
const ext = utils.fileExtension(sourcePath).toLowerCase();
let mime = 'application/octet-stream';
if (ext === 'js') mime = 'application/javascript';
@@ -60,19 +60,35 @@ async function encodeFile(sourcePath, destPath) {
};
}
async function main() {
const copyFontAwesomeAssets = async () => {
const sourceDir = `${rootDir}/node_modules/@fortawesome/fontawesome-free`;
const targetDir = `${rootDir}/fontawesome-temp`;
await fs.remove(targetDir);
await fs.mkdirp(`${targetDir}/fontawesome/css`);
await fs.mkdirp(`${targetDir}/fontawesome/webfonts`);
await fs.copyFile(`${sourceDir}/css/all.min.css`, `${targetDir}/fontawesome/css/all.min.css`);
await fs.copy(`${sourceDir}/webfonts`, `${targetDir}/fontawesome/webfonts`);
return targetDir;
};
const encodeDirectory = async (sourceAssetDir) => {
for (let i = 0; i < 3; i++) {
try {
const outputDir = sourceAssetDir.destination ? sourceAssetDir.destination : defaultOutputDir;
await fs.remove(outputDir);
await utils.mkdirp(outputDir);
const encodedFiles = [];
const sourceAssetDir = `${rootDir}/../renderer/assets`;
const files = walk(sourceAssetDir);
const files = walk(sourceAssetDir.source);
for (const file of files) {
const destFile = file.substr(sourceAssetDir.length + 1);
encodedFiles.push(await encodeFile(file, destFile));
if (file.endsWith('.DS_Store')) continue;
const destFile = file.substr(sourceAssetDir.source.length + 1);
encodedFiles.push(await encodeFile(file, destFile, outputDir));
}
const hashes = [];
@@ -87,7 +103,7 @@ async function main() {
await fs.writeFile(`${outputDir}/index.js`, `module.exports = {\nhash:"${hash}", files: {\n${indexJs.join('\n')}\n}\n};`);
await fs.writeFile(`${outputDir}/index.web.js`, `module.exports = ${JSON.stringify({
hash,
files: files.map(file => toForwardSlashes(path.relative(sourceAssetDir, file))),
files: files.map(file => toForwardSlashes(path.relative(sourceAssetDir.source, file))),
})}`);
return;
@@ -115,6 +131,28 @@ async function main() {
}
throw new Error('Could not encode file after multiple attempts. See above for errors.');
};
async function main() {
const fontAwesomeAssetDir = await copyFontAwesomeAssets();
const sourceAssetDirs = [
{
source: `${rootDir}/../renderer/assets`,
},
{
source: fontAwesomeAssetDir,
destination: `${rootDir}/plugins/pluginUserWebViewAssets/fontawesome`,
},
];
try {
for (const sourceAssetDir of sourceAssetDirs) {
await encodeDirectory(sourceAssetDir);
}
} finally {
await fs.remove(fontAwesomeAssetDir);
}
}
module.exports = main;

View File

@@ -10,7 +10,7 @@
http-equiv="Content-Security-Policy"
content="
default-src 'self' ;
connect-src 'self' * http://* https://* blob: ;
connect-src 'self' * http://* https://* ;
style-src 'unsafe-inline' 'self' blob: ;
child-src 'self' ;
script-src 'self' 'unsafe-eval' 'unsafe-inline' ;

View File

@@ -2,7 +2,7 @@ import { ViewPlugin } from '@codemirror/view';
import createEditorControl from './testUtil/createEditorControl';
import { EditorCommandType } from '../types';
import pressReleaseKey from './testUtil/pressReleaseKey';
import { EditorSelection, EditorState } from '@codemirror/state';
import { EditorSelection } from '@codemirror/state';
describe('CodeMirrorControl', () => {
it('clearHistory should clear the undo/redo history', () => {
@@ -208,43 +208,4 @@ describe('CodeMirrorControl', () => {
expect(control.editor.state.doc.toString()).toBe(expected);
});
it('updateBody should update the note ID facet and dispatch changes', () => {
const control = createEditorControl('test');
const noteIdFacet = control.joplinExtensions.noteIdFacet;
const getFacet = () => control.editor.state.facet(noteIdFacet);
control.updateBody('Test', { noteId: 'updated' });
expect(getFacet()).toBe('updated');
// Updating the ID without updating the body should change the ID
control.updateBody('Test', { noteId: 'updated 2' });
expect(getFacet()).toBe('updated 2');
// Changing the body, without specifying a new note ID, should
// not update the facet.
control.updateBody('Test 2');
expect(getFacet()).toBe('updated 2');
});
it('updateBody should dispatch changes to the note ID', () => {
const control = createEditorControl('test');
let noteId = '';
const noteIdChangeListener = EditorState.transactionExtender.of(transaction => {
for (const effect of transaction.effects) {
if (effect.is(control.joplinExtensions.setNoteIdEffect)) {
noteId = effect.value;
}
}
return null;
});
control.addExtension(noteIdChangeListener);
control.updateBody('Test', { noteId: 'updated' });
expect(noteId).toBe('updated');
control.updateBody('Test', { noteId: 'updated-2' });
expect(noteId).toBe('updated-2');
});
});

View File

@@ -1,5 +1,5 @@
import { EditorView, KeyBinding, keymap } from '@codemirror/view';
import { EditorCommandType, EditorControl, EditorSettings, LogMessageCallback, ContentScriptData, SearchState, UserEventSource, UpdateBodyOptions } from '../types';
import { EditorCommandType, EditorControl, EditorSettings, LogMessageCallback, ContentScriptData, SearchState, UserEventSource } from '../types';
import CodeMirror5Emulation from './CodeMirror5Emulation/CodeMirror5Emulation';
import editorCommands from './editorCommands/editorCommands';
import { Compartment, EditorSelection, Extension, StateEffect } from '@codemirror/state';
@@ -11,7 +11,6 @@ import { CompletionSource } from '@codemirror/autocomplete';
import { RegionSpec } from './utils/formatting/RegionSpec';
import toggleInlineSelectionFormat from './utils/formatting/toggleInlineSelectionFormat';
import getSearchState from './utils/getSearchState';
import { noteIdFacet, setNoteIdEffect } from './utils/selectedNoteIdExtension';
interface Callbacks {
onUndoRedo(): void;
@@ -119,44 +118,32 @@ export default class CodeMirrorControl extends CodeMirror5Emulation implements E
);
}
public updateBody(newBody: string, { noteId: newNoteId }: UpdateBodyOptions = {}) {
const state = this.editor.state;
const noteIdChanged = newNoteId && newNoteId !== state.facet(noteIdFacet);
const updateNoteIdEffects = noteIdChanged ? [setNoteIdEffect.of(newNoteId)] : [];
public updateBody(newBody: string) {
// TODO: doc.toString() can be slow for large documents.
const currentBody = state.doc.toString();
const currentBody = this.editor.state.doc.toString();
if (newBody !== currentBody) {
// For now, collapse the selection to a single cursor
// to ensure that the selection stays within the document
// (and thus avoids an exception).
const mainCursorPosition = state.selection.main.anchor;
const mainCursorPosition = this.editor.state.selection.main.anchor;
// The maximum cursor position needs to be calculated using the EditorState,
// to correctly account for line endings.
const maxCursorPosition = state.toText(newBody).length;
const maxCursorPosition = this.editor.state.toText(newBody).length;
const newCursorPosition = Math.min(mainCursorPosition, maxCursorPosition);
this.editor.dispatch(state.update({
this.editor.dispatch(this.editor.state.update({
changes: {
from: 0,
to: state.doc.length,
to: this.editor.state.doc.length,
insert: newBody,
},
selection: EditorSelection.cursor(newCursorPosition),
scrollIntoView: true,
effects: [...updateNoteIdEffects],
}));
return true;
} else if (updateNoteIdEffects.length) {
this.editor.dispatch(state.update({
effects: updateNoteIdEffects,
}));
// Although the note ID field changed, the body is the same:
return false;
}
return false;
@@ -262,11 +249,6 @@ export default class CodeMirrorControl extends CodeMirror5Emulation implements E
// See https://discuss.codemirror.net/t/autocompletion-merging-override-in-config/7853
completionSource: (completionSource: CompletionSource) => editorCompletionSource.of(completionSource),
enableLanguageDataAutocomplete: enableLanguageDataAutocomplete,
// Allow accessing the current note ID
noteIdFacet: noteIdFacet.reader,
// Allows watching the note ID for changes
setNoteIdEffect,
};
public addExtension(extension: Extension) {

View File

@@ -36,7 +36,6 @@ describe('createEditor', () => {
await loadLanguages();
const editor = createEditor(document.body, {
initialText,
initialNoteId: '',
settings: editorSettings,
onEvent: _event => {},
onLogMessage: _message => {},
@@ -65,7 +64,6 @@ describe('createEditor', () => {
const editor = createEditor(document.body, {
initialText,
initialNoteId: '',
settings: editorSettings,
onEvent: _event => {},
onLogMessage: _message => {},
@@ -134,7 +132,6 @@ describe('createEditor', () => {
const editor = createEditor(document.body, {
initialText,
initialNoteId: '',
settings: editorSettings,
onEvent: _event => {},
onLogMessage: _message => {},
@@ -177,21 +174,4 @@ describe('createEditor', () => {
// Should be one script container for each plugin
expect(document.querySelectorAll('#joplin-plugin-scripts-container script')).toHaveLength(2);
});
it('should be possible to access the initial note ID', () => {
const initialText = '# Test\nThis is a test.';
const editorSettings = createEditorSettings(Setting.THEME_LIGHT);
const editor = createEditor(document.body, {
initialText,
initialNoteId: 'Initial note ID',
settings: editorSettings,
onEvent: () => {},
onLogMessage: () => {},
onPasteFile: null,
});
const editorState = editor.editor.state;
const idFacet = editor.joplinExtensions.noteIdFacet;
expect(editorState.facet(idFacet)).toBe('Initial note ID');
});
});

View File

@@ -35,7 +35,6 @@ import searchExtension from './utils/searchExtension';
import isCursorAtBeginning from './utils/isCursorAtBeginning';
import overwriteModeExtension from './utils/overwriteModeExtension';
import handleLinkEditRequests, { showLinkEditor } from './utils/handleLinkEditRequests';
import selectedNoteIdExtension, { setNoteIdEffect } from './utils/selectedNoteIdExtension';
// Newer versions of CodeMirror by default use Chrome's EditContext API.
// While this might be stable enough for desktop use, it causes significant
@@ -277,8 +276,6 @@ const createEditor = (
biDirectionalTextExtension,
overwriteModeExtension,
selectedNoteIdExtension,
props.localisations ? EditorState.phrases.of(props.localisations) : [],
// Adds additional CSS classes to tokens (the default CSS classes are
@@ -303,10 +300,6 @@ const createEditor = (
parent: parentElement,
});
editor.dispatch(editor.state.update({
effects: setNoteIdEffect.of(props.initialNoteId),
}));
const editorControls = new CodeMirrorControl(editor, {
onClearHistory: () => {
// Clear history by removing then re-add the history extension.

View File

@@ -7,7 +7,6 @@ const createEditorControl = (initialText: string) => {
return createEditor(document.body, {
initialText,
initialNoteId: '',
settings: editorSettings,
onEvent: _event => {},
onLogMessage: _message => {},

View File

@@ -1,27 +0,0 @@
import { Facet, StateEffect, StateField } from '@codemirror/state';
// Allows updating the note ID stored in the state. Accessing the
// note ID associated with the editor can be useful for plugins.
export const setNoteIdEffect = StateEffect.define<string>();
// Allows accessing the note ID
export const noteIdFacet = Facet.define<string, string>({
combine: (possibleValues) => {
return possibleValues[0] ?? '';
},
});
const noteIdField = StateField.define({
create: () => '',
update: (oldValue, transaction) => {
for (const e of transaction.effects) {
if (e.is(setNoteIdEffect)) {
return e.value;
}
}
return oldValue;
},
provide: (field) => noteIdFacet.from(field),
});
export default [noteIdField];

View File

@@ -1,8 +1,9 @@
import type { Theme } from '@joplin/lib/themes/type';
import type { EditorEvent } from './events';
// Editor commands. Plugins can access these commands using editor.execCommand
// or, in some cases, by prefixing the command name with `editor.`.
// Editor commands. For compatibility, the string values of these commands
// should correspond with the CodeMirror 5 commands:
// https://codemirror.net/5/doc/manual.html#commands
export enum EditorCommandType {
Undo = 'undo',
Redo = 'redo',
@@ -95,10 +96,6 @@ export enum UserEventSource {
Drop = 'input.drop',
}
export interface UpdateBodyOptions {
noteId?: string;
}
export interface EditorControl {
supportsCommand(name: EditorCommandType|string): boolean|Promise<boolean>;
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
@@ -113,7 +110,7 @@ export interface EditorControl {
setScrollPercent(fraction: number): void;
insertText(text: string, source?: UserEventSource): void;
updateBody(newBody: string, UpdateBodyOptions?: UpdateBodyOptions): void;
updateBody(newBody: string): void;
updateSettings(newSettings: EditorSettings): void;
@@ -192,7 +189,6 @@ interface Localisations {
export interface EditorProps {
settings: EditorSettings;
initialText: string;
initialNoteId: string;
// Used mostly for internal editor library strings
localisations?: Localisations;

View File

@@ -622,22 +622,6 @@ export interface CodeMirrorControl {
*/
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
enableLanguageDataAutocomplete: { of: (enabled: boolean)=> any };
/**
* A CodeMirror [facet](https://codemirror.net/docs/ref/#state.EditorState.facet) that contains
* the ID of the note currently open in the editor.
*
* Access the value of this facet using
* ```ts
* const noteIdFacet = editorControl.joplinExtensions.noteIdFacet;
* const editorState = editorControl.editor.state;
* const noteId = editorState.facet(noteIdFacet);
* ```
*/
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- No better type available
noteIdFacet: any;
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- No better type available
setNoteIdEffect: any;
};
}

View File

@@ -14,7 +14,7 @@
"author": "Laurent Cozic",
"license": "MIT",
"dependencies": {
"@adobe/css-tools": "4.4.1",
"@adobe/css-tools": "4.4.0",
"@joplin/fork-htmlparser2": "^4.1.57",
"datauri": "4.1.0",
"fs-extra": "11.2.0",

View File

@@ -499,7 +499,7 @@ const builtInMetadata = (Setting: typeof SettingType) => {
},
'ocr.enabled': {
value: true,
value: false,
type: SettingItemType.Bool,
public: true,
appTypes: [AppType.Desktop],

View File

@@ -40,7 +40,7 @@
"typescript": "5.4.5"
},
"dependencies": {
"@adobe/css-tools": "4.4.1",
"@adobe/css-tools": "4.4.0",
"@aws-sdk/client-s3": "3.296.0",
"@aws-sdk/s3-request-presigner": "3.296.0",
"@joplin/fork-htmlparser2": "^4.1.57",

View File

@@ -207,17 +207,4 @@ describe('InteropService_Importer_OneNote', () => {
}
BaseModel.setIdGenerator(originalIdGenerator);
});
skipIfNotCI('should be able to create notes from corrupted attachment', async () => {
let idx = 0;
const originalIdGenerator = BaseModel.setIdGenerator(() => String(idx++));
const notes = await importNote(`${supportDir}/onenote/corrupted_attachment.zip`);
expect(notes.length).toBe(2);
for (const note of notes) {
expect(note.body).toMatchSnapshot(note.title);
}
BaseModel.setIdGenerator(originalIdGenerator);
});
});

View File

@@ -1,142 +1,5 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`InteropService_Importer_OneNote should be able to create notes from corrupted attachment: new_section 1`] = `
"<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>new_section</title>
<style>
html, body { margin: 0; padding: 0; }
body {
display: flex;
}
nav {
height: 100vh;
min-width: 200px;
max-width: 300px;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol";
border-right: 1px solid rgb(235, 235, 235);
}
nav ul {
padding: 0;
margin: 0;
overflow-y: auto;
height: 100%;
}
nav li {
padding: 10px 20px;
border-bottom: 1px solid rgb(235, 235, 235);
text-overflow: ellipsis;
overflow: hidden;
white-space: nowrap;
}
nav li.active {
background-color: rgb(233, 233, 233);
}
nav a {
color: black;
text-decoration: none;
}
.content {
flex: 1;
}
</style>
</head>
<body>
<nav>
<ul>
<li class="l1"><a href=":/3" target="content" title="title">title</a></li>
</ul>
</nav>
<iframe src="" frameborder="0" name="content" class="content"></iframe>
<style>
.l2 { padding: 10px 20px 10px 40px }
.l3 { padding: 10px 20px 10px 60px }
.l4 { padding: 10px 20px 10px 80px }
.l5 { padding: 10px 20px 10px 100px }
</style>
<script>
document.addEventListener('click', function (event) {
// If the clicked element doesn't have the right selector, bail
if (!event.target.matches('nav a')) return;
for (const child of event.target.parentElement.parentElement.children) {
child.classList.remove('active');
}
event.target.parentElement.classList.add('active');
}, false);
window.addEventListener('message', (event) => {
const activeTarget = event.data;
for (const link of document.querySelectorAll('nav ul li a')) {
if (link.href === activeTarget) {
link.parentElement.classList.add('active');
}
}
});
if (window.parent !== null) {
window.parent.postMessage(window.location.href, '*');
}
</script>
</body>
</html>"
`;
exports[`InteropService_Importer_OneNote should be able to create notes from corrupted attachment: title 1`] = `
"<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>title</title>
<style>
* { margin: 0; padding: 0; font-weight: normal; }
table, tr, td { border-color: #A3A3A3; }
ul, ol { padding: 0; }
.title .outline-element { display: inline; }
.title .outline-element:nth-child(2) { margin-left: 10px !important; }
.container-outline { font-family: Calibri, sans-serif; font-size: 6pt; }
.ink-text, .ink-space { display: inline-block; position: relative; vertical-align: bottom; }
.ink-text { top: 0; left: 0; }
.note-tag-icon { position: relative; }
.note-tag-icon > svg { position: absolute; }
.icon-secondary > svg { position: absolute; fill: black; filter: drop-shadow(0 0 2px white); height: 12px; top: -1px; }
.icon-secondary > .content { position: absolute; color: black; filter: drop-shadow(0 0 2px white); font-size: 10px; color: black; top: -1px; user-select: none; }
</style>
</head>
<body>
<div class="title" style="left: 48px; position: absolute; top: 24px;"><div class="container-outline" style="width: 624px;"><div class="outline-element" style="margin-left: 0px;"><span style="font-family: Calibri Light; font-size: 20pt;">title</span></div>
</div></div><div class="container-outline" style="left: 48px; position: absolute; top: 120px; width: 624px;"><div class="outline-element" style="margin-left: 0px;"><p style="font-size: 11pt; line-height: 17px;"><a href="Untitled">Untitled</a></p></div>
<div class="outline-element" style="margin-left: 0px;"><p style="font-size: 11pt; line-height: 17px;"><a href=":/5">Untitled-0.</a></p></div>
</div><div class="container-outline" style="left: 909px; position: absolute; top: 132px; width: 624px;"><div class="outline-element" style="margin-left: 0px;"><p style="font-family: Calibri; font-size: 11pt;">&nbsp;</p></div>
</div>
<script>
if (window.parent !== null) {
window.parent.postMessage(window.location.href, '*');
}
</script>
</body>
</html>"
`;
exports[`InteropService_Importer_OneNote should expect notes to be rendered the same: A page can have any width it wants 1`] = `
"<html xmlns="http://www.w3.org/1999/xhtml"><head>
<meta charset="UTF-8" />

View File

@@ -1,52 +0,0 @@
// The goal of this class is to simplify the integration of the `joplin.views.editor` plugin logic
// in the desktop and mobile app. See here for more information:
//
// packages/lib/services/plugins/api/JoplinViewsEditor.ts
import Logger from '@joplin/utils/Logger';
import AsyncActionQueue, { IntervalType } from '../../AsyncActionQueue';
import eventManager from '../../eventManager';
import { EditorActivationCheckFilterObject } from './api/types';
import type PluginService from './PluginService';
import WebviewController from './WebviewController';
const logger = Logger.create('EditorPluginHandler');
const makeNoteUpdateAction = (pluginService: PluginService, shownEditorViewIds: string[]) => {
return async () => {
for (const viewId of shownEditorViewIds) {
const controller = pluginService.viewControllerByViewId(viewId) as WebviewController;
if (controller) controller.emitUpdate();
}
};
};
export default class {
private pluginService_: PluginService;
private viewUpdateAsyncQueue_ = new AsyncActionQueue(100, IntervalType.Fixed);
public constructor(pluginService: PluginService) {
this.pluginService_ = pluginService;
}
public emitUpdate(shownEditorViewIds: string[]) {
logger.info('emitUpdate:', shownEditorViewIds);
this.viewUpdateAsyncQueue_.push(makeNoteUpdateAction(this.pluginService_, shownEditorViewIds));
}
public async emitActivationCheck() {
let filterObject: EditorActivationCheckFilterObject = {
activatedEditors: [],
};
filterObject = await eventManager.filterEmit('editorActivationCheck', filterObject);
logger.info('emitActivationCheck: responses:', filterObject);
for (const editor of filterObject.activatedEditors) {
const controller = this.pluginService_.pluginById(editor.pluginId).viewController(editor.viewId) as WebviewController;
controller.setActive(editor.isActive);
}
}
}

View File

@@ -157,10 +157,13 @@ export default class WebviewController extends ViewController {
public emitUpdate() {
if (!this.updateListener_) return;
if (this.containerType_ === ContainerType.Editor && (!this.isActive() || !this.isVisible())) {
logger.info('emitMessage: Not emitting update because editor is disabled or hidden:', this.pluginId, this.handle, this.isActive(), this.isVisible());
return;
}
// TODO:
// if (this.containerType_ === ContainerType.Editor && (!this.isActive() || !this.isVisible())) {
// logger.info('emitMessage: Not emitting update because editor is disabled or hidden:', this.pluginId, this.handle, this.isActive(), this.isVisible());
// return;
// }
this.updateListener_();
}

View File

@@ -93,10 +93,10 @@ export default class JoplinViewsEditors {
}
/**
* Emitted when the editor can potentially be activated - this is for example when the current
* note is changed, or when the application is opened. At that point you should check the
* current note and decide whether your editor should be activated or not. If it should, return
* `true`, otherwise return `false`.
* Emitted when the editor can potentially be activated - this for example when the current note
* is changed, or when the application is opened. At that point should can check the current
* note and decide whether your editor should be activated or not. If it should return `true`,
* otherwise return `false`.
*/
public async onActivationCheck(handle: ViewHandle, callback: ActivationCheckCallback): Promise<void> {
const handler: FilterHandler<EditorActivationCheckFilterObject> = async (object) => {
@@ -118,7 +118,7 @@ export default class JoplinViewsEditors {
}
/**
* Emitted when your editor content should be updated. This is for example when the currently
* Emitted when the editor content should be updated. This for example when the currently
* selected note changes, or when the user makes the editor visible.
*/
public async onUpdate(handle: ViewHandle, callback: UpdateCallback): Promise<void> {

View File

@@ -622,27 +622,6 @@ export interface CodeMirrorControl {
*/
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
enableLanguageDataAutocomplete: { of: (enabled: boolean)=> any };
/**
* A CodeMirror [facet](https://codemirror.net/docs/ref/#state.EditorState.facet) that contains
* the ID of the note currently open in the editor.
*
* Access the value of this facet using
* ```ts
* const noteIdFacet = editorControl.joplinExtensions.noteIdFacet;
* const editorState = editorControl.editor.state;
* const noteId = editorState.facet(noteIdFacet);
* ```
*/
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- No better type available
noteIdFacet: any;
/**
* A CodeMirror [StateEffect](https://codemirror.net/docs/ref/#state.StateEffect) that is
* included in a [Transaction](https://codemirror.net/docs/ref/#state.Transaction) when the
* note ID changes.
*/
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- No better type available
setNoteIdEffect: any;
};
}

View File

@@ -2,7 +2,6 @@
const { shimInit } = require('./shim-init-node');
import shim from './shim';
import { setupDatabaseAndSynchronizer, supportDir } from './testing/test-utils';
import { copyFile } from 'fs-extra';
describe('shim-init-node', () => {
@@ -11,21 +10,10 @@ describe('shim-init-node', () => {
shimInit();
});
test('should set the correct mime for a PDF file even if the extension is missing', async () => {
test('should set mime the correct mime for a PDF file even if the extension is missing', async () => {
const filePath = `${supportDir}/valid_pdf_without_ext`;
const resource = await shim.createResourceFromPath(filePath);
expect(resource.mime).toBe('application/pdf');
});
test('should preserve the file extension if one is provided regardless of the mime type', async () => {
const originalFilePath = `${supportDir}/valid_pdf_without_ext`;
const fileWithDifferentExtension = `${originalFilePath}.mscz`;
await copyFile(originalFilePath, fileWithDifferentExtension);
const resource = await shim.createResourceFromPath(fileWithDifferentExtension);
expect(resource.file_extension).toBe('mscz');
});
});

View File

@@ -344,7 +344,7 @@ function shimInit(options: ShimInitOptions = null) {
const detectedType = await fileTypeFromFile(filePath);
if (detectedType) {
fileExt = fileExt ? fileExt : detectedType.ext;
fileExt = detectedType.ext;
resource.mime = detectedType.mime;
} else {
resource.mime = 'application/octet-stream';

View File

@@ -4,7 +4,7 @@ module.exports = {
{
"id": "8a1556e382704160808e9a7bef7135d3",
"title": "1. Welcome to Joplin!",
"body": "# Welcome to Joplin!\n\nJoplin is a free, open source note taking and to-do application, which helps you write and organise your notes, and synchronise them between your devices. The notes are searchable, can be copied, tagged and modified either from the application directly or from your own text editor. The notes are in [Markdown format](https://joplinapp.org/help/apps/markdown). Joplin is available as a **desktop**, **mobile** and **terminal** application.\n\nThe notes in this notebook give an overview of what Joplin can do and how to use it. In general, the three applications share roughly the same functionalities; any differences will be clearly indicated.\n\n![](./AllClients.png)\n\n## Joplin is divided into three parts\n\nJoplin has three main columns:\n\n- **Sidebar** contains the list of your notebooks and tags, as well as the synchronisation status.\n\n- **Note List** contains the current list of notes - either the notes in the currently selected notebook, the notes in the currently selected tag, or search results.\n\n- **Note Editor** is the place where you write your notes. There is a **Rich Text editor** and a **Markdown editor** - click on the **Toggle editor** button in the top right hand corner to switch between both! You may also use an [external editor](https://joplinapp.org/help/apps/external_text_editor) to edit notes. For example you can use Typora as an external editor and it will display the note as well as any embedded images.\n\n## Writing notes in Markdown\n\nMarkdown is a lightweight markup language with plain text formatting syntax. Joplin supports a [Github-flavoured Markdown syntax](https://joplinapp.org/help/apps/markdown) with a few variations and additions.\n\nIn general, while Markdown is a markup language, it is meant to be human readable, even without being rendered. This is a simple example (you can see how it looks in the viewer panel):\n\n* * *\n\n# Heading\n\n## Sub-heading\n\nParagraphs are separated by a blank line. Text attributes _italic_, **bold** and `monospace` are supported. You can create bullet lists:\n\n* apples\n* oranges\n* pears\n\nOr numbered lists:\n\n1. wash\n2. rinse\n3. repeat\n\nThis is a [link](https://joplinapp.org) and, finally, below is a horizontal rule:\n\n* * *\n\nA lot more is possible including adding code samples, math formulae or checkbox lists - see the [Markdown documentation](https://joplinapp.org/help/apps/markdown) for more information.\n\n## Organising your notes\n\n### With notebooks\n\nJoplin notes are organised into a tree of notebooks and sub-notebooks.\n\n- On **desktop**, you can create a notebook by clicking on New Notebook, then you can organise them as you wish by drag and drop them into other notebooks or right-clicking and selecting \"Move to notebook\". You can also drag a sub-notebook to the \"Notebooks\" header to move it to the root. \n- On **mobile**, press the \"+\" icon and select \"New notebook\".\n- On **terminal**, press `:mn`\n\n![](./SubNotebooks.png)\n\n### With tags\n\nThe second way to organise your notes is using tags:\n\n- On **desktop**, right-click on any note in the Note List, and select \"Tags\". You can then add the tags, separating them by commas.\n- On **mobile**, open the note and press the \"⋮\" button and select \"Tags\".\n- On **terminal**, type `:help tag` for the available commands.\n",
"body": "# Welcome to Joplin!\n\nJoplin is a free, open source note taking and to-do application, which helps you write and organise your notes, and synchronise them between your devices. The notes are searchable, can be copied, tagged and modified either from the application directly or from your own text editor. The notes are in [Markdown format](https://joplinapp.org/help/apps/markdown). Joplin is available as a **desktop**, **mobile** and **terminal** application.\n\nThe notes in this notebook give an overview of what Joplin can do and how to use it. In general, the three applications share roughly the same functionalities; any differences will be clearly indicated.\n\n![](./AllClients.png)\n\n## Joplin is divided into three parts\n\nJoplin has three main columns:\n\n- **Sidebar** contains the list of your notebooks and tags, as well as the synchronisation status.\n\n- **Note List** contains the current list of notes - either the notes in the currently selected notebook, the notes in the currently selected tag, or search results.\n\n- **Note Editor** is the place where you write your notes. There is a **Rich Text editor** and a **Markdown editor** - click on the **Toggle editor** button in the top right hand corner to switch between both! You may also use an [external editor](https://joplinapp.org/help/apps/external_text_editor) to edit notes. For example you can use Typora as an external editor and it will display the note as well as any embedded images.\n\n## Writing notes in Markdown\n\nMarkdown is a lightweight markup language with plain text formatting syntax. Joplin supports a [Github-flavoured Markdown syntax](https://joplinapp.org/help/apps/markdown) with a few variations and additions.\n\nIn general, while Markdown is a markup language, it is meant to be human readable, even without being rendered. This is a simple example (you can see how it looks in the viewer panel):\n\n* * *\n\n# Heading\n\n## Sub-heading\n\nParagraphs are separated by a blank line. Text attributes _italic_, **bold** and `monospace` are supported. You can create bullet lists:\n\n* apples\n* oranges\n* pears\n\nOr numbered lists:\n\n1. wash\n2. rinse\n3. repeat\n\nThis is a [link](https://joplinapp.org) and, finally, below is a horizontal rule:\n\n* * *\n\nA lot more is possible including adding code samples, math formulae or checkbox lists - see the [Markdown documentation](https://joplinapp.org/help/apps/markdown) for more information.\n\n## Organising your notes\n\n### With notebooks\n\nJoplin notes are organised into a tree of notebooks and sub-notebooks.\n\n- On **desktop**, you can create a notebook by clicking on New Notebook, then you can organise them as you wish by drag and drop them into other notebooks or right-clicking and selecting \"Move to notebook\". You can also drag a sub-notebook to the \"Notebooks\" header to move it to the root. \n- On **mobile**, press the \"+\" icon and select \"New notebook\".\n- On **terminal**, press `:mn`\n\n![](./SubNotebooks.png)\n\n### With tags\n\nThe second way to organise your notes is using tags:\n\n- On **desktop**, right-click on any note in the Note List, and select \"Edit tags\". You can then add the tags, separating them by commas.\n- On **mobile**, open the note and press the \"⋮\" button and select \"Tags\".\n- On **terminal**, type `:help tag` for the available commands.\n",
"resources": {
"./AllClients.png": {
"id": "5c05172554194f95b60971f6d577cc1a",

View File

@@ -70,16 +70,18 @@ impl<'a> Renderer<'a> {
let path = PathBuf::from(filename);
let ext = path
.extension()
.unwrap_or_default();
.wrap_err("Embedded file has no extension")?
.to_str()
.wrap_err("Embedded file name is non utf-8")?;
let base = path
.as_os_str()
.to_str()
.wrap_err("Embedded file name is non utf-8")?
.strip_suffix(ext.to_string_lossy().as_ref())
.strip_suffix(ext)
.wrap_err("Failed to strip extension from file name")?
.trim_matches('.');
current_filename = format!("{}-{}.{}", base, i, ext.to_string_lossy());
current_filename = format!("{}-{}.{}", base, i, ext);
i += 1;
}

View File

@@ -8,7 +8,7 @@ use crate::parser::one::property::{simple, PropertyType};
use crate::parser::one::property_set::note_tag_container::Data as NoteTagData;
use crate::parser::one::property_set::PropertySetId;
use crate::parser::onestore::object::Object;
use crate::utils::utils::log;
use crate::utils::utils::log_warn;
/// An embedded file.
///
@@ -27,8 +27,8 @@ pub(crate) struct Data {
pub(crate) text_language_code: Option<u32>,
pub(crate) layout_alignment_in_parent: Option<LayoutAlignment>,
pub(crate) layout_alignment_self: Option<LayoutAlignment>,
pub(crate) embedded_file_container: Option<ExGuid>,
pub(crate) embedded_file_name: Option<String>,
pub(crate) embedded_file_container: ExGuid,
pub(crate) embedded_file_name: String,
pub(crate) source_path: Option<String>,
pub(crate) file_type: FileType,
pub(crate) picture_width: Option<f32>,
@@ -62,16 +62,15 @@ pub(crate) fn parse(object: &Object) -> Result<Data> {
LayoutAlignment::parse(PropertyType::LayoutAlignmentInParent, object)?;
let layout_alignment_self = LayoutAlignment::parse(PropertyType::LayoutAlignmentSelf, object)?;
let embedded_file_container =
ObjectReference::parse(PropertyType::EmbeddedFileContainer, object)?.or_else(|| {
log!("embeded file has no file container, using fallback value");
Some(ExGuid::fallback())
});
ObjectReference::parse(PropertyType::EmbeddedFileContainer, object)?.ok_or_else(|| {
log_warn!("embeded file has no file container");
ErrorKind::MalformedOneNoteFileData("embedded file has no file container".into())
})?;
let embedded_file_name =
simple::parse_string(PropertyType::EmbeddedFileName, object)?.or_else(|| {
log!("embeded file has no file name, using empty value");
Some(String::new())
});
let embedded_file_name = simple::parse_string(PropertyType::EmbeddedFileName, object)?
.ok_or_else(|| {
ErrorKind::MalformedOneNoteFileData("embedded file has no file name".into())
})?;
let source_path = simple::parse_string(PropertyType::SourceFilepath, object)?;
let file_type = FileType::parse(object)?;
let picture_width = simple::parse_f32(PropertyType::PictureWidth, object)?;

View File

@@ -96,49 +96,17 @@ pub(crate) fn parse_embedded_file(file_id: ExGuid, space: &ObjectSpace) -> Resul
.get_object(file_id)
.ok_or_else(|| ErrorKind::MalformedOneNoteData("embedded file is missing".into()))?;
let node = embedded_file_node::parse(node_object)?;
let fallback_value = ExGuid::fallback();
if node.embedded_file_name.is_none() {
return Err(ErrorKind::MalformedOneNoteData(
"embedded file name didn't return any value".into(),
)
.into());
}
if node.embedded_file_container.is_none() {
return Err(ErrorKind::MalformedOneNoteData(
"embedded file container didn't return any value".into(),
)
.into());
}
if node.embedded_file_container.unwrap() == fallback_value {
return Ok({
EmbeddedFile {
filename: node.embedded_file_name.unwrap(),
file_type: node.file_type,
data: Vec::<u8>::new(),
layout_max_width: node.layout_max_width,
layout_max_height: node.layout_max_height,
offset_horizontal: node.offset_from_parent_horiz,
offset_vertical: node.offset_from_parent_vert,
note_tags: parse_note_tags(node.note_tags, space)?,
}
});
}
let container_object_id = node.embedded_file_container;
let container_object = space
.get_object(container_object_id.unwrap())
.ok_or_else(|| {
ErrorKind::MalformedOneNoteData("embedded file container is missing".into())
})?;
let container_object = space.get_object(container_object_id).ok_or_else(|| {
ErrorKind::MalformedOneNoteData("embedded file container is missing".into())
})?;
let container = embedded_file_container::parse(container_object)?;
// TODO: Resolve picture container
let file = EmbeddedFile {
filename: node.embedded_file_name.unwrap(),
filename: node.embedded_file_name,
file_type: node.file_type,
data: container.into_value(),
layout_max_width: node.layout_max_width,

View File

@@ -18,9 +18,7 @@ export interface Options {
function resourceUrl(resourceFullPath: string): string {
if (
resourceFullPath.indexOf('http://') === 0 || resourceFullPath.indexOf('https://') === 0 || resourceFullPath.indexOf('joplin-content://') === 0 ||
resourceFullPath.indexOf('file://') === 0 ||
// On web, resources are loaded as blob URLs.
resourceFullPath.startsWith('blob:null/')
resourceFullPath.indexOf('file://') === 0
) {
return resourceFullPath;
}

View File

@@ -166,6 +166,3 @@ HMAC
Siri
cipherdecipher
pmmmwh
webm
millis
sideloading

View File

@@ -5,9 +5,7 @@
# Jozef Gaal <preklady@mayday.sk>, 2024,2025.
msgid ""
msgstr ""
"Project-Id-Version: Joplin v3.3.1\n"
"POT-Creation-Date: \n"
"PO-Revision-Date: \n"
"Project-Id-Version: Joplin v3.2.7\n"
"Last-Translator: Jozef Gaal <preklady@mayday.sk>\n"
"Language-Team: Jozef Gaal <preklady@mayday.sk >\n"
"Language: sk_SK\n"
@@ -15,7 +13,7 @@ msgstr ""
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"
"Plural-Forms: nplurals=3; plural=(n==1 ? 0 : n>=2 && n<=4 ? 1 : 2);\n"
"X-Generator: Poedit 3.5\n"
"X-Generator: Poedit 3.4.2\n"
#: packages/app-mobile/components/screens/ConfigScreen/ConfigScreen.tsx:593
msgid "- Camera: to allow taking a picture and attaching it to a note."
@@ -1528,8 +1526,6 @@ msgid ""
"Delete model and re-download?\n"
"This cannot be undone."
msgstr ""
"Odstrániť model a znovu stiahnuť?\n"
"Toto sa nedá vrátiť späť."
#: packages/lib/commands/deleteNote.ts:7
msgid "Delete note"
@@ -1627,8 +1623,9 @@ msgid "Detailed"
msgstr "Podrobné"
#: packages/app-mobile/components/screens/ConfigScreen/plugins/PluginBox/PluginChips.tsx:99
#, fuzzy
msgid "Dev"
msgstr "Vývoj"
msgstr "Termín"
#: packages/lib/services/interop/Module.ts:62
msgid "Directory"
@@ -1776,8 +1773,9 @@ msgid "Download and install the relevant extension for your browser:"
msgstr "Stiahnite a nainštalujte príslušné rozšírenie pre váš prehliadač:"
#: packages/app-mobile/components/voiceTyping/VoiceTypingDialog.tsx:206
#, fuzzy
msgid "Download updated model"
msgstr "Stiahnuť aktualizovaný model"
msgstr "Stiahnu"
#: packages/lib/models/Resource.ts:409
msgid "Downloaded"
@@ -4267,7 +4265,7 @@ msgstr "Ukončiť"
#: packages/app-mobile/components/voiceTyping/VoiceTypingDialog.tsx:206
msgid "Re-download model"
msgstr "Opätovne stiahnuť model"
msgstr ""
#: packages/app-desktop/gui/EncryptionConfigScreen/EncryptionConfigScreen.tsx:342
msgid "Re-encrypt data"
@@ -6122,12 +6120,11 @@ msgid "Unsupported link or message: %s"
msgstr "Nepodporovaný odkaz alebo správa: %s"
#: packages/app-mobile/commands/openItem.ts:60
#, fuzzy
msgid ""
"Unsupported link or message: %s.\n"
"Error: %s"
msgstr ""
"Nepodporovaný odkaz alebo správa: %s.\n"
"Chyba: %s"
msgstr "Nepodporovaný odkaz alebo správa: %s"
#: packages/app-desktop/gui/ResourceScreen.tsx:123
#: packages/lib/models/BaseItem.ts:921 packages/lib/path-utils.ts:27

File diff suppressed because it is too large Load Diff

View File

@@ -129,7 +129,6 @@
"v3.2.11": true,
"android-v3.2.7": true,
"ios-v13.2.5": true,
"v3.2.12": true,
"v3.3.1": true
"v3.2.12": true
}
}

View File

@@ -92,6 +92,12 @@
"imageName": "Route4Me.png",
"githubUser": "route4me"
},
{
"url": "https://buyyoutubviews.com",
"title": "BYTV",
"imageName": "BYTV.png",
"githubUser": "bytv1"
},
{
"url": "https://casinoreviews.net",
"title": "Casino Reviews",
@@ -160,12 +166,6 @@
"title": "Famegear",
"imageName": "Famegear.png",
"githubUser": "rankmediateknologi"
},
{
"url": "https://buyyoutubviews.com",
"title": "BYTV",
"imageName": "BYTV.png",
"githubUser": "bytv1"
}
]
}

View File

@@ -11,7 +11,7 @@
"jsdom": false
},
"dependencies": {
"@adobe/css-tools": "4.4.1",
"@adobe/css-tools": "4.4.0",
"html-entities": "1.4.0",
"jsdom": "24.1.1"
},

View File

@@ -1,12 +0,0 @@
import { formatMsToDurationLocal, Hour, Minute } from './time';
describe('time', () => {
test.each([
[0, '0:00'],
[Minute * 3, '3:00'],
[Hour * 4 + Minute * 3, '4:03:00'],
[Hour * 25, '0000-00-01T01:00'],
])('should support formatting durations', (input, expected) => {
expect(formatMsToDurationLocal(input)).toBe(expected);
});
});

View File

@@ -10,8 +10,6 @@ import * as dayjs from 'dayjs';
// - import * as dayJsRelativeTimeType causes a runtime error.
import type * as dayJsRelativeTimeType from 'dayjs/plugin/relativeTime';
const dayJsRelativeTime: typeof dayJsRelativeTimeType = require('dayjs/plugin/relativeTime');
import type * as dayJsDurationType from 'dayjs/plugin/duration';
const dayJsDuration: typeof dayJsDurationType = require('dayjs/plugin/duration');
const supportedLocales: Record<string, unknown> = {
'ar': require('dayjs/locale/ar'),
@@ -65,7 +63,6 @@ export const Week = 7 * Day;
export const Month = 30 * Day;
function initDayJs() {
dayjs.extend(dayJsDuration);
dayjs.extend(dayJsRelativeTime);
}
@@ -160,15 +157,3 @@ export const isValidDate = (anything: string) => {
export const formatDateTimeLocalToMs = (anything: string) => {
return dayjs(anything).unix() * 1000;
};
export const formatMsToDurationLocal = (ms: number) => {
let format;
if (ms < Hour) {
format = 'm:ss';
} else if (ms < Day) {
format = 'H:mm:ss';
} else {
format = 'YYYY-MM-DDTHH:mm';
}
return dayjs.duration(ms).format(format);
};

View File

@@ -1,59 +1,5 @@
# Joplin Desktop Changelog
## [v3.3.1](https://github.com/laurent22/joplin/releases/tag/v3.3.1) (Pre-release) - 2025-02-16T17:06:26Z
- New: Accessibility: Add a new shortcut to set focus to editor toolbar ([#11764](https://github.com/laurent22/joplin/issues/11764) by [@pedr](https://github.com/pedr))
- New: Accessibility: Add accessibility information to the warning banner ([#11775](https://github.com/laurent22/joplin/issues/11775) by [@personalizedrefrigerator](https://github.com/personalizedrefrigerator))
- New: Accessibility: Add label to the delete buttons of the Note Attachments ([#11749](https://github.com/laurent22/joplin/issues/11749) by [@pedr](https://github.com/pedr))
- New: Add specification document for new encryption methods ([#11754](https://github.com/laurent22/joplin/issues/11754) by Self Not Found)
- New: Added shortcut Cmd+Option+N to open note in new window, and added command to menu bar ([23f75f8](https://github.com/laurent22/joplin/commit/23f75f8))
- Improved: Accessibility: Add status update after update ([#11634](https://github.com/laurent22/joplin/issues/11634)) ([#11621](https://github.com/laurent22/joplin/issues/11621) by [@pedr](https://github.com/pedr))
- Improved: Accessibility: Allow toggling between tab navigation and indentation ([#11717](https://github.com/laurent22/joplin/issues/11717) by [@personalizedrefrigerator](https://github.com/personalizedrefrigerator))
- Improved: Accessibility: Improve "change application layout" screen keyboard accessibility ([#11718](https://github.com/laurent22/joplin/issues/11718) by [@personalizedrefrigerator](https://github.com/personalizedrefrigerator))
- Improved: Accessibility: Improve contrast of faded URLs in Markdown editor ([#11635](https://github.com/laurent22/joplin/issues/11635) by [@personalizedrefrigerator](https://github.com/personalizedrefrigerator))
- Improved: Accessibility: Improve scrollbar contrast ([#11708](https://github.com/laurent22/joplin/issues/11708) by [@personalizedrefrigerator](https://github.com/personalizedrefrigerator))
- Improved: Accessibility: Improve sidebar content contrast ([#11638](https://github.com/laurent22/joplin/issues/11638) by [@personalizedrefrigerator](https://github.com/personalizedrefrigerator))
- Improved: Accessibility: Improve sync wizard accessibility ([#11649](https://github.com/laurent22/joplin/issues/11649) by [@personalizedrefrigerator](https://github.com/personalizedrefrigerator))
- Improved: Accessibility: Make Markdown toolbar scrollable when low on space ([#11636](https://github.com/laurent22/joplin/issues/11636) by [@personalizedrefrigerator](https://github.com/personalizedrefrigerator))
- Improved: Accessibility: Mark secondary screen navigation bars as navigation regions ([#11650](https://github.com/laurent22/joplin/issues/11650) by [@personalizedrefrigerator](https://github.com/personalizedrefrigerator))
- Improved: Accessibility: Replacing library used for datetime with native input element ([#11725](https://github.com/laurent22/joplin/issues/11725) by [@pedr](https://github.com/pedr))
- Improved: Accessibility: Rich Text Editor: Make it possible to edit code blocks with a keyboard or touchscreen ([#11727](https://github.com/laurent22/joplin/issues/11727) by [@personalizedrefrigerator](https://github.com/personalizedrefrigerator))
- Improved: Add alt text to welcome notes ([#11643](https://github.com/laurent22/joplin/issues/11643)) ([#11642](https://github.com/laurent22/joplin/issues/11642) by [@pedr](https://github.com/pedr))
- Improved: Add proper type to search input ([#11645](https://github.com/laurent22/joplin/issues/11645)) ([#11644](https://github.com/laurent22/joplin/issues/11644) by [@pedr](https://github.com/pedr))
- Improved: Add scrollbar to Note Revision to allow usage on zoomed interface ([#11689](https://github.com/laurent22/joplin/issues/11689)) ([#11654](https://github.com/laurent22/joplin/issues/11654) by [@pedr](https://github.com/pedr))
- Improved: Built-in plugins: Update Freehand Drawing to v2.15.0 ([#11735](https://github.com/laurent22/joplin/issues/11735) by [@personalizedrefrigerator](https://github.com/personalizedrefrigerator))
- Improved: Disable featureFlag.autoUpdaterServiceEnabled for now ([7994c0b](https://github.com/laurent22/joplin/commit/7994c0b))
- Improved: Do not add double newlines around attached files ([#11690](https://github.com/laurent22/joplin/issues/11690))
- Improved: Double click to open a note in a new window ([#11664](https://github.com/laurent22/joplin/issues/11664))
- Improved: Enable OCR processing by default ([c55979c](https://github.com/laurent22/joplin/commit/c55979c))
- Improved: Harden failsafe logic to check for the presence of info.json, rather than just the item count ([#11750](https://github.com/laurent22/joplin/issues/11750) by [@mrjo118](https://github.com/mrjo118))
- Improved: Highlight `==marked==` text in the Markdown editor ([#11794](https://github.com/laurent22/joplin/issues/11794) by [@personalizedrefrigerator](https://github.com/personalizedrefrigerator))
- Improved: Improve Welcome Notes with clearer instructions ([#11656](https://github.com/laurent22/joplin/issues/11656)) ([#11647](https://github.com/laurent22/joplin/issues/11647) by [@pedr](https://github.com/pedr))
- Improved: Improve font picker accessibility ([#11763](https://github.com/laurent22/joplin/issues/11763) by [@personalizedrefrigerator](https://github.com/personalizedrefrigerator))
- Improved: Logging: Log less information at level `warn` when a decryption error occurs ([#11771](https://github.com/laurent22/joplin/issues/11771) by [@personalizedrefrigerator](https://github.com/personalizedrefrigerator))
- Improved: Move S3 sync target out of beta ([798e1b8](https://github.com/laurent22/joplin/commit/798e1b8))
- Improved: Performance: Improve performance when changing window state ([#11720](https://github.com/laurent22/joplin/issues/11720) by [@personalizedrefrigerator](https://github.com/personalizedrefrigerator))
- Improved: Plugins: Legacy editor API: Fix delayed crash caused by out-of-bounds inputs ([#11714](https://github.com/laurent22/joplin/issues/11714) by [@personalizedrefrigerator](https://github.com/personalizedrefrigerator))
- Improved: Re-enable the beta "auto-update" feature flag ([#11802](https://github.com/laurent22/joplin/issues/11802) by [@personalizedrefrigerator](https://github.com/personalizedrefrigerator))
- Improved: Render strikethrough text in the editor ([#11795](https://github.com/laurent22/joplin/issues/11795)) ([#11790](https://github.com/laurent22/joplin/issues/11790) by [@personalizedrefrigerator](https://github.com/personalizedrefrigerator))
- Improved: Reorganised note list context menu ([#11664](https://github.com/laurent22/joplin/issues/11664))
- Improved: Updated packages @adobe/css-tools (v4.4.1), @axe-core/playwright (v4.10.1)
- Improved: Upgrade to Electron 34 ([#11665](https://github.com/laurent22/joplin/issues/11665) by [@personalizedrefrigerator](https://github.com/personalizedrefrigerator))
- Improved: Upgrade to TinyMCE v6 ([#11652](https://github.com/laurent22/joplin/issues/11652) by [@personalizedrefrigerator](https://github.com/personalizedrefrigerator))
- Fixed: Accessibility: Fix incorrect note viewer accessibility label ([#11744](https://github.com/laurent22/joplin/issues/11744) by [@personalizedrefrigerator](https://github.com/personalizedrefrigerator))
- Fixed: Accessibility: Fix input fields not associated with labels ([#11700](https://github.com/laurent22/joplin/issues/11700) by [@pedr](https://github.com/pedr))
- Fixed: Accessibility: Fix unlabelled toolbar button in the Rich Text Editor ([#11655](https://github.com/laurent22/joplin/issues/11655) by [@personalizedrefrigerator](https://github.com/personalizedrefrigerator))
- Fixed: Accessibility: Fixes focus going to start of document when Note History is open ([#11769](https://github.com/laurent22/joplin/issues/11769) by [@pedr](https://github.com/pedr))
- Fixed: Adjust how items are queried by ID ([#11734](https://github.com/laurent22/joplin/issues/11734)) ([#11630](https://github.com/laurent22/joplin/issues/11630) by [@personalizedrefrigerator](https://github.com/personalizedrefrigerator))
- Fixed: Context menu was empty for notes on Trash folder ([#11743](https://github.com/laurent22/joplin/issues/11743)) ([#11738](https://github.com/laurent22/joplin/issues/11738) by [@pedr](https://github.com/pedr))
- Fixed: Fix crash when closing a secondary window with the Rich Text Editor open ([#11737](https://github.com/laurent22/joplin/issues/11737) by [@personalizedrefrigerator](https://github.com/personalizedrefrigerator))
- Fixed: Fix datetime values not appearing on note properties when the picker is open ([#11748](https://github.com/laurent22/joplin/issues/11748) by [@pedr](https://github.com/pedr))
- Fixed: Fix secondary windows not removed from state if closed while focused ([#11740](https://github.com/laurent22/joplin/issues/11740) by [@personalizedrefrigerator](https://github.com/personalizedrefrigerator))
- Fixed: Plugins: Fix toast notifications don't reappear unless parameters are changed ([#11786](https://github.com/laurent22/joplin/issues/11786)) ([#11783](https://github.com/laurent22/joplin/issues/11783) by [@personalizedrefrigerator](https://github.com/personalizedrefrigerator))
- Fixed: Pressing Shift+Tab when focus is on notebook list would jump straight to editor ([#11641](https://github.com/laurent22/joplin/issues/11641)) ([#11640](https://github.com/laurent22/joplin/issues/11640) by [@pedr](https://github.com/pedr))
- Fixed: Prevent the default note title from being "&nbsp;" ([#11785](https://github.com/laurent22/joplin/issues/11785)) ([#11662](https://github.com/laurent22/joplin/issues/11662) by [@personalizedrefrigerator](https://github.com/personalizedrefrigerator))
- Fixed: Sync: Fix share not marked as readonly if the recipient has an outgoing share ([#11770](https://github.com/laurent22/joplin/issues/11770) by [@personalizedrefrigerator](https://github.com/personalizedrefrigerator))
## [v3.2.12](https://github.com/laurent22/joplin/releases/tag/v3.2.12) - 2025-01-23T23:52:04Z
- Improved: Allow internal links to target elements using the name attribute ([#11671](https://github.com/laurent22/joplin/issues/11671) by [@personalizedrefrigerator](https://github.com/personalizedrefrigerator))

View File

@@ -18,46 +18,29 @@ Variables follow the naming convention `--joplin-{property}` and are used in you
## Icons
On desktop, your plugin view will have access to icons used by the app. It is however not recommended to use them because they may change in future versions. And it will also make your plugin incompatible with the mobile app (which does not expose any icon library).
In addition to variables, you have access to a set of standard font assets that ship with Joplin. These include:
Instead a recommended approach is to add Font Awesome in your plugin project, and to import only the icons you'll need. To do so using React, follow these instructions:
* [Roboto](https://fonts.google.com/specimen/Roboto?preview.text_type=custom) - (the standard UI font, `font-family` referenced above)
* [Font Awesome](https://fontawesome.com/icons?d=gallery&p=2&m=free) - icon library
* [icoMoon](https://icomoon.io/#preview-free) - icon library (subset, see [style.css](https://github.com/laurent22/joplin/blob/dev/packages/app-desktop/style/icons/style.css))
**Install Font Awesome:**
To display an icon, use CSS and HTML like the following.
```shell
npm install --save @fortawesome/fontawesome-svg-core @fortawesome/free-solid-svg-icons @fortawesome/free-regular-svg-icons @fortawesome/react-fontawesome
```
**Import and load the icons:**
From one of your top TypeScript files:
```typescript
import { library } from '@fortawesome/fontawesome-svg-core';
// Import the specific icons you want to use
import { faTimes } from '@fortawesome/free-solid-svg-icons';
import { faCheckCircle } from '@fortawesome/free-regular-svg-icons';
// Add the icons to the library
library.add(faTimes, faCheckCircle);
```
**Use Font Awesome React Components:**
```JSX
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
const App = () => {
return (
<div>
<FontAwesomeIcon icon="times" />
<FontAwesomeIcon icon={['far', 'check-circle']} />
</div>
);
```css
/* style icons to match the theme */
.toolbarIcon {
font-size: var(--joplin-toolbar-icon-size);
}
.primary {
color: var(--joplin-color);
}
.secondary {
color: var(--joplin-color2);
}
export default App;
```
If you are not using React, just ask ChatGPT on how to do the above using you preferred JS framework.
```html
<i class="toolbarIcon primary fas fa-music"></i> Font Awesome music icon
<br />
<i class="toolbarIcon secondary icon-notebooks"></i> icoMoon notebook icon
```

View File

@@ -4,29 +4,19 @@ Optical Character Recognition (OCR) involves transforming an image containing te
## Enabling OCR
OCR should be enabled by default. If it is not you can enable it from the [Configuration screen](https://github.com/laurent22/joplin/blob/dev/readme/apps/config_screen.md), under the "General" section. Once you do so, Joplin is going to scan your images (PNG and JPEG) and PDF files to extract text data from it.
You can enable OCR from the [Configuration screen](https://github.com/laurent22/joplin/blob/dev/readme/apps/config_screen.md), under the "General" section. Once you do so, Joplin is going to scan your images and PDF files to extract text data from it. That data will not be visible but will be associated with those files.
Scanning documents is only available on the desktop app since this is a relatively resource-intensive process. The mobile app will have access to that OCR data via sync.
For now OCR is reliable when scanning printed text, PDFs in particular, or images where the text is clear such as screenshots. We do not currently support handwritten text, and text on photos may or may not be recognized depending on how clear it is.
## Searching
When you search, the application will be able to tell you what notes but also what attachments match the query. In this case, a banner will be displayed at the top of the note that contains the attachment(s):
Then, when you search, the application will be able to tell you what notes but also what attachments match the query. In this case, a banner will be displayed at the top of the note that contains the attachment(s):
![](https://raw.githubusercontent.com/laurent22/joplin/dev/Assets/WebsiteAssets/images/ocr/search_results.png)
Searching in OCR text is enabled on the desktop and mobile app.
Searching in OCR text is enabled on the desktop and mobile app. Scanning documents however is only available on the desktop app since this is a relatively resource-intensive process. The mobile app will have access to that OCR data via sync.
## Viewing OCR text
The application allows you to view the OCR text associated with an image. To do so, right-click on a PDF link or image and select "View OCR text". This will create a new text file with that OCR text, and open it in your text editor.
![](https://raw.githubusercontent.com/laurent22/joplin/dev/Assets/WebsiteAssets/images/ocr/view_ocr_text.png)
For now OCR is reliable when scanning printed text, PDFs in particular, or images where the text is clear such as screenshots. We do not currently support handwritten text, and text on photos may or may not be recognized depending on how clear it is.
## Initial processing
Processing images and PDF may be resource intensive, especially if you have a lot of attachments. So the first time the feature is enabled don't be surprised if Joplin CPU usage is higher than usual. Once the initial scan of all your attachments is done, this will go back to normal. Later, whenever you attach a file it will be scanned quickly in a way that's not noticeable.
As mentioned above processing images and PDF may be resource intensive, especially if you have a lot of attachments. So the first time you enable the feature don't be surprised if Joplin CPU usage is higher than usual. Once the initial scan of all your attachments is done, this will go back to normal. Late,r whenever you attach a file it will be scanned quickly in a way that's not noticeable.
## Offline first

View File

@@ -104,8 +104,6 @@ To make changes to the application, you'll need to rebuild any TypeScript file y
Running `yarn tsc` would have the same effect, but without watching.
**Mobile-specific note**: If making changes to the note editor, viewer, or other WebView content, run `yarn watchInjectedJs` from `packages/app-mobile` to rebuild the WebView JavaScript files on change.
## Running an application with additional parameters
You can specify additional parameters when running the desktop or CLI application. To do so, add `--` to the `yarn start` command, followed by your flags. For example:

View File

@@ -1,39 +0,0 @@
# Editor Commands
## On Mobile
On mobile, there are two types of editor-related commands:
- Editor commands
- Note screen commands
These commands can be used in different cases and run in different contexts.
### Note screen commands
These commands are stored in `packages/app-mobile/components/screens/Note/commands`. Note screen commands run in the main React Native JavaScript context.
After adding a new note screen command, run `yarn buildScriptIndexes` from the top-level Joplin directory.
### CodeMirror commands
These commands are stored in `packages/editor/CodeMirror/editorCommands/editorCommands.ts` and are shared between mobile and desktop. On mobile, these commands run in the editor `WebView`, giving them access to the CodeMirror API.
Although all of these commands can be accessed by plugins using `editor.execCommand`, some are registered directly with the `CommandService`. Registering an editor command with the `CommandService` can be useful in a few circumstances. In particular, it:
- Allows the command to be run directly using `CommandService.instance().execute('commandName')`. This makes it simpler for plugins to access the command.
- Allows adding a toolbar button for the command.
New editor commands registered with `CommandService` should:
- Be added to `NoteEditor/commandDeclarations.ts` in the `app-mobile` package.
- Be given the `editor.` prefix to mark them as editor commands. This changes how the commands are applied, for example, by focusing the editor after the command completes.
While testing a new editor command, be sure to watch the bundled editor JavaScript for changes. See `BUILD.md` for details.
## On Desktop
On desktop, the different note editor types each register their own command handlers. For example, the `useEditorCommands.ts` hook in `NoteBody/CodeMirror/v6/useEditorCommands.ts` is responsible for registering the different editor commands.
In most cases, to share code with the mobile app, new commands here should call:
```js
// For some SomeCommand implemented in packages/editor
editorRef.current.execCommand(EditorCommandType.SomeCommand, ...argsHere)
```

View File

@@ -159,4 +159,4 @@ Notebooks and notes on iOS are not backed up when [backing up to your Mac](https
## Why is it named Joplin?
The application is named in honour of composer and pianist [Scott Joplin](https://en.wikipedia.org/wiki/Scott_Joplin), whose music I frequently listen to. His name is also easy to remember and type, making it a fitting choice.
The name comes from the composer and pianist [Scott Joplin](https://en.wikipedia.org/wiki/Scott_Joplin), which I often listen to. His name is also easy to remember and type so it felt like a good choice.

View File

@@ -62,6 +62,6 @@ Joplin notes are organised into a tree of notebooks and sub-notebooks.
The second way to organise your notes is using tags:
- On **desktop**, right-click on any note in the Note List, and select "Tags". You can then add the tags, separating them by commas.
- On **desktop**, right-click on any note in the Note List, and select "Edit tags". You can then add the tags, separating them by commas.
- On **mobile**, open the note and press the "⋮" button and select "Tags".
- On **terminal**, type `:help tag` for the available commands.

View File

@@ -76,7 +76,6 @@
"browserify",
"codemirror",
"cspell",
"expo-av", // Must be updated with expo
"file-loader",
"gradle",
"html-webpack-plugin",

View File

@@ -31,10 +31,10 @@ __metadata:
languageName: node
linkType: hard
"@adobe/css-tools@npm:4.4.1":
version: 4.4.1
resolution: "@adobe/css-tools@npm:4.4.1"
checksum: bbded8a03c314afee0fb0b42922f664f437e0e2f0b86eeeb06dee9d02cd8fc958cf87aa3314952b00074e0b22fc5b8da23f45b61b6f8291c8aaa7cffc56a76e9
"@adobe/css-tools@npm:4.4.0":
version: 4.4.0
resolution: "@adobe/css-tools@npm:4.4.0"
checksum: 1f08fb49bf17fc7f2d1a86d3e739f29ca80063d28168307f1b0a962ef37501c5667271f6771966578897f2e94e43c4770fd802728a6e6495b812da54112d506a
languageName: node
linkType: hard
@@ -7468,6 +7468,13 @@ __metadata:
languageName: node
linkType: hard
"@fortawesome/fontawesome-free@npm:^6.7.2":
version: 6.7.2
resolution: "@fortawesome/fontawesome-free@npm:6.7.2"
checksum: 2ceb384ada8e4a1e8a8e24384a35e3afa01589ddec67c8c52e3ad5d7db1662d0fc92560bd9a23baa4e0676e721e423aef99fb79fe6899bf13900fd1e611b6760
languageName: node
linkType: hard
"@fortawesome/fontawesome-svg-core@npm:6.1.2":
version: 6.1.2
resolution: "@fortawesome/fontawesome-svg-core@npm:6.1.2"
@@ -8355,6 +8362,7 @@ __metadata:
"@babel/preset-env": 7.24.7
"@babel/runtime": 7.24.7
"@bam.tech/react-native-image-resizer": 3.0.10
"@fortawesome/fontawesome-free": ^6.7.2
"@joplin/editor": ~3.3
"@joplin/lib": ~3.3
"@joplin/react-native-alarm-notification": ~3.3
@@ -8396,7 +8404,6 @@ __metadata:
deprecated-react-native-prop-types: 5.0.0
events: 3.3.0
expo: 51.0.26
expo-av: 14.0.7
expo-camera: 15.0.16
fast-deep-equal: 3.1.3
fs-extra: 11.2.0
@@ -8579,7 +8586,7 @@ __metadata:
version: 0.0.0-use.local
resolution: "@joplin/htmlpack@workspace:packages/htmlpack"
dependencies:
"@adobe/css-tools": 4.4.1
"@adobe/css-tools": 4.4.0
"@joplin/fork-htmlparser2": ^4.1.57
"@types/fs-extra": 11.0.4
datauri: 4.1.0
@@ -8592,7 +8599,7 @@ __metadata:
version: 0.0.0-use.local
resolution: "@joplin/lib@workspace:packages/lib"
dependencies:
"@adobe/css-tools": 4.4.1
"@adobe/css-tools": 4.4.0
"@aws-sdk/client-s3": 3.296.0
"@aws-sdk/s3-request-presigner": 3.296.0
"@joplin/fork-htmlparser2": ^4.1.57
@@ -8945,7 +8952,7 @@ __metadata:
version: 0.0.0-use.local
resolution: "@joplin/turndown@workspace:packages/turndown"
dependencies:
"@adobe/css-tools": 4.4.1
"@adobe/css-tools": 4.4.0
"@rollup/plugin-commonjs": 25.0.8
"@rollup/plugin-node-resolve": 15.2.4
"@rollup/plugin-replace": 5.0.7
@@ -24702,15 +24709,6 @@ __metadata:
languageName: node
linkType: hard
"expo-av@npm:14.0.7":
version: 14.0.7
resolution: "expo-av@npm:14.0.7"
peerDependencies:
expo: "*"
checksum: 7518a8972b0d1b3b362d4dc9c4d21a39c7cf98b46abbdbb2349451115f4edf6e97fb6c723674fd3c62fa037fc77e00172955082d21b1c3fdf5273f33781ce78d
languageName: node
linkType: hard
"expo-camera@npm:15.0.16":
version: 15.0.16
resolution: "expo-camera@npm:15.0.16"