feat: Initial clean release of CHM
一些检查失败了
automerge / automerge (push) Has been cancelled
react-native-android-build-apk / AssembleRelease (push) Has been cancelled
Build macOS M1 / Main (push) Has been cancelled
Joplin Continuous Integration / Main (macos-15-intel) (push) Has been cancelled
Joplin Continuous Integration / Main (ubuntu-22.04) (push) Has been cancelled
Joplin Continuous Integration / Main (ubuntu-22.04-arm) (push) Has been cancelled
Joplin Continuous Integration / Main (windows-11-vs2026-arm) (push) Has been cancelled
Joplin Continuous Integration / Main (windows-2025) (push) Has been cancelled
Joplin Continuous Integration / ServerDockerImage (ubuntu-22.04) (push) Has been cancelled
Joplin Continuous Integration / ServerDockerImage (ubuntu-22.04-arm) (push) Has been cancelled
Joplin UI tests / Main (ubuntu-22.04) (push) Has been cancelled
Joplin UI tests / Main (windows-2025) (push) Has been cancelled
Joplin UI tests / Mobile (push) Has been cancelled
Close stale issues / ProcessStaleIssues (push) Has been cancelled
一些检查失败了
automerge / automerge (push) Has been cancelled
react-native-android-build-apk / AssembleRelease (push) Has been cancelled
Build macOS M1 / Main (push) Has been cancelled
Joplin Continuous Integration / Main (macos-15-intel) (push) Has been cancelled
Joplin Continuous Integration / Main (ubuntu-22.04) (push) Has been cancelled
Joplin Continuous Integration / Main (ubuntu-22.04-arm) (push) Has been cancelled
Joplin Continuous Integration / Main (windows-11-vs2026-arm) (push) Has been cancelled
Joplin Continuous Integration / Main (windows-2025) (push) Has been cancelled
Joplin Continuous Integration / ServerDockerImage (ubuntu-22.04) (push) Has been cancelled
Joplin Continuous Integration / ServerDockerImage (ubuntu-22.04-arm) (push) Has been cancelled
Joplin UI tests / Main (ubuntu-22.04) (push) Has been cancelled
Joplin UI tests / Main (windows-2025) (push) Has been cancelled
Joplin UI tests / Mobile (push) Has been cancelled
Close stale issues / ProcessStaleIssues (push) Has been cancelled
这个提交包含在:
@@ -0,0 +1,65 @@
|
||||
const { pregQuote } = require('@joplin/lib/string-utils-common');
|
||||
|
||||
// Specifies how a to find the start/stop of a type of formatting
|
||||
interface RegionMatchSpec {
|
||||
start: RegExp;
|
||||
end: RegExp;
|
||||
}
|
||||
|
||||
// Describes a region's formatting
|
||||
export interface RegionSpec {
|
||||
// The name of the node corresponding to the region in the syntax tree
|
||||
nodeName?: string;
|
||||
|
||||
// Text to be inserted before and after the region when toggling.
|
||||
template: { start: string; end: string };
|
||||
|
||||
// How to identify the region
|
||||
matcher: RegionMatchSpec;
|
||||
}
|
||||
|
||||
export namespace RegionSpec { // eslint-disable-line no-redeclare
|
||||
interface RegionSpecConfig {
|
||||
nodeName?: string;
|
||||
template: string | { start: string; end: string };
|
||||
matcher?: RegionMatchSpec;
|
||||
}
|
||||
|
||||
// Creates a new RegionSpec, given a simplified set of options.
|
||||
// If [config.template] is a string, it is used as both the starting and ending
|
||||
// templates.
|
||||
// Similarly, if [config.matcher] is not given, a matcher is created based on
|
||||
// [config.template].
|
||||
export const of = (config: RegionSpecConfig): RegionSpec => {
|
||||
let templateStart: string, templateEnd: string;
|
||||
if (typeof config.template === 'string') {
|
||||
templateStart = config.template;
|
||||
templateEnd = config.template;
|
||||
} else {
|
||||
templateStart = config.template.start;
|
||||
templateEnd = config.template.end;
|
||||
}
|
||||
|
||||
const matcher: RegionMatchSpec =
|
||||
config.matcher ?? matcherFromTemplate(templateStart, templateEnd);
|
||||
|
||||
return {
|
||||
nodeName: config.nodeName,
|
||||
template: { start: templateStart, end: templateEnd },
|
||||
matcher,
|
||||
};
|
||||
};
|
||||
|
||||
const matcherFromTemplate = (start: string, end: string): RegionMatchSpec => {
|
||||
// See https://stackoverflow.com/a/30851002
|
||||
const escapedStart = pregQuote(start);
|
||||
const escapedEnd = pregQuote(end);
|
||||
|
||||
return {
|
||||
start: new RegExp(escapedStart, 'g'),
|
||||
end: new RegExp(escapedEnd, 'g'),
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
import { EditorSelection } from '@codemirror/state';
|
||||
import createTestEditor from '../../testing/createTestEditor';
|
||||
import computeSelectionFormatting from './computeSelectionFormatting';
|
||||
|
||||
|
||||
describe('computeSelectionFormatting', () => {
|
||||
// The below tests rely on CodeMirror to correctly parse the document, which
|
||||
// can be buggy (and fail very rarely).
|
||||
jest.retryTimes(2);
|
||||
|
||||
it('should correctly compute formatting for partial links', async () => {
|
||||
// Start with the selection midway through the link
|
||||
const editor = await createTestEditor('A [partial link]', EditorSelection.cursor(4), ['Link']);
|
||||
|
||||
const formatting = computeSelectionFormatting(editor.state, false);
|
||||
expect(formatting.linkData).toMatchObject({
|
||||
linkText: null,
|
||||
linkURL: null,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,126 @@
|
||||
import SelectionFormatting, { MutableSelectionFormatting, defaultSelectionFormatting } from '../../../SelectionFormatting';
|
||||
import { syntaxTree } from '@codemirror/language';
|
||||
import { EditorState } from '@codemirror/state';
|
||||
|
||||
const computeSelectionFormatting = (state: EditorState, globalSpellcheck: boolean): SelectionFormatting => {
|
||||
const range = state.selection.main;
|
||||
const formatting: MutableSelectionFormatting = {
|
||||
...defaultSelectionFormatting,
|
||||
selectedText: state.doc.sliceString(range.from, range.to),
|
||||
spellChecking: globalSpellcheck,
|
||||
};
|
||||
|
||||
const parseLinkData = (nodeText: string) => {
|
||||
const linkMatch = nodeText.match(/\[([^\]]*)\]\(([^)]*)\)/);
|
||||
|
||||
if (linkMatch) {
|
||||
return {
|
||||
linkText: linkMatch[1],
|
||||
linkURL: linkMatch[2],
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
linkText: null,
|
||||
linkURL: null,
|
||||
};
|
||||
};
|
||||
|
||||
// Find nodes that overlap/are within the selected region
|
||||
syntaxTree(state).iterate({
|
||||
from: range.from, to: range.to,
|
||||
enter: node => {
|
||||
// Checklists don't have a specific containing node. As such,
|
||||
// we're in a checklist if we've selected a 'Task' node.
|
||||
if (node.name === 'Task') {
|
||||
formatting.inChecklist = true;
|
||||
}
|
||||
|
||||
// Only handle notes that contain the entire range.
|
||||
if (node.from > range.from || node.to < range.to) {
|
||||
return;
|
||||
}
|
||||
// Lazily compute the node's text
|
||||
const nodeText = () => state.doc.sliceString(node.from, node.to);
|
||||
|
||||
switch (node.name) {
|
||||
case 'StrongEmphasis':
|
||||
formatting.bolded = true;
|
||||
break;
|
||||
case 'Emphasis':
|
||||
formatting.italicized = true;
|
||||
break;
|
||||
case 'ListItem':
|
||||
formatting.listLevel += 1;
|
||||
break;
|
||||
case 'BulletList':
|
||||
formatting.inUnorderedList = true;
|
||||
break;
|
||||
case 'OrderedList':
|
||||
formatting.inOrderedList = true;
|
||||
break;
|
||||
case 'TaskList':
|
||||
formatting.inChecklist = true;
|
||||
break;
|
||||
case 'InlineCode':
|
||||
case 'FencedCode':
|
||||
formatting.inCode = true;
|
||||
formatting.unspellCheckableRegion = true;
|
||||
break;
|
||||
case 'InlineMath':
|
||||
case 'BlockMath':
|
||||
formatting.inMath = true;
|
||||
formatting.unspellCheckableRegion = true;
|
||||
break;
|
||||
case 'ATXHeading1':
|
||||
formatting.headerLevel = 1;
|
||||
break;
|
||||
case 'ATXHeading2':
|
||||
formatting.headerLevel = 2;
|
||||
break;
|
||||
case 'ATXHeading3':
|
||||
formatting.headerLevel = 3;
|
||||
break;
|
||||
case 'ATXHeading4':
|
||||
formatting.headerLevel = 4;
|
||||
break;
|
||||
case 'ATXHeading5':
|
||||
formatting.headerLevel = 5;
|
||||
break;
|
||||
case 'URL':
|
||||
formatting.inLink = true;
|
||||
formatting.linkData = {
|
||||
...formatting.linkData,
|
||||
linkURL: nodeText(),
|
||||
};
|
||||
formatting.unspellCheckableRegion = true;
|
||||
break;
|
||||
case 'Link':
|
||||
formatting.inLink = true;
|
||||
formatting.linkData = parseLinkData(nodeText());
|
||||
break;
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
// The markdown parser marks checklists as unordered lists. Ensure
|
||||
// that they aren't marked as such.
|
||||
if (formatting.inChecklist) {
|
||||
if (!formatting.inUnorderedList) {
|
||||
// Even if the selection contains a Task, because an unordered list node
|
||||
// must contain a valid Task node, we're only in a checklist if we're also in
|
||||
// an unordered list.
|
||||
formatting.inChecklist = false;
|
||||
} else {
|
||||
formatting.inUnorderedList = false;
|
||||
}
|
||||
}
|
||||
|
||||
if (formatting.unspellCheckableRegion) {
|
||||
formatting.spellChecking = false;
|
||||
}
|
||||
|
||||
return formatting;
|
||||
};
|
||||
export default computeSelectionFormatting;
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
import { EditorSelection, Text as DocumentText } from '@codemirror/state';
|
||||
import { RegionSpec } from './RegionSpec';
|
||||
import findInlineMatch, { MatchSide } from './findInlineMatch';
|
||||
|
||||
describe('findInlineMatch', () => {
|
||||
jest.retryTimes(2);
|
||||
|
||||
const boldSpec: RegionSpec = RegionSpec.of({
|
||||
template: '**',
|
||||
});
|
||||
|
||||
it('matching a bolded region: should return the length of the match', () => {
|
||||
const doc = DocumentText.of(['**test**']);
|
||||
const sel = EditorSelection.range(0, 5);
|
||||
|
||||
// matchStart returns the length of the match
|
||||
expect(findInlineMatch(doc, boldSpec, sel, MatchSide.Start)).toBe(2);
|
||||
});
|
||||
|
||||
it('matching a bolded region: should match the end of a region, if next to the cursor', () => {
|
||||
const doc = DocumentText.of(['**...** test.']);
|
||||
const sel = EditorSelection.range(5, 5);
|
||||
expect(findInlineMatch(doc, boldSpec, sel, MatchSide.End)).toBe(2);
|
||||
});
|
||||
|
||||
it('matching a bolded region: should return -1 if no match is found', () => {
|
||||
const doc = DocumentText.of(['**...** test.']);
|
||||
const sel = EditorSelection.range(3, 3);
|
||||
expect(findInlineMatch(doc, boldSpec, sel, MatchSide.Start)).toBe(-1);
|
||||
});
|
||||
|
||||
it('should match a custom specification of italicized regions', () => {
|
||||
const spec: RegionSpec = {
|
||||
template: { start: '*', end: '*' },
|
||||
matcher: { start: /[*_]/g, end: /[*_]/g },
|
||||
};
|
||||
const testString = 'This is a _test_';
|
||||
const testDoc = DocumentText.of([testString]);
|
||||
const fullSel = EditorSelection.range('This is a '.length, testString.length);
|
||||
|
||||
// should match the start of the region
|
||||
expect(findInlineMatch(testDoc, spec, fullSel, MatchSide.Start)).toBe(1);
|
||||
|
||||
// should match the end of the region
|
||||
expect(findInlineMatch(testDoc, spec, fullSel, MatchSide.End)).toBe(1);
|
||||
});
|
||||
|
||||
const listSpec: RegionSpec = {
|
||||
template: { start: ' - ', end: '' },
|
||||
matcher: {
|
||||
start: /^\s*[-*]\s/g,
|
||||
end: /$/g,
|
||||
},
|
||||
};
|
||||
|
||||
it('matching a custom list: should not match a list if not within the selection', () => {
|
||||
const doc = DocumentText.of(['- Test...']);
|
||||
const sel = EditorSelection.range(1, 6);
|
||||
|
||||
// Beginning of list not selected: no match
|
||||
expect(findInlineMatch(doc, listSpec, sel, MatchSide.Start)).toBe(-1);
|
||||
});
|
||||
|
||||
it('matching a custom list: should match start of selected, unindented list', () => {
|
||||
const doc = DocumentText.of(['- Test...']);
|
||||
const sel = EditorSelection.range(0, 6);
|
||||
|
||||
expect(findInlineMatch(doc, listSpec, sel, MatchSide.Start)).toBe(2);
|
||||
});
|
||||
|
||||
it('matching a custom list: should match start of indented list', () => {
|
||||
const doc = DocumentText.of([' - Test...']);
|
||||
const sel = EditorSelection.range(0, 6);
|
||||
|
||||
expect(findInlineMatch(doc, listSpec, sel, MatchSide.Start)).toBe(5);
|
||||
});
|
||||
|
||||
it('matching a custom list: should match the end of an item in an indented list', () => {
|
||||
const doc = DocumentText.of([' - Test...']);
|
||||
const sel = EditorSelection.range(0, 6);
|
||||
|
||||
// Zero-length, but found, selection
|
||||
expect(findInlineMatch(doc, listSpec, sel, MatchSide.End)).toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,76 @@
|
||||
import { Text as DocumentText, SelectionRange } from '@codemirror/state';
|
||||
import { RegionSpec } from './RegionSpec';
|
||||
|
||||
export enum MatchSide {
|
||||
Start,
|
||||
End,
|
||||
}
|
||||
|
||||
// Returns the length of a match for this in the given selection,
|
||||
// -1 if no match is found.
|
||||
const findInlineMatch = (
|
||||
doc: DocumentText, spec: RegionSpec, sel: SelectionRange, side: MatchSide,
|
||||
): number => {
|
||||
const [regex, template] = (() => {
|
||||
if (side === MatchSide.Start) {
|
||||
return [spec.matcher.start, spec.template.start];
|
||||
} else {
|
||||
return [spec.matcher.end, spec.template.end];
|
||||
}
|
||||
})();
|
||||
const [startIndex, endIndex] = (() => {
|
||||
if (!sel.empty) {
|
||||
return [sel.from, sel.to];
|
||||
}
|
||||
|
||||
const bufferSize = template.length;
|
||||
if (side === MatchSide.Start) {
|
||||
return [sel.from - bufferSize, sel.to];
|
||||
} else {
|
||||
return [sel.from, sel.to + bufferSize];
|
||||
}
|
||||
})();
|
||||
const searchText = doc.sliceString(startIndex, endIndex);
|
||||
|
||||
// Returns true if [idx] is in the right place (the match is at
|
||||
// the end of the string or the beginning based on startIndex/endIndex).
|
||||
const indexSatisfies = (idx: number, len: number): boolean => {
|
||||
idx += startIndex;
|
||||
if (side === MatchSide.Start) {
|
||||
return idx === startIndex;
|
||||
} else {
|
||||
return idx + len === endIndex;
|
||||
}
|
||||
};
|
||||
|
||||
if (!regex.global) {
|
||||
throw new Error('Regular expressions used by RegionSpec must have the global flag! This flag is required to find multiple matches.');
|
||||
}
|
||||
|
||||
// Search from the beginning.
|
||||
regex.lastIndex = 0;
|
||||
|
||||
let foundMatch: RegExpMatchArray|null = null;
|
||||
let match: RegExpMatchArray|null;
|
||||
while ((match = regex.exec(searchText)) !== null) {
|
||||
if (indexSatisfies(match.index ?? -1, match[0].length)) {
|
||||
foundMatch = match;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (foundMatch) {
|
||||
const matchLength = foundMatch[0].length;
|
||||
const matchIndex = foundMatch.index;
|
||||
|
||||
// If the match isn't in the right place,
|
||||
if (indexSatisfies(matchIndex, matchLength)) {
|
||||
return matchLength;
|
||||
}
|
||||
}
|
||||
|
||||
return -1;
|
||||
};
|
||||
|
||||
export default findInlineMatch;
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
import { EditorState } from '@codemirror/state';
|
||||
import tabsToSpaces from './tabsToSpaces';
|
||||
|
||||
// Returns true iff [a] (an indentation string) is roughly equivalent to [b].
|
||||
const isIndentationEquivalent = (state: EditorState, a: string, b: string): boolean => {
|
||||
// Consider sublists to be the same as their parent list if they have the same
|
||||
// label plus or minus 1 space.
|
||||
return Math.abs(tabsToSpaces(state, a).length - tabsToSpaces(state, b).length) <= 1;
|
||||
};
|
||||
|
||||
export default isIndentationEquivalent;
|
||||
@@ -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,19 @@
|
||||
import { indentUnit } from '@codemirror/language';
|
||||
import { EditorSelection, EditorState } from '@codemirror/state';
|
||||
import tabsToSpaces from './tabsToSpaces';
|
||||
|
||||
|
||||
describe('tabsToSpaces', () => {
|
||||
it('should convert tabs to spaces based on indentUnit', () => {
|
||||
const state: EditorState = EditorState.create({
|
||||
doc: 'This is a test.',
|
||||
selection: EditorSelection.cursor(0),
|
||||
extensions: [
|
||||
indentUnit.of(' '),
|
||||
],
|
||||
});
|
||||
expect(tabsToSpaces(state, '\t')).toBe(' ');
|
||||
expect(tabsToSpaces(state, '\t ')).toBe(' ');
|
||||
expect(tabsToSpaces(state, ' \t ')).toBe(' ');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,19 @@
|
||||
import { EditorState } from '@codemirror/state';
|
||||
import { getIndentUnit } from '@codemirror/language';
|
||||
|
||||
const tabsToSpaces = (state: EditorState, text: string): string => {
|
||||
const chunks = text.split('\t');
|
||||
const spaceLen = getIndentUnit(state);
|
||||
let result = chunks[0];
|
||||
|
||||
for (let i = 1; i < chunks.length; i++) {
|
||||
for (let j = result.length % spaceLen; j < spaceLen; j++) {
|
||||
result += ' ';
|
||||
}
|
||||
|
||||
result += chunks[i];
|
||||
}
|
||||
return result;
|
||||
};
|
||||
|
||||
export default tabsToSpaces;
|
||||
@@ -0,0 +1,16 @@
|
||||
import { EditorState, SelectionRange, TransactionSpec } from '@codemirror/state';
|
||||
import { RegionSpec } from './RegionSpec';
|
||||
import toggleInlineSelectionFormat from './toggleInlineSelectionFormat';
|
||||
|
||||
|
||||
// Like toggleInlineSelectionFormat, but for all selections in [state].
|
||||
const toggleInlineFormatGlobally = (
|
||||
state: EditorState, spec: RegionSpec,
|
||||
): TransactionSpec => {
|
||||
const changes = state.changeByRange((sel: SelectionRange) => {
|
||||
return toggleInlineSelectionFormat(state, spec, sel);
|
||||
});
|
||||
return changes;
|
||||
};
|
||||
|
||||
export default toggleInlineFormatGlobally;
|
||||
@@ -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;
|
||||
@@ -0,0 +1,83 @@
|
||||
import { Text as DocumentText, EditorSelection, SelectionRange } from '@codemirror/state';
|
||||
import { RegionSpec } from './RegionSpec';
|
||||
import findInlineMatch, { MatchSide } from './findInlineMatch';
|
||||
import { SelectionUpdate } from './types';
|
||||
|
||||
// Toggles whether the given selection matches the inline region specified by [spec].
|
||||
//
|
||||
// For example, something similar to toggleSurrounded('**', '**') would surround
|
||||
// every selection range with asterisks (including the caret).
|
||||
// If the selection is already surrounded by these characters, they are
|
||||
// removed.
|
||||
const toggleInlineRegionSurrounded = (
|
||||
doc: DocumentText, sel: SelectionRange, spec: RegionSpec,
|
||||
): SelectionUpdate => {
|
||||
let content = doc.sliceString(sel.from, sel.to);
|
||||
const startMatchLen = findInlineMatch(doc, spec, sel, MatchSide.Start);
|
||||
const endMatchLen = findInlineMatch(doc, spec, sel, MatchSide.End);
|
||||
|
||||
const startsWithBefore = startMatchLen >= 0;
|
||||
const endsWithAfter = endMatchLen >= 0;
|
||||
|
||||
const changes = [];
|
||||
let finalSelStart = sel.from;
|
||||
let finalSelEnd = sel.to;
|
||||
|
||||
if (startsWithBefore && endsWithAfter) {
|
||||
// Remove the before and after.
|
||||
content = content.substring(startMatchLen);
|
||||
content = content.substring(0, content.length - endMatchLen);
|
||||
|
||||
finalSelEnd -= startMatchLen + endMatchLen;
|
||||
|
||||
changes.push({
|
||||
from: sel.from,
|
||||
to: sel.to,
|
||||
insert: content,
|
||||
});
|
||||
} else {
|
||||
// When the selection is non-empty, trim leading/trailing whitespace so that
|
||||
// formatting markers are placed adjacent to the text content.
|
||||
let insertFrom = sel.from;
|
||||
let insertTo = sel.to;
|
||||
|
||||
if (!sel.empty) {
|
||||
const leadingWhitespace = content.match(/^\s*/)?.[0] ?? '';
|
||||
const trailingWhitespace = content.match(/\s*$/)?.[0] ?? '';
|
||||
insertFrom += leadingWhitespace.length;
|
||||
insertTo -= trailingWhitespace.length;
|
||||
|
||||
// If the entire selection is whitespace, fall back to the original range.
|
||||
if (insertFrom >= insertTo) {
|
||||
insertFrom = sel.from;
|
||||
insertTo = sel.to;
|
||||
}
|
||||
}
|
||||
|
||||
changes.push({
|
||||
from: insertFrom,
|
||||
insert: spec.template.start,
|
||||
});
|
||||
|
||||
changes.push({
|
||||
from: insertTo,
|
||||
insert: spec.template.end,
|
||||
});
|
||||
|
||||
if (!sel.empty) {
|
||||
// Select the surrounding chars.
|
||||
finalSelEnd += spec.template.start.length + spec.template.end.length;
|
||||
} else {
|
||||
// Position the caret within the added content.
|
||||
finalSelStart = sel.from + spec.template.start.length;
|
||||
finalSelEnd = finalSelStart;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
changes,
|
||||
range: EditorSelection.range(finalSelStart, finalSelEnd),
|
||||
};
|
||||
};
|
||||
|
||||
export default toggleInlineRegionSurrounded;
|
||||
@@ -0,0 +1,37 @@
|
||||
import { EditorSelection, SelectionRange, EditorState } from '@codemirror/state';
|
||||
import { RegionSpec } from './RegionSpec';
|
||||
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
|
||||
// caret to the end.
|
||||
// E.g.
|
||||
// **foobar|**
|
||||
// **foobar**|
|
||||
if (sel.empty && endMatchLen > -1) {
|
||||
const newCursorPos = sel.from + endMatchLen;
|
||||
|
||||
return {
|
||||
range: EditorSelection.cursor(newCursorPos),
|
||||
};
|
||||
}
|
||||
|
||||
// Grow the selection to encompass the entire node.
|
||||
const newRange = growSelectionToNode(state, sel, spec.nodeName);
|
||||
return toggleInlineRegionSurrounded(state.doc, newRange, spec);
|
||||
};
|
||||
|
||||
export default toggleInlineSelectionFormat;
|
||||
@@ -0,0 +1,52 @@
|
||||
import { EditorSelection, EditorState } from '@codemirror/state';
|
||||
import { RegionSpec } from './RegionSpec';
|
||||
import toggleRegionFormatGlobally from './toggleRegionFormatGlobally';
|
||||
|
||||
describe('toggleRegionFormatGlobally', () => {
|
||||
jest.retryTimes(2);
|
||||
|
||||
const multiLineTestText = `Internal text manipulation
|
||||
This is a test...
|
||||
of block and inline region toggling.`;
|
||||
const codeFenceRegex = /^``````\w*\s*$/;
|
||||
const inlineCodeRegionSpec = RegionSpec.of({
|
||||
template: '`',
|
||||
nodeName: 'InlineCode',
|
||||
});
|
||||
const blockCodeRegionSpec: RegionSpec = {
|
||||
template: { start: '``````', end: '``````' },
|
||||
matcher: { start: codeFenceRegex, end: codeFenceRegex },
|
||||
};
|
||||
|
||||
it('should create an empty inline region around the cursor, if given an empty selection', () => {
|
||||
const initialState: EditorState = EditorState.create({
|
||||
doc: multiLineTestText,
|
||||
selection: EditorSelection.cursor(0),
|
||||
});
|
||||
|
||||
const changes = toggleRegionFormatGlobally(
|
||||
initialState, inlineCodeRegionSpec, blockCodeRegionSpec,
|
||||
);
|
||||
|
||||
const newState = initialState.update(changes).state;
|
||||
expect(newState.doc.toString()).toEqual(`\`\`${multiLineTestText}`);
|
||||
});
|
||||
|
||||
it('should wrap multiple selected lines in block formatting', () => {
|
||||
const initialState: EditorState = EditorState.create({
|
||||
doc: multiLineTestText,
|
||||
selection: EditorSelection.range(0, multiLineTestText.length),
|
||||
});
|
||||
|
||||
const changes = toggleRegionFormatGlobally(
|
||||
initialState, inlineCodeRegionSpec, blockCodeRegionSpec,
|
||||
);
|
||||
|
||||
const newState = initialState.update(changes).state;
|
||||
const editorText = newState.doc.toString();
|
||||
expect(editorText).toBe(`\`\`\`\`\`\`\n${multiLineTestText}\n\`\`\`\`\`\``);
|
||||
expect(newState.selection.main.from).toBe(0);
|
||||
expect(newState.selection.main.to).toBe(editorText.length);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,202 @@
|
||||
import { EditorSelection, EditorState, Line, SelectionRange, TransactionSpec } from '@codemirror/state';
|
||||
import { RegionSpec } from './RegionSpec';
|
||||
import findInlineMatch, { MatchSide } from './findInlineMatch';
|
||||
import growSelectionToNode from '../growSelectionToNode';
|
||||
import toggleInlineSelectionFormat from './toggleInlineSelectionFormat';
|
||||
import { blockquoteDetectRegex, singleBlockquoteMarkerLength } from './markdownFormatPatterns';
|
||||
|
||||
// Toggle formatting for all selections. For example,
|
||||
// toggling a code RegionSpec repeatedly should create:
|
||||
// 1. Empty inline code: ``
|
||||
// 2. Empty block code:
|
||||
// ```
|
||||
// ```
|
||||
// 3. Remove the block code.
|
||||
//
|
||||
// This is intended primarily for mobile, where characters
|
||||
// like "`" can be difficult to type.
|
||||
const toggleRegionFormatGlobally = (
|
||||
state: EditorState,
|
||||
|
||||
inlineSpec: RegionSpec,
|
||||
blockSpec: RegionSpec,
|
||||
): TransactionSpec => {
|
||||
const doc = state.doc;
|
||||
const preserveBlockQuotes = true;
|
||||
|
||||
const getMatchEndPoints = (
|
||||
match: RegExpMatchArray, line: Line, inBlockQuote: boolean,
|
||||
): [startIdx: number, stopIdx: number] => {
|
||||
const startIdx = line.from + match.index;
|
||||
let stopIdx;
|
||||
|
||||
// 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 -= singleBlockquoteMarkerLength;
|
||||
}
|
||||
|
||||
// If it matches the entire line, remove the newline character.
|
||||
if (match[0].length === contentLength) {
|
||||
stopIdx = line.to + 1;
|
||||
} else {
|
||||
stopIdx = startIdx + match[0].length;
|
||||
|
||||
// Take into account the extra '> ' characters, if necessary
|
||||
if (inBlockQuote && preserveBlockQuotes) {
|
||||
stopIdx += singleBlockquoteMarkerLength;
|
||||
}
|
||||
}
|
||||
|
||||
stopIdx = Math.min(stopIdx, doc.length);
|
||||
return [startIdx, stopIdx];
|
||||
};
|
||||
|
||||
// Returns a change spec that converts an inline region to a block region
|
||||
// only if the user's cursor is in an empty inline region.
|
||||
// For example,
|
||||
// $|$ -> $$\n|\n$$ where | represents the cursor.
|
||||
const handleInlineToBlockConversion = (sel: SelectionRange) => {
|
||||
if (!sel.empty) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const startMatchLen = findInlineMatch(doc, inlineSpec, sel, MatchSide.Start);
|
||||
const stopMatchLen = findInlineMatch(doc, inlineSpec, sel, MatchSide.End);
|
||||
|
||||
if (startMatchLen >= 0 && stopMatchLen >= 0) {
|
||||
const fromLine = doc.lineAt(sel.from);
|
||||
const inBlockQuote = fromLine.text.match(blockquoteDetectRegex);
|
||||
|
||||
let lineStartStr = '\n';
|
||||
if (inBlockQuote && preserveBlockQuotes) {
|
||||
lineStartStr = '\n> ';
|
||||
}
|
||||
|
||||
|
||||
const inlineStart = sel.from - startMatchLen;
|
||||
const inlineStop = sel.from + stopMatchLen;
|
||||
|
||||
// Determine the text that starts the new block (e.g. \n$$\n for
|
||||
// a math block).
|
||||
let blockStart = `${blockSpec.template.start}${lineStartStr}`;
|
||||
if (fromLine.from !== inlineStart) {
|
||||
// Add a line before to put the start of the block
|
||||
// on its own line.
|
||||
blockStart = lineStartStr + blockStart;
|
||||
}
|
||||
|
||||
return {
|
||||
changes: [
|
||||
{
|
||||
from: inlineStart,
|
||||
to: inlineStop,
|
||||
insert: `${blockStart}${lineStartStr}${blockSpec.template.end}`,
|
||||
},
|
||||
],
|
||||
|
||||
range: EditorSelection.cursor(inlineStart + blockStart.length),
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
const changes = state.changeByRange((sel: SelectionRange) => {
|
||||
const blockConversion = handleInlineToBlockConversion(sel);
|
||||
if (blockConversion) {
|
||||
return blockConversion;
|
||||
}
|
||||
|
||||
// If we're in the block version, grow the selection to cover the entire region.
|
||||
sel = growSelectionToNode(state, sel, blockSpec.nodeName);
|
||||
|
||||
const fromLine = doc.lineAt(sel.from);
|
||||
const toLine = doc.lineAt(sel.to);
|
||||
let fromLineText = fromLine.text;
|
||||
let toLineText = toLine.text;
|
||||
|
||||
let charsAdded = 0;
|
||||
const changes = [];
|
||||
|
||||
// Single line: Inline toggle.
|
||||
if (fromLine.number === toLine.number) {
|
||||
return toggleInlineSelectionFormat(state, inlineSpec, sel);
|
||||
}
|
||||
|
||||
// Are all lines in a block quote?
|
||||
let inBlockQuote = true;
|
||||
for (let i = fromLine.number; i <= toLine.number; i++) {
|
||||
const line = doc.line(i);
|
||||
|
||||
if (!line.text.match(blockquoteDetectRegex)) {
|
||||
inBlockQuote = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Ignore block quote characters if in a block quote.
|
||||
if (inBlockQuote && preserveBlockQuotes) {
|
||||
fromLineText = fromLineText.substring(singleBlockquoteMarkerLength);
|
||||
toLineText = toLineText.substring(singleBlockquoteMarkerLength);
|
||||
}
|
||||
|
||||
// Otherwise, we're toggling the block version
|
||||
const startMatch = blockSpec.matcher.start.exec(fromLineText);
|
||||
const stopMatch = blockSpec.matcher.end.exec(toLineText);
|
||||
if (startMatch && stopMatch) {
|
||||
// Get start and stop indices for the starting and ending matches
|
||||
const [fromMatchFrom, fromMatchTo] = getMatchEndPoints(startMatch, fromLine, inBlockQuote);
|
||||
const [toMatchFrom, toMatchTo] = getMatchEndPoints(stopMatch, toLine, inBlockQuote);
|
||||
|
||||
// Delete content of the first line
|
||||
changes.push({
|
||||
from: fromMatchFrom,
|
||||
to: fromMatchTo,
|
||||
});
|
||||
charsAdded -= fromMatchTo - fromMatchFrom;
|
||||
|
||||
// Delete content of the last line
|
||||
changes.push({
|
||||
from: toMatchFrom,
|
||||
to: toMatchTo,
|
||||
});
|
||||
charsAdded -= toMatchTo - toMatchFrom;
|
||||
} else {
|
||||
let insertBefore, insertAfter;
|
||||
|
||||
if (inBlockQuote && preserveBlockQuotes) {
|
||||
insertBefore = `> ${blockSpec.template.start}\n`;
|
||||
insertAfter = `\n> ${blockSpec.template.end}`;
|
||||
} else {
|
||||
insertBefore = `${blockSpec.template.start}\n`;
|
||||
insertAfter = `\n${blockSpec.template.end}`;
|
||||
}
|
||||
|
||||
changes.push({
|
||||
from: fromLine.from,
|
||||
insert: insertBefore,
|
||||
});
|
||||
|
||||
changes.push({
|
||||
from: toLine.to,
|
||||
insert: insertAfter,
|
||||
});
|
||||
charsAdded += insertBefore.length + insertAfter.length;
|
||||
}
|
||||
|
||||
return {
|
||||
changes,
|
||||
|
||||
// Selection should now encompass all lines that were changed.
|
||||
range: EditorSelection.range(
|
||||
fromLine.from, toLine.to + charsAdded,
|
||||
),
|
||||
};
|
||||
});
|
||||
|
||||
return changes;
|
||||
};
|
||||
|
||||
export default toggleRegionFormatGlobally;
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
import { EditorSelection, EditorState, Line, SelectionRange, TransactionSpec } from '@codemirror/state';
|
||||
import growSelectionToNode from '../growSelectionToNode';
|
||||
|
||||
// Toggles whether all lines in the user's selection start with [regex].
|
||||
const toggleSelectedLinesStartWith = (
|
||||
state: EditorState,
|
||||
regex: RegExp,
|
||||
template: string,
|
||||
matchEmpty: boolean,
|
||||
|
||||
// Determines where this formatting can begin on a line.
|
||||
// Defaults to after a block quote marker
|
||||
lineContentStartRegex = /^>\s/,
|
||||
|
||||
// Name associated with what [regex] matches (e.g. FencedCode)
|
||||
nodeName?: string,
|
||||
): TransactionSpec => {
|
||||
const getLineContentStart = (line: Line): number => {
|
||||
const blockQuoteMatch = line.text.match(lineContentStartRegex);
|
||||
if (blockQuoteMatch) {
|
||||
return line.from + blockQuoteMatch[0].length;
|
||||
}
|
||||
|
||||
return line.from;
|
||||
};
|
||||
|
||||
const getLineContent = (line: Line): string => {
|
||||
const contentStart = getLineContentStart(line);
|
||||
return line.text.substring(contentStart - line.from);
|
||||
};
|
||||
|
||||
const changes = state.changeByRange((sel: SelectionRange) => {
|
||||
// Attempt to select all lines in the region
|
||||
if (nodeName && sel.empty) {
|
||||
sel = growSelectionToNode(state, sel, nodeName);
|
||||
}
|
||||
|
||||
const doc = state.doc;
|
||||
const fromLine = doc.lineAt(sel.from);
|
||||
const toLine = doc.lineAt(sel.to);
|
||||
let hasProp = false;
|
||||
let charsAdded = 0;
|
||||
let charsAddedBefore = 0;
|
||||
|
||||
const changes = [];
|
||||
const lines: Line[] = [];
|
||||
|
||||
for (let i = fromLine.number; i <= toLine.number; i++) {
|
||||
const line = doc.line(i);
|
||||
const text = getLineContent(line);
|
||||
|
||||
// If already matching [regex],
|
||||
if (text.search(regex) === 0) {
|
||||
hasProp = true;
|
||||
}
|
||||
|
||||
lines.push(line);
|
||||
}
|
||||
|
||||
for (const line of lines) {
|
||||
const text = getLineContent(line);
|
||||
const contentFrom = getLineContentStart(line);
|
||||
|
||||
// Only process if the line is non-empty.
|
||||
if (!matchEmpty && text.trim().length === 0
|
||||
// Treat the first line differently
|
||||
&& fromLine.number < line.number) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (hasProp) {
|
||||
const match = text.match(regex);
|
||||
if (!match) {
|
||||
continue;
|
||||
}
|
||||
changes.push({
|
||||
from: contentFrom,
|
||||
to: contentFrom + match[0].length,
|
||||
insert: '',
|
||||
});
|
||||
|
||||
const deletedSize = match[0].length;
|
||||
if (contentFrom <= sel.from) {
|
||||
// Math.min: Handles the case where some deleted characters are before sel.from
|
||||
// and others are after.
|
||||
charsAddedBefore -= Math.min(sel.from - contentFrom, deletedSize);
|
||||
}
|
||||
charsAdded -= deletedSize;
|
||||
} else {
|
||||
changes.push({
|
||||
from: contentFrom,
|
||||
insert: template,
|
||||
});
|
||||
|
||||
charsAdded += template.length;
|
||||
if (contentFrom <= sel.from) {
|
||||
charsAddedBefore += template.length;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// If the selection is empty and a single line was changed, don't grow it.
|
||||
// (user might be adding a list/header, in which case, selecting the just
|
||||
// added text isn't helpful)
|
||||
let newSel;
|
||||
if (sel.empty && fromLine.number === toLine.number) {
|
||||
newSel = EditorSelection.cursor(sel.from + charsAddedBefore);
|
||||
} else {
|
||||
newSel = EditorSelection.range(fromLine.from, toLine.to + charsAdded);
|
||||
}
|
||||
|
||||
return {
|
||||
changes,
|
||||
|
||||
// Selection should now encompass all lines that were changed.
|
||||
range: newSel,
|
||||
};
|
||||
});
|
||||
|
||||
return changes;
|
||||
};
|
||||
|
||||
export default toggleSelectedLinesStartWith;
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
import { ChangeSpec, SelectionRange } from '@codemirror/state';
|
||||
|
||||
// Specifies the update of a single selection region and its contents
|
||||
export type SelectionUpdate = { range: SelectionRange; changes?: ChangeSpec };
|
||||
|
||||
在新工单中引用
屏蔽一个用户