mirror of
https://github.com/kotaindah55/extended-markdown-syntax.git
synced 2026-07-22 05:38:06 +00:00
feat: use a formatter as formatting helper
Has an ability to format in a tidier way (configurable in settings).
This commit is contained in:
parent
1839a6c586
commit
e2587b2be5
5 changed files with 413 additions and 0 deletions
336
src/editor-mode/formatting/Formatter.ts
Normal file
336
src/editor-mode/formatting/Formatter.ts
Normal file
|
|
@ -0,0 +1,336 @@
|
|||
import { ChangeSet, EditorSelection } from "@codemirror/state";
|
||||
import { SelectionObserver } from "src/editor-mode/observer";
|
||||
import { selectionObserverField } from "src/editor-mode/state-fields";
|
||||
import { EditorView } from "codemirror";
|
||||
import { Parser } from "src/editor-mode/parser";
|
||||
import { Format, TokenLevel, TokenStatus } from "src/enums";
|
||||
import { PlainRange, PluginSettings, Token } from "src/types";
|
||||
import { getTagRange, provideTokenRanges } from "src/editor-mode/parser/token-utils";
|
||||
import { getBlocks, isBlockStart, isBlockEnd } from "src/editor-mode/doc-utils";
|
||||
import { FormatterState } from "src/editor-mode/formatting";
|
||||
import { supportTag } from "src/format-configs/utils";
|
||||
import { isTouched } from "src/editor-mode/range-utils";
|
||||
import { TagMenu } from "src/editor-mode/ui-components";
|
||||
import { isSurroundedByDelimiter } from "./formatting-utils";
|
||||
|
||||
export class Formatter {
|
||||
view: EditorView;
|
||||
state: FormatterState;
|
||||
parser: Parser;
|
||||
selectionObserver: SelectionObserver;
|
||||
settings: PluginSettings;
|
||||
constructor(view: EditorView) {
|
||||
this.view = view;
|
||||
this.selectionObserver = view.state.field(selectionObserverField);
|
||||
this.parser = this.selectionObserver.parser;
|
||||
this.settings = this.parser.settings;
|
||||
}
|
||||
get doc() {
|
||||
return this.view.state.doc;
|
||||
}
|
||||
defineState(type: Format, tagStr?: string) {
|
||||
this.state = new FormatterState(type, this.doc, this.selectionObserver, this.settings, tagStr);
|
||||
}
|
||||
clearState() {
|
||||
(this.state as unknown as undefined) = undefined;
|
||||
}
|
||||
startFormat(type: Format, tagStr?: string, forceRemove?: boolean, showMenu?: boolean) {
|
||||
if (forceRemove) {
|
||||
tagStr = undefined;
|
||||
}
|
||||
if (!this.settings.openTagMenuAfterFormat) {
|
||||
showMenu = false;
|
||||
}
|
||||
let tokenMaps = this.selectionObserver.pickMaps(type);
|
||||
if (tokenMaps.length == 1 && !tokenMaps[0]?.length && showMenu) {
|
||||
TagMenu.create(this.view, type).showMenu();
|
||||
return;
|
||||
}
|
||||
this.defineState(type, tagStr);
|
||||
if (forceRemove) {
|
||||
this.removeAll()
|
||||
} else if (this.state.level == TokenLevel.INLINE) {
|
||||
this.formatInline();
|
||||
} else {
|
||||
this.formatBlock();
|
||||
}
|
||||
this.composeChanges();
|
||||
this.remapSelection();
|
||||
this.dispatchToView();
|
||||
}
|
||||
formatInline() {
|
||||
let state = this.state,
|
||||
tokens = this.state.tokens;
|
||||
do {
|
||||
let { curTokenMap, curRange, tagStr, precise } = state,
|
||||
firstToken = curTokenMap ? tokens[curTokenMap[0]] : null;
|
||||
if (!precise) {
|
||||
this.toggleDelim();
|
||||
} else if (!firstToken) {
|
||||
this.wrap();
|
||||
} else if (firstToken.from > curRange.from || firstToken.to < curRange.to) {
|
||||
this.extend();
|
||||
} else if (firstToken.status != TokenStatus.ACTIVE) {
|
||||
this.close(firstToken);
|
||||
} else if (tagStr !== undefined) {
|
||||
this.changeInlineTag(firstToken);
|
||||
} else if (curRange.empty) {
|
||||
this.remove(firstToken);
|
||||
} else {
|
||||
this.breakApart(firstToken);
|
||||
}
|
||||
} while (state.advance())
|
||||
}
|
||||
formatBlock() {
|
||||
do {
|
||||
this.toggleBlockTag();
|
||||
} while (this.state.advance())
|
||||
}
|
||||
/**
|
||||
* Use wrap only when the current range didn't meet any token.
|
||||
*
|
||||
* **Exclusive to inline formatting use.**
|
||||
*/
|
||||
wrap() {
|
||||
let { curRange, delimStr, tagStr } = this.state;
|
||||
// If the current selection is actually an empty cursor, attempt to use
|
||||
// word range if any.
|
||||
if (curRange.empty) {
|
||||
let cursorOffset = curRange.from;
|
||||
curRange = this.view.state.wordAt(curRange.from) ?? curRange;
|
||||
if (cursorOffset == curRange.to) {
|
||||
let shiftAmount = delimStr.length;
|
||||
if (cursorOffset == curRange.from) {
|
||||
shiftAmount += tagStr?.length ?? 0;
|
||||
}
|
||||
this.state.pushSelectionShift(this.state.curSelectionIndex, shiftAmount);
|
||||
}
|
||||
}
|
||||
this.state.pushChange([
|
||||
{ from: curRange.from, insert: delimStr + (tagStr ?? "") },
|
||||
{ from: curRange.to, insert: delimStr }
|
||||
]);
|
||||
}
|
||||
/**
|
||||
* Add corresponding closing delimiter to the token that's currently
|
||||
* inactive. Should be run when the cursor is within or the same range as
|
||||
* the token.
|
||||
*
|
||||
* **Exclusive to inline formatting use.**
|
||||
*
|
||||
* @param token should be in `INACTIVE` status.
|
||||
*/
|
||||
close(token: Token) {
|
||||
let { delimStr, tagStr } = this.state;
|
||||
if (supportTag(token.type) && tagStr) {
|
||||
this.state.pushChange({ from: token.from + token.openLen, insert: tagStr });
|
||||
}
|
||||
this.state.pushChange({ from: token.to, insert: delimStr });
|
||||
}
|
||||
/**
|
||||
* Break the current token into two new tokens if the current range
|
||||
* was within the content range of the token and didn't touch both
|
||||
* delimiters, or narrow it if one of both delimiters was touched, or
|
||||
* behave like `remove()`. Should be run when the current selection
|
||||
* range within the token.
|
||||
*
|
||||
* **Exclusive to inline formatting use.**
|
||||
*/
|
||||
breakApart(token: Token) {
|
||||
let { openRange, tagRange, closeRange } = provideTokenRanges(token),
|
||||
{ curRange, delimStr } = this.state,
|
||||
tagStr = this.doc.sliceString(tagRange.from, tagRange.to);
|
||||
// Remove opening delimiter when the current range touched it. Otherwise,
|
||||
// insert corresponding delimiter at the start offset.
|
||||
if (isTouched(curRange.from, openRange)) {
|
||||
this.state.pushChange(openRange);
|
||||
} else {
|
||||
this.state.pushChange({ from: curRange.from, insert: delimStr });
|
||||
}
|
||||
// Remove closing delimiter when the current range touched it. Otherwise,
|
||||
// insert corresponding delimiter at the end offset.
|
||||
if (isTouched(curRange.to, closeRange)) {
|
||||
this.state.pushChange(closeRange);
|
||||
} else {
|
||||
// This delimiter should be opening. To have the same tag as the original
|
||||
// we need to copy the original tag to the newly created one.
|
||||
this.state.pushChange({ from: curRange.to, insert: delimStr + tagStr });
|
||||
}
|
||||
}
|
||||
/**
|
||||
* Replace the tag of targetted token, or insert as a new if the current
|
||||
* tag was invalid or didn't exist.
|
||||
*
|
||||
* **Exclusive to inline formatting use.**
|
||||
*/
|
||||
changeInlineTag(token: Token) {
|
||||
let { tagRange } = provideTokenRanges(token),
|
||||
{ tagStr, curRange, curSelectionIndex } = this.state;
|
||||
if (tagStr === undefined) { return }
|
||||
if (!token.validTag) { tagRange.to = tagRange.from }
|
||||
this.state.pushChange({ from: tagRange.from, to: tagRange.to, insert: tagStr });
|
||||
if (curRange.empty && curRange.from == tagRange.from) {
|
||||
this.state.pushSelectionShift(curSelectionIndex, tagStr.length);
|
||||
}
|
||||
}
|
||||
/**
|
||||
* Extend formatting range to cover across the tokens that are in the
|
||||
* current token map, and across the current selection. Should be run
|
||||
* with condition the selection touched at least two tokens, or a token
|
||||
* with the selection range exceeds the token range, at least one of its
|
||||
* side.
|
||||
*
|
||||
* **Exclusive to inline formatting use.**
|
||||
*/
|
||||
extend() {
|
||||
let { curRange, delimStr, tagStr, curTokenMap, mappedTokens } = this.state,
|
||||
tokens = this.parser.getTokens(this.state.level),
|
||||
firstTokenIndex = curTokenMap?.[0],
|
||||
lastTokenIndex = curTokenMap?.at(-1),
|
||||
firstToken = mappedTokens[0],
|
||||
lastToken = mappedTokens[mappedTokens.length - 1],
|
||||
isLastTokenAtEdge = false,
|
||||
fusedRange: PlainRange = { from: firstTokenIndex ?? 0, to: (lastTokenIndex ?? 0) + 1 };
|
||||
// If the start offset of the current range touches a token, then
|
||||
// eliminate only its closing delimiter.
|
||||
if (firstToken && firstToken.from <= curRange.from) {
|
||||
let { tagRange, closeRange } = provideTokenRanges(firstToken);
|
||||
fusedRange.from++;
|
||||
if (tagStr !== undefined) {
|
||||
this.state.pushChange(
|
||||
firstToken.validTag
|
||||
? { from: tagRange.from, to: tagRange.to, insert: tagStr }
|
||||
: { from: tagRange.from, insert: tagStr }
|
||||
);
|
||||
}
|
||||
this.state.pushChange(closeRange);
|
||||
} else {
|
||||
this.state.pushChange({ from: curRange.from, insert: delimStr });
|
||||
}
|
||||
if (lastToken && lastToken.to >= curRange.to) {
|
||||
isLastTokenAtEdge = true;
|
||||
fusedRange.to--;
|
||||
}
|
||||
// Tokens that don't touch one of the current range side have to be fused
|
||||
// (i.e. eliminate all of their delimiter).
|
||||
for (let i = fusedRange.from; i < fusedRange.to; i++) {
|
||||
let tokenIndex = curTokenMap?.[i];
|
||||
if (tokenIndex !== undefined) {
|
||||
this.remove(tokens[tokenIndex]);
|
||||
}
|
||||
}
|
||||
// If the end offset of the current range touches a token, then eliminate
|
||||
// only its opening delimiter.
|
||||
if (isLastTokenAtEdge) {
|
||||
let { openRange, tagRange } = provideTokenRanges(lastToken!);
|
||||
this.state.pushChange(openRange);
|
||||
if (lastToken!.validTag) {
|
||||
this.state.pushChange(tagRange);
|
||||
}
|
||||
if (lastToken!.status != TokenStatus.ACTIVE) {
|
||||
this.state.pushChange({ from: curRange.to, insert: delimStr });
|
||||
}
|
||||
} else {
|
||||
this.state.pushChange({ from: curRange.to, insert: delimStr });
|
||||
}
|
||||
}
|
||||
toggleDelim() {
|
||||
let { curRange, delimStr } = this.state,
|
||||
delimLen = delimStr.length,
|
||||
selectedStrWithOverlappedEdge = this.doc.sliceString(curRange.from - delimLen, curRange.to + delimLen),
|
||||
selectedStr = selectedStrWithOverlappedEdge.slice(delimLen, -delimLen);
|
||||
if (isSurroundedByDelimiter(selectedStr, delimStr)) {
|
||||
this.state.pushChange([
|
||||
{ from: curRange.from, to: curRange.from + delimLen },
|
||||
{ from: curRange.to - delimLen, to: curRange.to }
|
||||
]);
|
||||
} else if (isSurroundedByDelimiter(selectedStrWithOverlappedEdge, delimStr)) {
|
||||
this.state.pushChange([
|
||||
{ from: curRange.from - delimLen, to: curRange.from },
|
||||
{ from: curRange.to, to: curRange.to + delimLen }
|
||||
]);
|
||||
} else {
|
||||
this.wrap();
|
||||
}
|
||||
}
|
||||
addBlockTag(block: { start: number, end: number }) {
|
||||
let { doc } = this,
|
||||
{ delimStr } = this.state,
|
||||
blockStart = doc.line(block.start),
|
||||
blockEnd = doc.line(block.end - 1),
|
||||
tagStr = this.state.tagStr ?? "";
|
||||
if (!isBlockStart(doc, blockStart)) { delimStr = "\n" + delimStr }
|
||||
this.state.pushChange({ from: blockStart.from, insert: delimStr + tagStr });
|
||||
if (!isBlockEnd(doc, blockEnd)) {
|
||||
this.state.pushChange({ from: blockEnd.to, insert: "\n" });
|
||||
}
|
||||
}
|
||||
changeBlockTag(token: Token) {
|
||||
let { tagRange } = provideTokenRanges(token),
|
||||
{ tagStr } = this.state;
|
||||
this.state.pushChange({ from: tagRange.from, to: tagRange.to, insert: tagStr });
|
||||
}
|
||||
toggleBlockTag() {
|
||||
let doc = this.doc,
|
||||
blocks = getBlocks(doc, this.state.curRange),
|
||||
{ mappedTokens, tagStr } = this.state;
|
||||
for (let i = 0, j = 0; i < blocks.length; i++) {
|
||||
let block = blocks[i],
|
||||
token: Token | undefined = mappedTokens[j],
|
||||
blockStart = doc.line(block.start),
|
||||
tagRange = token ? getTagRange(token) : undefined;
|
||||
if (!token || blockStart.from < token.from) {
|
||||
this.addBlockTag(block);
|
||||
} else if (token.status != TokenStatus.ACTIVE || blockStart.from > tagRange!.to + 1) {
|
||||
this.addBlockTag(block); j++;
|
||||
} else if (tagStr === undefined) {
|
||||
this.remove(token); j++;
|
||||
} else {
|
||||
this.changeBlockTag(token); j++;
|
||||
}
|
||||
}
|
||||
}
|
||||
/**
|
||||
* Remove formatting based on the token by erasing its delimiter. Should
|
||||
* be run on fused token in `extend()`, or when the cursor is empty and
|
||||
* within the token.
|
||||
*/
|
||||
remove(token: Token) {
|
||||
let { openRange, tagRange, closeRange } = provideTokenRanges(token),
|
||||
removedRanges = [openRange, tagRange, closeRange];
|
||||
if (!token.validTag) { removedRanges.remove(tagRange) }
|
||||
if (token.level == TokenLevel.BLOCK && token.to > tagRange.to) { tagRange.to++ }
|
||||
this.state.pushChange(removedRanges);
|
||||
}
|
||||
removeAll() {
|
||||
do {
|
||||
let { mappedTokens } = this.state;
|
||||
mappedTokens.forEach(token => {
|
||||
this.remove(token);
|
||||
});
|
||||
} while (this.state.advance())
|
||||
}
|
||||
composeChanges() {
|
||||
return this.state.changeSet = ChangeSet.of(this.state.changes, this.state.docLen);
|
||||
}
|
||||
remapSelection() {
|
||||
let { selectionRanges, changeSet, selectionShift: selectionChanges } = this.state;
|
||||
for (let i = 0; i < selectionRanges.length; i++) {
|
||||
let range = selectionRanges[i].map(changeSet),
|
||||
shift = selectionChanges[i]?.shift;
|
||||
if (shift) {
|
||||
range = EditorSelection.range(range.to + shift, range.from + shift);
|
||||
}
|
||||
selectionRanges[i] = range;
|
||||
}
|
||||
return this.state.remappedSelection = EditorSelection.create(selectionRanges);
|
||||
}
|
||||
dispatchToView() {
|
||||
let { changeSet, remappedSelection } = this.state;
|
||||
this.view.dispatch(
|
||||
{ changes: changeSet },
|
||||
{ selection: remappedSelection, sequential: true }
|
||||
);
|
||||
this.clearState();
|
||||
}
|
||||
}
|
||||
71
src/editor-mode/formatting/FormatterState.ts
Normal file
71
src/editor-mode/formatting/FormatterState.ts
Normal file
|
|
@ -0,0 +1,71 @@
|
|||
import { ChangeSet, ChangeSpec, EditorSelection, SelectionRange, Text } from "@codemirror/state";
|
||||
import { Format, TokenLevel } from "src/enums";
|
||||
import { BlockFormat, InlineFormat, PluginSettings, TokenGroup } from "src/types";
|
||||
import { SelectionObserver } from "src/editor-mode/observer";
|
||||
import { isInlineFormat } from "src/format-configs/utils";
|
||||
import { trimSelection } from "src/editor-mode/selection";
|
||||
import { BlockRules, InlineRules } from "src/format-configs";
|
||||
|
||||
export class FormatterState {
|
||||
docLen: number;
|
||||
tokens: TokenGroup;
|
||||
selectionRanges: SelectionRange[];
|
||||
curSelectionIndex: number = 0;
|
||||
tokenMaps: (number[] | undefined)[];
|
||||
curTokenMap: number[] | undefined;
|
||||
level: TokenLevel;
|
||||
type: Format;
|
||||
delimStr: string;
|
||||
tagStr: string | undefined;
|
||||
precise: boolean;
|
||||
changes: ChangeSpec[] = [];
|
||||
selectionShift: Partial<Record<number, { shift: number }>> = {};
|
||||
changeSet: ChangeSet;
|
||||
remappedSelection: EditorSelection;
|
||||
constructor(type: Format, doc: Text, selectionObserver: SelectionObserver, settings: PluginSettings, tagStr?: string) {
|
||||
this.type = type;
|
||||
this.docLen = doc.length;
|
||||
this.level = isInlineFormat(type) ? TokenLevel.INLINE : TokenLevel.BLOCK;
|
||||
this.tagStr = tagStr;
|
||||
this.precise = settings.tidyFormatting;
|
||||
this.tokens = selectionObserver.parser.getTokens(this.level);
|
||||
this.tokenMaps = selectionObserver.pickMaps(type);
|
||||
this.curTokenMap = this.tokenMaps[this.curSelectionIndex];
|
||||
if (this.precise) {
|
||||
let trimmedSelection = trimSelection(selectionObserver.selection, doc);
|
||||
this.selectionRanges = trimmedSelection.ranges.map(range => range);
|
||||
} else {
|
||||
this.selectionRanges = selectionObserver.selection.ranges.map(range => range);
|
||||
}
|
||||
if (tagStr && this.level == TokenLevel.INLINE) {
|
||||
this.tagStr = "{" + tagStr + "}";
|
||||
} else if (this.level == TokenLevel.BLOCK) {
|
||||
this.tagStr += "\n";
|
||||
}
|
||||
let { char, length: delimLen } = this.level == TokenLevel.INLINE
|
||||
? InlineRules[type as InlineFormat]
|
||||
: BlockRules[type as BlockFormat];
|
||||
this.delimStr = char.padEnd(delimLen, char);
|
||||
}
|
||||
get curRange() {
|
||||
return this.selectionRanges[this.curSelectionIndex];
|
||||
}
|
||||
get mappedTokens(): TokenGroup {
|
||||
if (!this.curTokenMap) { return [] }
|
||||
return this.curTokenMap.map(index => this.tokens[index]);
|
||||
}
|
||||
advance() {
|
||||
this.curSelectionIndex++
|
||||
if (this.curSelectionIndex >= this.selectionRanges.length) {
|
||||
return false;
|
||||
}
|
||||
this.curTokenMap = this.tokenMaps[this.curSelectionIndex];
|
||||
return true;
|
||||
}
|
||||
pushChange(spec: ChangeSpec) {
|
||||
this.changes.push(spec);
|
||||
}
|
||||
pushSelectionShift(index: number, shift: number) {
|
||||
this.selectionShift[index] = { shift };
|
||||
}
|
||||
}
|
||||
1
src/editor-mode/formatting/formatting-utils/index.ts
Normal file
1
src/editor-mode/formatting/formatting-utils/index.ts
Normal file
|
|
@ -0,0 +1 @@
|
|||
export * from "./isSurroundedByDelimiter";
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
export function isSurroundedByDelimiter(str: string, delimStr: string) {
|
||||
return str.startsWith(delimStr) && str.endsWith(delimStr);
|
||||
}
|
||||
2
src/editor-mode/formatting/index.ts
Normal file
2
src/editor-mode/formatting/index.ts
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
export * from "./Formatter";
|
||||
export * from "./FormatterState";
|
||||
Loading…
Reference in a new issue