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

这个提交包含在:
chen
2026-11-04 11:29:39 +08:00
当前提交 301194963b
共有 8663 个文件被更改,包括 1962691 次插入 和 0 次删除

查看文件

@@ -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 };