1
0
mirror of https://github.com/laurent22/joplin.git synced 2026-04-21 19:45:16 +02:00

Compare commits

..

5 Commits

17 changed files with 261 additions and 28 deletions
+2
View File
@@ -1123,9 +1123,11 @@ packages/editor/CodeMirror/utils/formatting/computeSelectionFormatting.js
packages/editor/CodeMirror/utils/formatting/findInlineMatch.test.js
packages/editor/CodeMirror/utils/formatting/findInlineMatch.js
packages/editor/CodeMirror/utils/formatting/isIndentationEquivalent.js
packages/editor/CodeMirror/utils/formatting/markdownFormatPatterns.js
packages/editor/CodeMirror/utils/formatting/tabsToSpaces.test.js
packages/editor/CodeMirror/utils/formatting/tabsToSpaces.js
packages/editor/CodeMirror/utils/formatting/toggleInlineFormatGlobally.js
packages/editor/CodeMirror/utils/formatting/toggleInlineMultilineSelectionFormat.js
packages/editor/CodeMirror/utils/formatting/toggleInlineRegionSurrounded.js
packages/editor/CodeMirror/utils/formatting/toggleInlineSelectionFormat.js
packages/editor/CodeMirror/utils/formatting/toggleRegionFormatGlobally.test.js
+2
View File
@@ -1096,9 +1096,11 @@ packages/editor/CodeMirror/utils/formatting/computeSelectionFormatting.js
packages/editor/CodeMirror/utils/formatting/findInlineMatch.test.js
packages/editor/CodeMirror/utils/formatting/findInlineMatch.js
packages/editor/CodeMirror/utils/formatting/isIndentationEquivalent.js
packages/editor/CodeMirror/utils/formatting/markdownFormatPatterns.js
packages/editor/CodeMirror/utils/formatting/tabsToSpaces.test.js
packages/editor/CodeMirror/utils/formatting/tabsToSpaces.js
packages/editor/CodeMirror/utils/formatting/toggleInlineFormatGlobally.js
packages/editor/CodeMirror/utils/formatting/toggleInlineMultilineSelectionFormat.js
packages/editor/CodeMirror/utils/formatting/toggleInlineRegionSurrounded.js
packages/editor/CodeMirror/utils/formatting/toggleInlineSelectionFormat.js
packages/editor/CodeMirror/utils/formatting/toggleRegionFormatGlobally.test.js
@@ -43,6 +43,12 @@ export interface FolderPickerOptions {
mustSelect?: boolean;
}
export enum ViewToggleButtonMode {
Hidden = 'hidden',
ShowViewer = 'show-viewer',
ShowEditor = 'show-editor',
}
interface ScreenHeaderProps {
selectedNoteIds: string[];
selectedFolderId: string;
@@ -70,9 +76,8 @@ interface ScreenHeaderProps {
showContextMenuButton?: boolean;
showPluginEditorButton?: boolean;
showBackButton?: boolean;
showViewToggleButton?: boolean;
viewToggleButtonMode?: ViewToggleButtonMode;
onViewTogglePress?: OnPressCallback;
viewToggleIconName?: string;
saveButtonDisabled?: boolean;
showSaveButton?: boolean;
@@ -376,10 +381,12 @@ class ScreenHeaderComponent extends PureComponent<ScreenHeaderProps, ScreenHeade
};
const renderViewToggleButton = () => {
if (!this.props.showViewToggleButton || !this.props.onViewTogglePress || !this.props.viewToggleIconName) return null;
const mode = this.props.viewToggleButtonMode ?? ViewToggleButtonMode.Hidden;
if (mode === ViewToggleButtonMode.Hidden || !this.props.onViewTogglePress) return null;
return renderTopButton({
iconName: this.props.viewToggleIconName,
description: _('Toggle view/edit'),
iconName: mode === ViewToggleButtonMode.ShowViewer ? 'ionicon book' : 'ionicon pencil',
description: mode === ViewToggleButtonMode.ShowViewer ? _('Stop editing') : _('Edit'),
onPress: this.props.onViewTogglePress,
visible: true,
});
@@ -134,7 +134,7 @@ const expectToBeEditing = async (editing: boolean) => {
};
const openEditor = async () => {
const editToggle = await screen.findByLabelText('Toggle view/edit');
const editToggle = await screen.findByLabelText('Edit');
fireEvent.press(editToggle);
await expectToBeEditing(true);
@@ -383,10 +383,10 @@ describe('screens/Note', () => {
unmount();
});
it('should show toggle button', async () => {
it('should show edit button', async () => {
const { unmount } = await setupNoteWithPanes(['viewer']);
const toggleButton = await screen.findByLabelText('Toggle view/edit');
expect(toggleButton).toBeVisible();
const editButton = await screen.findByLabelText('Edit');
expect(editButton).toBeVisible();
unmount();
});
@@ -398,7 +398,7 @@ describe('screens/Note', () => {
const expectedEditing = !initialEditing;
const { unmount } = await setupNoteWithPanes(panes);
await expectToBeEditing(initialEditing);
const toggleButton = await screen.findByLabelText('Toggle view/edit');
const toggleButton = await screen.findByLabelText(/Edit|Stop editing/);
fireEvent.press(toggleButton);
await expectToBeEditing(expectedEditing);
unmount();
@@ -21,7 +21,7 @@ import NavService, { OnNavigateCallback as OnNavigateCallback } from '@joplin/li
import { ModelType } from '@joplin/lib/BaseModel';
import { fileExtension, safeFileExtension } from '@joplin/lib/path-utils';
import * as mimeUtils from '@joplin/lib/mime-utils';
import ScreenHeader, { MenuOptionType } from '../../ScreenHeader';
import ScreenHeader, { MenuOptionType, ViewToggleButtonMode } from '../../ScreenHeader';
import NoteTagsDialog from '../NoteTagsDialog';
import time from '@joplin/lib/time';
import Checkbox from '../../Checkbox';
@@ -1800,6 +1800,12 @@ class NoteScreenComponent extends BaseScreenComponent<ComponentProps, State> imp
this.setState({ titleContainerWidth: width });
}
}}
// Making this focusable works around a tab ordering bug on Android
// See https://github.com/laurent22/joplin/issues/14548
accessible={Platform.OS === 'android'}
// Since the group is focusable, it also needs a label (otherwise TalkBack reads "unlabelled"):
aria-label={_('Title')}
>
<TextWrapCalculator
textCompStyle={this.styles().titleTextInput}
@@ -1855,6 +1861,11 @@ class NoteScreenComponent extends BaseScreenComponent<ComponentProps, State> imp
const { editorPlugin: activeEditorPlugin } = getActivePluginEditorView(this.props.plugins, this.props.windowId);
let viewEditToggleMode = this.state.mode === 'edit' ? ViewToggleButtonMode.ShowViewer : ViewToggleButtonMode.ShowEditor;
if (!this.state.note || this.state.note.deleted_time > 0 || editorView) {
viewEditToggleMode = ViewToggleButtonMode.Hidden;
}
const header = <ScreenHeader
folderPickerOptions={this.folderPickerOptions()}
menuOptions={this.menuOptions()}
@@ -1869,8 +1880,7 @@ class NoteScreenComponent extends BaseScreenComponent<ComponentProps, State> imp
undoButtonDisabled={!this.state.undoRedoButtonState.canUndo && this.state.undoRedoButtonState.canRedo}
onUndoButtonPress={this.screenHeader_undoButtonPress}
onRedoButtonPress={this.screenHeader_redoButtonPress}
showViewToggleButton={!!this.state.note && !this.state.note.deleted_time && !editorView}
viewToggleIconName={this.state.mode === 'edit' ? 'ionicon book' : 'ionicon pencil'}
viewToggleButtonMode={viewEditToggleMode}
onViewTogglePress={this.toggleVisiblePanes}
title={getDisplayParentTitle(this.state.note, this.state.folder)}
/>;
@@ -1,6 +1,8 @@
import { CommandContext, CommandDeclaration, CommandRuntime } from '@joplin/lib/services/CommandService';
import { CommandRuntimeProps } from '../types';
import Setting from '@joplin/lib/models/Setting';
import { AccessibilityInfo } from 'react-native';
import { _ } from '@joplin/lib/locale';
export const declaration: CommandDeclaration = {
// For compatibility with the desktop app, this command is called "toggleVisiblePanes".
@@ -19,6 +21,8 @@ export const runtime = (props: CommandRuntimeProps): CommandRuntime => {
const currentMode = props.getMode();
const newMode = currentMode === 'edit' ? 'view' : 'edit';
props.setMode(newMode);
AccessibilityInfo.announceForAccessibility(newMode === 'view' ? _('Viewing') : _('Editing'));
},
};
};
@@ -117,10 +117,6 @@ const useStyles = (themeId: number) => {
sidebarIcon: sidebarIconStyle,
folderButton: folderButtonStyle,
folderButtonText: folderButtonTextStyle,
conflictFolderButtonText: {
...folderButtonTextStyle,
color: theme.colorError,
},
folderButtonSelected: {
...folderButtonStyle,
backgroundColor: theme.selectedColor,
@@ -197,6 +193,12 @@ const FolderItem: React.FC<FolderItemProps> = props => {
paddingRight: 10,
backgroundColor: props.selected ? theme.selectedColor : undefined,
},
conflictFolderButtonText: {
color: theme.colorError,
},
conflictFolderButtonSelectedText: {
color: theme.colorErrorSelected,
},
});
}, [props.selected, props.depth, props.themeId]);
const baseStyles = props.styles;
@@ -268,7 +270,18 @@ const FolderItem: React.FC<FolderItemProps> = props => {
// depth is specified with an accessibilityLabel:
const folderDepthDescription = props.depth > 0 ? _('(level %d)', props.depth) : '';
const accessibilityLabel = `${folderTitle} ${folderDepthDescription}`.trim();
const folderButtonTextStyle = props.folder.id === Folder.conflictFolderId() ? baseStyles.conflictFolderButtonText : baseStyles.folderButtonText;
const isConflictFolder = props.folder.id === Folder.conflictFolderId();
const textStyle = useMemo(() => {
const result: TextStyle[] = [baseStyles.folderButtonText];
if (isConflictFolder) {
result.push(styles.conflictFolderButtonText);
if (props.selected) {
result.push(styles.conflictFolderButtonSelectedText);
}
}
return result;
}, [styles, props.selected, isConflictFolder, baseStyles.folderButtonText]);
return (
<View key={props.folder.id} style={styles.buttonWrapper}>
<TouchableRipple
@@ -284,7 +297,7 @@ const FolderItem: React.FC<FolderItemProps> = props => {
{renderFolderIcon(props.folder.id, folderIcon)}
<Text
numberOfLines={1}
style={folderButtonTextStyle}
style={textStyle}
accessibilityLabel={accessibilityLabel}
>
{folderTitle}
@@ -38,6 +38,49 @@ describe('markdownCommands', () => {
expect(editor.state.doc.toString()).toBe('Testing...');
});
it.each([
{
name: 'bolding bullet lists line by line',
initialDocText: '- one\n- two',
syntaxNodes: ['BulletList'],
toggleCommand: toggleBolded,
expectedAfterFirstToggle: '- **one**\n- **two**',
},
{
name: 'bolding bullet lists (alternate format, with indentation) line by line',
initialDocText: '+ one\n\t+ two',
syntaxNodes: ['BulletList'],
toggleCommand: toggleBolded,
expectedAfterFirstToggle: '+ **one**\n\t+ **two**',
},
{
name: 'italicizing ordered lists line by line',
initialDocText: '1. one\n2. two',
syntaxNodes: ['OrderedList'],
toggleCommand: toggleItalicized,
expectedAfterFirstToggle: '1. *one*\n2. *two*',
},
{
name: 'bolding checklist content while preserving markers',
initialDocText: '- [ ] one\n- [x] two',
syntaxNodes: ['BulletList'],
toggleCommand: toggleBolded,
expectedAfterFirstToggle: '- [ ] **one**\n- [x] **two**',
},
])('should support $name', async ({ initialDocText, syntaxNodes, toggleCommand, expectedAfterFirstToggle }) => {
const editor = await createTestEditor(
initialDocText,
EditorSelection.range(0, initialDocText.length),
syntaxNodes,
);
toggleCommand(editor);
expect(editor.state.doc.toString()).toBe(expectedAfterFirstToggle);
toggleCommand(editor);
expect(editor.state.doc.toString()).toBe(initialDocText);
});
it.each([
['trailing', 'ABC ', '**ABC** '],
['leading', ' ABC', ' **ABC**'],
@@ -56,6 +99,42 @@ describe('markdownCommands', () => {
});
});
it('should wrap fenced code block multiline selections as a whole region', async () => {
const initialDocText = '```\none\ntwo\n```';
const editor = await createTestEditor(
initialDocText,
EditorSelection.range(0, initialDocText.length),
['FencedCode'],
);
toggleBolded(editor);
expect(editor.state.doc.toString()).toBe('**```\none\ntwo\n```**');
});
it('should apply bold to blockquote list content without wrapping markers', async () => {
const initialDocText = '> - one\n> - two';
const editor = await createTestEditor(
initialDocText,
EditorSelection.range(0, initialDocText.length),
['Blockquote', 'BulletList'],
);
toggleBolded(editor);
expect(editor.state.doc.toString()).toBe('> - **one**\n> - **two**');
});
it('should preserve blank lines when bolding multiline list selections', async () => {
const initialDocText = '- one\n\n- two';
const editor = await createTestEditor(
initialDocText,
EditorSelection.range(0, initialDocText.length),
['BulletList'],
);
toggleBolded(editor);
expect(editor.state.doc.toString()).toBe('- **one**\n\n- **two**');
});
it('for a cursor, bolding, then italicizing, should produce a bold-italic region', async () => {
const initialDocText = '';
const editor = await createTestEditor(
@@ -0,0 +1,36 @@
// Shared regex patterns and constants for Markdown formatting utilities.
// Provides centralized definitions of blockquote, list, and other formatting patterns
// to prevent duplication and ensure consistency across formatting modules.
//
// DESIGN ASSUMPTIONS:
// - Blockquote lines follow format "> content" (no leading whitespace before the marker)
// - blockquoteDetectRegex is used to check if a line starts with blockquote marker
// - blockquotePrefixRegex is used to extract complete blockquote prefixes including nesting
// Blockquote extraction pattern: matches zero or more leading blockquote markers (> )
// Supports nested blockquotes and captures leading whitespace.
// PRIMARY USE: toggleInlineMultilineSelectionFormat - extract full prefix to preserve markers
// Example: " > > text" extracts " > > "
export const blockquotePrefixRegex = /^(\s*(?:>\s*)+)/;
// Blockquote detection pattern: checks if a line starts with a blockquote marker
// Detects: starts with "> " (blockquote marker + space)
// PRIMARY USE: toggleRegionFormatGlobally - check if line is in blockquote
// LIMITATION: Does NOT support leading whitespace before ">" marker
// Example: "> text" matches, " > text" does NOT match
export const blockquoteDetectRegex = /^>\s/;
// Length of a single blockquote marker with space ("> ").
// Used for offset calculations when removing blockquote markers from formatted line content.
// ASSUMPTION: Simple "> " structure without leading whitespace
export const singleBlockquoteMarkerLength = '> '.length;
// List prefix pattern: matches list markers at the start of a line
// Supports:
// - Bullet lists: "- " or "* "
// - Checklist items: "- [ ]", "- [x]", "- [X]"
// - Ordered lists: "1. ", "2. ", etc.
// Captures the entire prefix including leading whitespace.
// PRIMARY USE: toggleInlineMultilineSelectionFormat - extract list prefix to preserve markers
// Example: " - [ ] item" extracts " - [ ] "
export const listPrefixRegex = /^(\s*(?:[-*+]\s\[[ xX]\]\s|[-*+]\s|\d+[.)]\s))/;
@@ -0,0 +1,69 @@
import { Text, EditorSelection, EditorState, SelectionRange, ChangeSet } from '@codemirror/state';
import { RegionSpec } from './RegionSpec';
import { SelectionUpdate } from './types';
import toggleInlineRegionSurrounded from './toggleInlineRegionSurrounded';
import intersectsSyntaxNode from '../isInSyntaxNode';
import { blockquotePrefixRegex, listPrefixRegex } from './markdownFormatPatterns';
const toggleWholeTextRegion = (content: string, spec: RegionSpec) => {
if (!content.trim()) return content;
let doc = Text.of(content.split('\n'));
const update = toggleInlineRegionSurrounded(doc, EditorSelection.range(0, content.length), spec);
if (update.changes) {
const change = ChangeSet.of(update.changes, doc.length);
doc = change.apply(doc);
}
return doc.toString();
};
const toggleListLineContent = (lineText: string, spec: RegionSpec) => {
const blockquotePrefix = lineText.match(blockquotePrefixRegex)?.[1] ?? '';
const remainingText = lineText.slice(blockquotePrefix.length);
const listPrefix = remainingText.match(listPrefixRegex)?.[1];
if (!listPrefix) return toggleWholeTextRegion(lineText, spec);
const content = remainingText.slice(listPrefix.length);
if (!content.trim()) return lineText;
return blockquotePrefix + listPrefix + toggleWholeTextRegion(content, spec);
};
export const shouldUseMultilineInlineSelectionFormatting = (
state: EditorState,
sel: SelectionRange,
spec: RegionSpec,
) => {
if (sel.empty) return false;
if (spec.nodeName !== 'StrongEmphasis' && spec.nodeName !== 'Emphasis') return false;
if (intersectsSyntaxNode(state, sel, 'FencedCode') || intersectsSyntaxNode(state, sel, 'CodeBlock')) return false;
const doc = state.doc;
const startLine = doc.lineAt(sel.from);
const endLine = doc.lineAt(sel.to);
if (startLine.number === endLine.number) return false;
// Keep behavior predictable by applying this strategy only to full-line ranges.
return sel.from === startLine.from && sel.to === endLine.to;
};
const toggleInlineMultilineSelectionFormat = (
state: EditorState,
sel: SelectionRange,
spec: RegionSpec,
): SelectionUpdate => {
const doc = state.doc;
const selectedText = doc.sliceString(sel.from, sel.to);
const transformedText = selectedText
.split('\n')
.map(line => toggleListLineContent(line, spec))
.join('\n');
return {
changes: [{ from: sel.from, to: sel.to, insert: transformedText }],
range: EditorSelection.range(sel.from, sel.from + transformedText.length),
};
};
export default toggleInlineMultilineSelectionFormat;
@@ -4,12 +4,16 @@ import { SelectionUpdate } from './types';
import findInlineMatch, { MatchSide } from './findInlineMatch';
import growSelectionToNode from '../growSelectionToNode';
import toggleInlineRegionSurrounded from './toggleInlineRegionSurrounded';
import toggleInlineMultilineSelectionFormat, { shouldUseMultilineInlineSelectionFormatting } from './toggleInlineMultilineSelectionFormat';
// Returns updated selections: For all selections in the given `EditorState`, toggles
// whether each is contained in an inline region of type [spec].
const toggleInlineSelectionFormat = (
state: EditorState, spec: RegionSpec, sel: SelectionRange,
): SelectionUpdate => {
if (shouldUseMultilineInlineSelectionFormatting(state, sel, spec)) {
return toggleInlineMultilineSelectionFormat(state, sel, spec);
}
const endMatchLen = findInlineMatch(state.doc, spec, sel, MatchSide.End);
// If at the end of the region, move the
@@ -3,9 +3,7 @@ import { RegionSpec } from './RegionSpec';
import findInlineMatch, { MatchSide } from './findInlineMatch';
import growSelectionToNode from '../growSelectionToNode';
import toggleInlineSelectionFormat from './toggleInlineSelectionFormat';
const blockQuoteStartLen = '> '.length;
const blockQuoteRegex = /^>\s/;
import { blockquoteDetectRegex, singleBlockquoteMarkerLength } from './markdownFormatPatterns';
// Toggle formatting for all selections. For example,
// toggling a code RegionSpec repeatedly should create:
@@ -35,7 +33,7 @@ const toggleRegionFormatGlobally = (
// Don't treat '> ' as part of the line's content if we're in a blockquote.
let contentLength = line.text.length;
if (inBlockQuote && preserveBlockQuotes) {
contentLength -= blockQuoteStartLen;
contentLength -= singleBlockquoteMarkerLength;
}
// If it matches the entire line, remove the newline character.
@@ -46,7 +44,7 @@ const toggleRegionFormatGlobally = (
// Take into account the extra '> ' characters, if necessary
if (inBlockQuote && preserveBlockQuotes) {
stopIdx += blockQuoteStartLen;
stopIdx += singleBlockquoteMarkerLength;
}
}
@@ -68,7 +66,7 @@ const toggleRegionFormatGlobally = (
if (startMatchLen >= 0 && stopMatchLen >= 0) {
const fromLine = doc.lineAt(sel.from);
const inBlockQuote = fromLine.text.match(blockQuoteRegex);
const inBlockQuote = fromLine.text.match(blockquoteDetectRegex);
let lineStartStr = '\n';
if (inBlockQuote && preserveBlockQuotes) {
@@ -131,7 +129,7 @@ const toggleRegionFormatGlobally = (
for (let i = fromLine.number; i <= toLine.number; i++) {
const line = doc.line(i);
if (!line.text.match(blockQuoteRegex)) {
if (!line.text.match(blockquoteDetectRegex)) {
inBlockQuote = false;
break;
}
@@ -139,8 +137,8 @@ const toggleRegionFormatGlobally = (
// Ignore block quote characters if in a block quote.
if (inBlockQuote && preserveBlockQuotes) {
fromLineText = fromLineText.substring(blockQuoteStartLen);
toLineText = toLineText.substring(blockQuoteStartLen);
fromLineText = fromLineText.substring(singleBlockquoteMarkerLength);
toLineText = toLineText.substring(singleBlockquoteMarkerLength);
}
// Otherwise, we're toggling the block version
@@ -226,6 +226,10 @@ export default class InteropService_Importer_OneNote extends InteropService_Impo
(dom: Document, currentFolder: string) => this.convertExternalLinksToInternalLinks_(dom, currentFolder, idMap),
(dom: Document, _currentFolder: string) => Promise.resolve(this.simplifyHtml_(dom)),
];
// Workaround: HTML read directly from the filesystem can cause parseFromString to hang.
// Force creation of a new string.
// See https://github.com/laurent22/joplin/issues/15132
html = `${html} `.substring(0, html.length);
const dom = this.domParser.parseFromString(html, 'text/html');
let changed = false;
@@ -11,6 +11,7 @@ const input: Theme = {
oddBackgroundColor: '#eeeeee',
color: '#32373F', // For regular text
colorError: 'red',
colorErrorSelected: '#d00000',
colorCorrect: 'green',
colorWarn: 'rgb(228,86,0)',
colorWarnUrl: '#155BDA',
@@ -87,6 +88,7 @@ const expected = `
--joplin-color-correct: green;
--joplin-color-error: red;
--joplin-color-error2: #ff6c6c;
--joplin-color-error-selected: #d00000;
--joplin-color-faded: #7C8B9E;
--joplin-color-warn: rgb(228,86,0);
--joplin-color-warn2: #ffcb81;
+1
View File
@@ -21,6 +21,7 @@ const theme: Theme = {
dividerColor: '#555555',
selectedColor: '#616161',
urlColor: 'rgb(166,166,255)',
colorErrorSelected: '#FFD7D7',
// Color scheme "2" is used for the sidebar. It's white text over
// dark blue background.
+1
View File
@@ -18,6 +18,7 @@ const theme: Theme = {
dividerColor: '#dddddd',
selectedColor: '#e5e5e5',
urlColor: '#155BDA',
colorErrorSelected: '#d00000',
// Color scheme "2" is used for the sidebar. It's white text over
// dark blue background.
+1
View File
@@ -13,6 +13,7 @@ export interface Theme {
oddBackgroundColor: string;
color: string; // For regular text
colorError: string;
colorErrorSelected: string; // On a selectedColor background
colorCorrect: string;
colorWarn: string;
colorWarnUrl: string; // For URL displayed over a warningBackgroundColor