build: remake of decoration builder to let the decorations have more behavior

This commit is contained in:
kotaindah55 2025-03-04 17:05:32 +02:00
parent 564e337c30
commit bf6ec3d94a
32 changed files with 729 additions and 456 deletions

View file

@ -1,113 +1,225 @@
import { EditorState, Range } from "@codemirror/state";
import { Decoration, DecorationSet, ViewUpdate } from "@codemirror/view";
import { ChangeDesc, ChangeSet, EditorState, Range, RangeSet, Text, Transaction } from "@codemirror/state";
import { Decoration, EditorView, ViewUpdate } from "@codemirror/view";
import { Parser } from "src/editor-mode/parser";
import { Format, TokenRole, TokenStatus } from "src/enums";
import { createHlDeco } from "src/editor-mode/decorator/utils";
import { AlignDeco, AlignMarkDeco, ColorTagDeco, ContentDeco, DelimDeco, RevealedSpoiler } from "src/editor-mode/decorator/decorations";
import { AlignFormat, CharPos, MainFormat } from "src/types";
import { Format, MarkdownViewMode, TokenStatus } from "src/enums";
import { REVEALED_SPOILER_DECO } from "src/editor-mode/decorator/decorations";
import { BlockFormat, PlainRange, InlineFormat, Token, TokenGroup, TokenDecoration, PluginSettings } from "src/types";
import { ColorButton } from "src/editor-mode/decorator/widgets";
import { BlockRules, InlineRules } from "src/shared-configs";
import { DecorationHolder, DelimOmitter, LineBreakReplacer, TokensCatcher } from "src/editor-mode/decorator/builder";
import { createInlineDecoRange, createLineDecoRange, getLineAt, getTagRange, isEditorModeChanged, iterLine, iterTokenGroup, sliceStrFromLine } from "src/editor-mode/decorator/utils";
import { trimTag } from "src/utils";
import { SelectionObserver } from "src/editor-mode/observer";
import { editorLivePreviewField } from "obsidian";
import { refresherAnnot } from "src/editor-mode/annotations";
/** Decoration builder (excludes omitted delimiter), should be attached to `ExtendedSyntax` */
export class DecorationBuilder {
/** Attached `Parser` for obtaining tokens quickly */
parser: Parser;
constructor() {
readonly parser: Parser;
readonly omitter: DelimOmitter;
readonly catcher: TokensCatcher;
readonly lineBreakReplacer: LineBreakReplacer;
readonly selectionObserver: SelectionObserver;
readonly holder: DecorationHolder;
readonly settings: PluginSettings;
readonly indexCaches = {
linePos: { number: 1 }, // 1-based
inlineToken: { number: 0 }, // 0-based
blockToken: { number: 0 } // 0-based
}
constructor(parser: Parser, selectionObserver: SelectionObserver) {
this.parser = parser;
this.selectionObserver = selectionObserver;
this.settings = parser.settings;
this.omitter = new DelimOmitter(this.settings, selectionObserver);
this.catcher = new TokensCatcher();
this.lineBreakReplacer = new LineBreakReplacer(parser);
this.holder = new DecorationHolder();
}
/**
* Produces _necessary_ decoration ranges from tokens provided by
* `Builder.parser`. Therefore, it doesn't reproduce new ranges
* from reused tokens. Doesn't include supplementary and omitted
* delimiter.
* Main decorations hold basic formatting style of the tokens.
*
* Intended to build non-height-altering decorations. So, it doesn't
* include line breaks and fenced div opening omitter. (It runs only in
* the view update)
*/
build(state: EditorState) {
let doc = state.doc,
tokens = this.parser.tokens,
mainDecoRanges: Range<Decoration>[] = [],
outerDecoRanges: Range<Decoration>[] = [],
delimiterRanges: Range<Decoration>[] = [],
{ startToken, endToken } = this.parser.lastParsed;
for (
let i = startToken, token = tokens[i];
i < endToken;
token = tokens[++i]
) {
if (token.status != TokenStatus.ACTIVE) { continue }
if (token.role == TokenRole.OPEN) {
if (token.type == Format.HIGHLIGHT) {
let color = "",
open = token,
close = tokens[i + token.size - 1],
mayBeColorTag = tokens[i + 2],
hasColorTag = mayBeColorTag?.type == Format.COLOR_TAG;
// gets color tag
if (hasColorTag) {
color = doc.sliceString(mayBeColorTag.from + 1, mayBeColorTag.to - 1);
i++;
}
let hlDeco = createHlDeco(color, {
open: { from: open.from, to: open.to } as CharPos,
close: { from: close.from, to: close.to } as CharPos
});
// highlight should be pushed in outer decorations
outerDecoRanges.push(hlDeco.range(open.from, close.to));
if (hasColorTag) {
delimiterRanges.push(ColorTagDeco.range(mayBeColorTag.from, mayBeColorTag.to));
}
} else {
let content = tokens[++i];
delimiterRanges.push(DelimDeco[token.type as MainFormat].range(token.from, token.to));
if (token.type == Format.SPOILER) { // spoiler should be pushed in outer decorations
outerDecoRanges.push(ContentDeco[token.type as MainFormat].range(content.from, content.to));
} else {
mainDecoRanges.push(ContentDeco[token.type as MainFormat].range(content.from, content.to));
}
}
} else if (token.role == TokenRole.CLOSE && token.type != Format.HIGHLIGHT) {
delimiterRanges.push(DelimDeco[token.type as MainFormat].range(token.from, token.to));
} else if (token.role == TokenRole.BLOCK_TAG) {
delimiterRanges.push(AlignMarkDeco[token.type as AlignFormat].range(token.from, token.to));
mainDecoRanges.push(AlignDeco[token.type as AlignFormat].range(token.from, token.from));
buildMain(view: EditorView, state: EditorState) {
this.buildInline(state, view.visibleRanges);
this.buildBlock(state, view.visibleRanges);
}
/**
* Supplementary decorations consist omitted delimiter of inline tokens,
* color buttons for the highlight, and revealed spoiler when touched the
* cursor or selection.
*
* Intended to build non-height-altering decorations. So, it doesn't
* include line breaks and fenced div opening omitter. (It runs only in
* the view update)
*/
buildSupplementary(isLivePreview: boolean) {
if (isLivePreview) {
this.omitInlineDelim(this.catcher.activeTokens);
this.createColorBtnWidgets(this.catcher.hlTokens);
this.revealSpoiler(this.catcher.spoilerTokens);
} else {
this.holder.colorBtnSet = this.holder.revealedSpoilerSet = RangeSet.empty;
}
}
/**
* Runs once on editor intialization, should be inside the view update
* (i.e. the ViewPlugin update).
*/
onViewInit(view: EditorView) {
let state = view.state,
isLivePreview = state.field(editorLivePreviewField);
this.buildMain(view, state);
this.buildSupplementary(isLivePreview);
}
onViewUpdate(update: ViewUpdate) {
let state = update.state,
view = update.view,
isLivePreview = state.field(editorLivePreviewField);
if (this.parser.isReparsing || this.parser.isInitializing || update.viewportMoved) {
this.buildMain(view, state);
}
if (this.selectionObserver.isObserving || update.viewportMoved) {
this.buildSupplementary(isLivePreview);
}
}
/**
* Runs once on editor intialization, should be inside the state update
* (i.e. the StateField update).
*/
onStateInit(state: EditorState) {
let isLivePreview = state.field(editorLivePreviewField);
if (isLivePreview) {
this.omitFencedDivOpening();
}
this.replaceLineBreaks(state.doc);
}
onStateUpdate(transaction: Transaction) {
let state = transaction.state,
isLivePreview = state.field(editorLivePreviewField),
isRefreshed = transaction.annotation(refresherAnnot),
isModeChanged = isEditorModeChanged(state, transaction.startState);
if (this.parser.isReparsing || this.parser.isInitializing) {
this.replaceLineBreaks(transaction.newDoc, transaction.changes);
}
if (isModeChanged || isRefreshed) {
this.selectionObserver.restartObserver(transaction.newSelection, transaction.docChanged);
this.holder.blockOmittedSet = RangeSet.empty;
}
if (!isLivePreview) {
this.removeOmitter();
} else if (this.selectionObserver.isObserving || isModeChanged) {
this.omitFencedDivOpening(transaction.changes);
}
}
buildInline(state: EditorState, visibleRanges: readonly PlainRange[]) {
let inlineDecoRanges: Range<TokenDecoration>[] = [],
activeTokens: TokenGroup = [],
hlTokens: TokenGroup = [],
spoilerTokens: TokenGroup = [];
iterTokenGroup({
tokens: this.parser.inlineTokens,
ranges: visibleRanges,
indexCache: this.indexCaches.inlineToken,
callback: (token) => {
if (token.status != TokenStatus.ACTIVE) { return }
if (token.type == Format.HIGHLIGHT) { hlTokens.push(token) }
if (token.type == Format.SPOILER) { spoilerTokens.push(token) }
inlineDecoRanges.push(this.transformInlineToken(token, state.doc));
activeTokens.push(token);
},
});
this.catcher.catch(activeTokens, hlTokens, spoilerTokens);
return this.holder.inlineSet = Decoration.set(inlineDecoRanges);
}
buildBlock(state: EditorState, visibleRanges: readonly PlainRange[]) {
let lineDecoRanges: Range<TokenDecoration>[] = [];
iterTokenGroup({
tokens: this.parser.blockTokens,
ranges: visibleRanges,
indexCache: this.indexCaches.blockToken,
callback: (token) => {
if (token.status != TokenStatus.ACTIVE) { return }
lineDecoRanges.push(...this.transformBlockToken(token, state.doc));
},
});
return this.holder.blockSet = Decoration.set(lineDecoRanges);
}
transformInlineToken(token: Token, doc: Text) {
let cls = "cm-" + InlineRules[token.type as InlineFormat].class;
if (token.tagLen) {
let line = getLineAt(doc, token.from, this.indexCaches.linePos),
tagRange = getTagRange(token),
tagStr = sliceStrFromLine(line, tagRange.from + 1, tagRange.to - 1);
if (token.type == Format.CUSTOM_SPAN) {
tagStr = trimTag(tagStr);
cls += " " + tagStr;
} else {
cls += " " + cls + "-" + tagStr;
}
}
return { mainDecoRanges, outerDecoRanges, delimiterRanges };
return createInlineDecoRange(token, cls);
}
/**
* Filters out some ranges from old set that needs to be replaced by the new one,
* resulting new `DecorationSet`. Doesn't intended to filtering supplementary
* and omitted delimiter set.
*/
updateSet(update: ViewUpdate, decoSet: DecorationSet, ranges: Range<Decoration>[]) {
let { startToken: start, endToken: end } = this.parser.lastParsed,
docLen = update.state.doc.length,
filterFrom = (this.parser.tokens[start - 1]?.to ?? 0),
filterTo = (this.parser.tokens[end]?.from ?? docLen);
return decoSet.map(update.changes).update({
add: ranges, filterFrom, filterTo,
filter(from, to, val) {
return (to == filterFrom || from == filterTo) && ((val.spec.type as Format) <= Format.ALIGN_JUSTIFY && from != 0 || to != from);
}
transformBlockToken(token: Token, doc: Text) {
let baseCls = "cm-" + BlockRules[token.type as BlockFormat].class,
openLine = getLineAt(doc, token.from, this.indexCaches.linePos),
tagRange = getTagRange(token),
tagStr = trimTag(sliceStrFromLine(openLine, tagRange.from, tagRange.to)),
openDelimCls = baseCls + " cm-fenced-div-start",
contentCls = baseCls + " " + tagStr,
ranges: Range<TokenDecoration>[] = [createLineDecoRange(token, openDelimCls, openLine)];
iterLine({
doc, fromLn: openLine.number + 1,
callback(line) {
if (line.to > token.to) { return false }
if (line.to == token.to && !line.text.trimEnd()) { return false }
let decoRange = createLineDecoRange(token, contentCls, line);
ranges.push(decoRange);
},
});
return ranges;
}
/** Produces supplementary decorations (color button and reveled text for spoiler). */
// TODO: Make it incrementally
getSupplemental(state: EditorState, outerDecoSet: DecorationSet) {
let decoRanges: Range<Decoration>[] = [],
selectRanges = state.selection.ranges,
i = 0;
outerDecoSet.between(0, state.doc.length, (from, to, val) => {
while (selectRanges[i] && selectRanges[i].to < from) { i++ }
if (!selectRanges[i]) { return false }
if (selectRanges[i].from > to) { return }
let type = val.spec.type as Format.HIGHLIGHT | Format.SPOILER;
if (type == Format.HIGHLIGHT && this.parser.settings.colorButton) {
let color = val.spec.color as string,
open = (val.spec.open as CharPos),
close = (val.spec.close as CharPos);
decoRanges.push(ColorButton.of(color, open, close));
} else if (type == Format.SPOILER) {
decoRanges.push(RevealedSpoiler.range(from, to));
createColorBtnWidgets(hlTokens: TokenGroup) {
if (!this.settings.colorButton) {
return this.holder.colorBtnSet = RangeSet.empty;
}
let btnWidgets: Range<Decoration>[] = [];
for (let i = 0; i < hlTokens.length; i++) {
let token = hlTokens[i];
if (this.selectionObserver.touchSelection(token.from, token.to)) {
btnWidgets.push(ColorButton.of(token));
}
});
return Decoration.set(decoRanges);
}
return this.holder.colorBtnSet = Decoration.set(btnWidgets);
}
revealSpoiler(spoilerTokens: TokenGroup) {
let revealedRanges: Range<Decoration>[] = [];
for (let i = 0; i < spoilerTokens.length; i++) {
let token = spoilerTokens[i];
if (this.selectionObserver.touchSelection(token.from, token.to)) {
revealedRanges.push(REVEALED_SPOILER_DECO.range(token.from, token.to));
}
}
return this.holder.revealedSpoilerSet = Decoration.set(revealedRanges);
}
omitInlineDelim(activeTokens: TokenGroup) {
return this.holder.inlineOmittedSet = this.omitter.omitInline(activeTokens);
}
/** Executed only in the state update. */
replaceLineBreaks(doc: Text, changes?: ChangeSet) {
return this.holder.lineBreaksSet = this.lineBreakReplacer.replace(doc, changes);
}
/** Executed only in the state update. */
omitFencedDivOpening(changes?: ChangeDesc) {
let isOmitted = !(this.settings.alwaysShowFencedDivTag & MarkdownViewMode.EDITOR_MODE);
if (!isOmitted) {
return this.holder.blockOmittedSet = RangeSet.empty;
}
return this.holder.blockOmittedSet = this.omitter.omitBlock(this.holder.blockOmittedSet, changes);
}
removeOmitter() {
this.holder.inlineOmittedSet = this.holder.blockOmittedSet = RangeSet.empty;
}
}

View file

@ -0,0 +1,13 @@
import { RangeSet } from "@codemirror/state";
import { DecorationSet } from "@codemirror/view";
export class DecorationHolder {
inlineSet: DecorationSet = RangeSet.empty;
blockSet: DecorationSet = RangeSet.empty;
inlineOmittedSet: DecorationSet = RangeSet.empty;
blockOmittedSet: DecorationSet = RangeSet.empty;
colorBtnSet: DecorationSet = RangeSet.empty;
revealedSpoilerSet: DecorationSet = RangeSet.empty;
lineBreaksSet: DecorationSet = RangeSet.empty;
constructor() {}
}

View file

@ -0,0 +1,73 @@
import { ChangeDesc, Range } from "@codemirror/state";
import { Decoration, DecorationSet } from "@codemirror/view";
import { DisplayBehaviour, Format, TokenLevel, TokenStatus } from "src/enums";
import { PluginSettings, TokenGroup } from "src/types";
import { HiddenWidget } from "src/editor-mode/decorator/widgets";
import { SelectionObserver } from "src/editor-mode/observer";
export class DelimOmitter {
selectionObserver: SelectionObserver;
settings: PluginSettings;
constructor(settings: PluginSettings, selectionObserver: SelectionObserver) {
this.settings = settings;
this.selectionObserver = selectionObserver;
}
omitBlock(omittedSet?: DecorationSet, changes?: ChangeDesc) {
let omittedRanges: Range<Decoration>[] = [],
filterRegion = this.selectionObserver.filterRegions[TokenLevel.BLOCK];
this.selectionObserver.iterateChangedRegion(TokenLevel.BLOCK, (token, index, tokens, inSelection) => {
if (inSelection || token.status != TokenStatus.ACTIVE || !token.validTag) { return }
let openFrom = token.from,
openTo = openFrom + token.openLen + token.tagLen;
if (token.to > openTo) { openTo++ }
omittedRanges.push(HiddenWidget.of(openFrom, openTo, token, true));
});
if (!omittedSet?.size) {
omittedSet = Decoration.set(omittedRanges);
} else {
if (changes) {
omittedSet = omittedSet.map(changes);
}
for (let i = 0; i < filterRegion.length; i++) {
let filterRange = filterRegion[i];
omittedSet = omittedSet.update({
filterFrom: filterRange.from,
filterTo: filterRange.to,
filter: () => false,
});
}
omittedSet = omittedSet.update({ add: omittedRanges });
}
return omittedSet;
}
omitInline(activeTokens: TokenGroup) {
let alwaysShowHlTag = this.settings.hlTagDisplayBehaviour & DisplayBehaviour.ALWAYS,
alwaysShowSpanTag = this.settings.spanTagDisplayBehaviour & DisplayBehaviour.ALWAYS,
showHlTagIfTouched = this.settings.hlTagDisplayBehaviour & DisplayBehaviour.TAG_TOUCHED,
showSpanTagIfTouched = this.settings.spanTagDisplayBehaviour & DisplayBehaviour.TAG_TOUCHED;
let omittedRanges: Range<Decoration>[] = [];
for (let i = 0; i < activeTokens.length; i++) {
let token = activeTokens[i],
openFrom = token.from,
openTo = openFrom + token.openLen,
tagTo = openTo + token.tagLen;
if (this.selectionObserver.touchSelection(token.from, token.to)) {
if (
token.validTag && !this.selectionObserver.touchSelection(openTo, tagTo) &&
(token.type == Format.HIGHLIGHT && showHlTagIfTouched || token.type == Format.CUSTOM_SPAN && showSpanTagIfTouched)
) {
omittedRanges.push(HiddenWidget.of(openTo, tagTo, token));
}
} else {
if (token.type == Format.HIGHLIGHT && !alwaysShowHlTag || token.type == Format.CUSTOM_SPAN && !alwaysShowSpanTag) {
openTo = tagTo;
}
omittedRanges.push(HiddenWidget.of(openFrom, openTo, token));
if (token.closeLen) {
omittedRanges.push(HiddenWidget.of(token.to - token.closeLen, token.to, token));
}
}
}
return Decoration.set(omittedRanges);
}
}

View file

@ -1,85 +0,0 @@
import { Decoration, DecorationSet, EditorView, PluginValue, ViewUpdate } from "@codemirror/view";
import { Parser } from "src/editor-mode/parser";
import { DecorationBuilder, RangeOmitter } from "src/editor-mode/decorator/builder";
import { parserField } from "src/editor-mode/state-fields";
import { EditorState, RangeSet } from "@codemirror/state";
import { editorLivePreviewField } from "obsidian";
export class ExtendedSyntax implements PluginValue {
/** Insertion, superscript, and subscript. */
mainDecoSet: DecorationSet;
/**
* Spoiler and highlight, positioned at upper DOM hierarchy and
* not split by other decorations. Useful for making them appear
* continous in the editor, especially when overlap with others.
*/
outerDecoSet: DecorationSet;
/** Any type of tag and delimiter */
delimiterSet: DecorationSet;
/**
* Any type of tag and delimiter omitted due to touching cursor or selection.
* Used only when in live preview mode.
*/
omittedSet: DecorationSet;
/**
* Color button for highlight and revealed text for spoiler,
* only when selection or cursor are touching them.
*/
supplementalSet: DecorationSet;
combinedSet: DecorationSet;
parser: Parser;
builder: DecorationBuilder;
omitter: RangeOmitter;
noOmit = true;
constructor(view: EditorView) {
this.builder = new DecorationBuilder();
this.omitter = new RangeOmitter();
this.parser = view.state.field(parserField);
this.builder.parser = this.parser;
this.init(view.state);
}
init(state: EditorState) {
let { mainDecoRanges, outerDecoRanges, delimiterRanges } = this.builder.build(state);
this.mainDecoSet = Decoration.set(mainDecoRanges);
this.outerDecoSet = Decoration.set(outerDecoRanges);
this.delimiterSet = Decoration.set(delimiterRanges);
this.supplementalSet = this.builder.getSupplemental(state, this.outerDecoSet);
if (state.field(editorLivePreviewField)) {
this.noOmit = false;
this.omittedSet = this.omitter.omit(state, this.delimiterSet);
} else {
this.omittedSet = RangeSet.empty;
}
this.combine();
}
update(update: ViewUpdate) {
if (update.docChanged || this.parser.isReparsing) {
let { mainDecoRanges, outerDecoRanges, delimiterRanges } = this.builder.build(update.state);
this.mainDecoSet = this.builder.updateSet(update, this.mainDecoSet, mainDecoRanges);
this.outerDecoSet = this.builder.updateSet(update, this.outerDecoSet, outerDecoRanges);
this.delimiterSet = this.builder.updateSet(update, this.delimiterSet, delimiterRanges);
}
if (update.docChanged || update.selectionSet || this.parser.isReparsing) {
this.supplementalSet = this.builder.getSupplemental(update.state, this.outerDecoSet);
}
if (update.state.field(editorLivePreviewField)) {
if (update.docChanged || update.selectionSet || this.parser.isReparsing || this.noOmit) {
this.omittedSet = this.omitter.omit(update.state, this.delimiterSet);
}
this.noOmit = false;
} else {
this.omittedSet = RangeSet.empty;
this.noOmit = true;
}
this.combine();
this.parser.isReparsing = false;
}
combine() {
this.combinedSet = RangeSet.join([
this.mainDecoSet,
this.delimiterSet,
this.omittedSet,
this.supplementalSet
]);
}
}

View file

@ -0,0 +1,66 @@
import { RangeSet, Range, Text, ChangeSet } from "@codemirror/state";
import { Decoration } from "@codemirror/view";
import { Parser } from "src/editor-mode/parser";
import { IndexCache, RangeSetUpdate, TokenGroup } from "src/types";
import { getLineAt, iterLine } from "src/editor-mode/decorator/utils";
import { LineBreak } from "src/editor-mode/decorator/widgets";
import { TokenLevel, TokenStatus } from "src/enums";
export class LineBreakReplacer {
private linePosCache: IndexCache = { number: 1 };
parser: Parser;
lineBreakSet: RangeSet<Decoration> = RangeSet.empty;
constructor(parser: Parser) {
this.parser = parser;
}
replace(doc: Text, changes?: ChangeSet) {
let reparsedRange = this.parser.reparsedRanges[TokenLevel.BLOCK],
blockTokens = this.parser.blockTokens,
updateSpec: RangeSetUpdate<Decoration> = {
add: this.produceWidgetRanges(doc)
};
if (!blockTokens.length) {
return this.lineBreakSet = RangeSet.empty;
}
if (reparsedRange.from != reparsedRange.initTo || reparsedRange.from != reparsedRange.changedTo) {
updateSpec.filterFrom = Math.min(blockTokens[reparsedRange.from]?.from ?? this.parser.lastStreamPoint.from, this.parser.lastStreamPoint.from);
updateSpec.filterTo = this.parser.lastStreamPoint.to;
updateSpec.filter = () => false;
}
if (changes) {
this.lineBreakSet = this.lineBreakSet.map(changes);
}
return this.lineBreakSet = this.lineBreakSet.update(updateSpec);
}
private getReparsedBlockTokens(): TokenGroup {
let range = this.parser.reparsedRanges[TokenLevel.BLOCK];
return this.parser.blockTokens.slice(range.from, range.changedTo);
}
private produceWidgetRanges(doc: Text) {
let tokens = this.getReparsedBlockTokens(),
ranges: Range<Decoration>[] = [];
if (!tokens.length) { return ranges }
let startLine = getLineAt(doc, tokens[0].from, this.linePosCache),
tokenIndex = 0;
iterLine({
doc,
fromLn: startLine.number,
callback: (line) => {
let curToken = tokens[tokenIndex];
if (!curToken) { return false }
if (curToken.status != TokenStatus.ACTIVE) { tokenIndex++; return }
if (
line.from == curToken.from ||
line.from - 1 == curToken.from + curToken.openLen + curToken.tagLen
) { return }
if (
line.from >= curToken.to ||
line.to < curToken.from ||
line.to == curToken.to && curToken.closedByBlankLine
) { tokenIndex++; return }
ranges.push(LineBreak.of(line.from));
},
});
return ranges;
}
}

View file

@ -1,52 +0,0 @@
import { EditorState, Range } from "@codemirror/state";
import { Decoration, DecorationSet } from "@codemirror/view";
import { HiddenWidget } from "src/editor-mode/decorator/widgets";
import { Format } from "src/enums";
import { MainFormat } from "src/types";
/**
* Omits delimiters that touch the cursor or selection,
* so those don't appear in the editor.
*/
export class RangeOmitter {
constructor() {};
/** Should be run in preview mode only */
omit(state: EditorState, delimiterSet: DecorationSet) {
let omittedRanges: Range<Decoration>[] = [],
selectRanges = state.selection.ranges, i = 0,
openOffset: { [T in MainFormat]: null | number } = {
[Format.INSERTION]: null,
[Format.SPOILER]: null,
[Format.SUPERSCRIPT]: null,
[Format.SUBSCRIPT]: null,
[Format.HIGHLIGHT]: null
};
delimiterSet.between(0, state.doc.length, (from, to, val) => {
let type = val.spec.type as Format;
if (type == Format.COLOR_TAG || type <= Format.ALIGN_JUSTIFY) {
while (i + 1 < selectRanges.length && selectRanges[i].to < from) { i++ }
if (selectRanges[i].from > to || selectRanges[i].to < from) {
omittedRanges.push(HiddenWidget.of(from, to, val));
return;
}
}
else {
if (openOffset[type as MainFormat] === null) {
openOffset[type as MainFormat] = from;
} else {
let length = to - from,
openFrom = openOffset[type as MainFormat]!;
while (i + 1 < selectRanges.length && selectRanges[i].to < openFrom) { i++ }
if (selectRanges[i].from > to || selectRanges[i].to < openFrom) {
omittedRanges.push(
HiddenWidget.of(openFrom, openFrom + length, val),
HiddenWidget.of(from, to, val)
);
}
openOffset[type as MainFormat] = null;
}
}
});
return Decoration.set(omittedRanges, true);
}
}

View file

@ -0,0 +1,18 @@
import { TokenGroup } from "src/types";
export class TokensCatcher {
activeTokens: TokenGroup = [];
hlTokens: TokenGroup = [];
spoilerTokens: TokenGroup = [];
constructor () {}
catch(activeTokens: TokenGroup, hlTokens: TokenGroup, spoilerTokens: TokenGroup) {
this.activeTokens = activeTokens;
this.hlTokens = hlTokens;
this.spoilerTokens = spoilerTokens;
}
empty() {
this.activeTokens = [];
this.hlTokens = [];
this.spoilerTokens = [];
}
}

View file

@ -1,3 +1,5 @@
export * from "./DecorationBuilder";
export * from "./RangeOmitter";
export * from "./ExtendedSyntax";
export * from "./DelimOmitter";
export * from "./TokensCatcher";
export * from "./LineBreakReplacer";
export * from "./DecorationHolder";

View file

@ -1,111 +1,5 @@
import { Decoration } from "@codemirror/view";
import { Format } from "src/enums";
export const AlignDeco = {
[Format.ALIGN_LEFT]: Decoration.line({
class: "cm-align-left",
type: Format.ALIGN_LEFT
}),
[Format.ALIGN_RIGHT]: Decoration.line({
class: "cm-align-right",
type: Format.ALIGN_RIGHT
}),
[Format.ALIGN_CENTER]: Decoration.line({
class: "cm-align-center",
type: Format.ALIGN_CENTER
}),
[Format.ALIGN_JUSTIFY]: Decoration.line({
class: "cm-align-justify",
type: Format.ALIGN_JUSTIFY
})
};
export const AlignMarkDeco = {
[Format.ALIGN_LEFT]: Decoration.mark({
class: "cm-align-mark-left",
type: Format.ALIGN_LEFT,
omitted: true
}),
[Format.ALIGN_RIGHT]: Decoration.mark({
class: "cm-align-mark-right",
type: Format.ALIGN_RIGHT,
omitted: true
}),
[Format.ALIGN_CENTER]: Decoration.mark({
class: "cm-align-mark-center",
type: Format.ALIGN_CENTER,
omitted: true
}),
[Format.ALIGN_JUSTIFY]: Decoration.mark({
class: "cm-align-mark-justify",
type: Format.ALIGN_JUSTIFY,
omitted: true
}),
};
export const DelimDeco = {
[Format.INSERTION]: Decoration.mark({
class: "cm-delim cm-ins",
type: Format.INSERTION,
omitted: true
}),
[Format.SPOILER]: Decoration.mark({
class: "cm-delim cm-spoiler",
type: Format.SPOILER,
omitted: true
}),
[Format.SUPERSCRIPT]: Decoration.mark({
class: "cm-delim cm-sup",
type: Format.SUPERSCRIPT,
omitted: true
}),
[Format.SUBSCRIPT]: Decoration.mark({
class: "cm-delim cm-sub",
type: Format.SUBSCRIPT,
omitted: true
}),
[Format.HIGHLIGHT]: Decoration.mark({
class: "cm-highlight",
type: Format.HIGHLIGHT,
omitted: true
})
};
export const ContentDeco = {
[Format.INSERTION]: Decoration.mark({
class: "cm-ins",
type: Format.INSERTION,
inclusive: true
}),
[Format.SPOILER]: Decoration.mark({
class: "cm-spoiler",
type: Format.SPOILER,
inclusive: true
}),
[Format.SUPERSCRIPT]: Decoration.mark({
class: "cm-sup",
type: Format.SUPERSCRIPT,
inclusive: true
}),
[Format.SUBSCRIPT]: Decoration.mark({
class: "cm-sub",
type: Format.SUBSCRIPT,
inclusive: true
}),
[Format.HIGHLIGHT]: Decoration.mark({
class: "cm-highlight",
type: Format.HIGHLIGHT,
inclusive: true
})
};
export const ColorTagDeco = Decoration.mark({
class: "cm-color-tag",
type: Format.COLOR_TAG,
omitted: true
});
export const RevealedSpoiler = Decoration.mark({
export const REVEALED_SPOILER_DECO = Decoration.mark({
class: "cm-spoiler-revealed",
type: Format.SPOILER
});

View file

@ -1,12 +1,12 @@
import { Decoration } from "@codemirror/view";
import { Format } from "src/enums";
export function createHlDeco(color: string, data?: { [P in string]: unknown }) {
export function createHlDeco(color: string, data?: Record<string, unknown>) {
let spec: Parameters<typeof Decoration.mark>[0] = {
class: "cm-custom-highlight cm-custom-highlight-" + (color || "default"),
type: Format.HIGHLIGHT,
color,
inclusive: true
inclusive: false
};
if (data) {
for (let prop in data) { spec[prop] = data[prop] }

View file

@ -0,0 +1,8 @@
import { Decoration } from "@codemirror/view";
import { Token, TokenDecoration } from "src/types";
export function createInlineDecoRange(token: Token, cls: string) {
return (Decoration
.mark({ class: cls, token }) as TokenDecoration)
.range(token.from, token.to);
}

View file

@ -0,0 +1,9 @@
import { Line } from "@codemirror/state";
import { Decoration } from "@codemirror/view";
import { Token, TokenDecoration } from "src/types";
export function createLineDecoRange(token: Token, cls: string, line: Line) {
return (Decoration
.line({ class: cls, token }) as TokenDecoration)
.range(line.from);
}

View file

@ -0,0 +1,18 @@
import { IndexCache } from "src/types";
import { Text } from "@codemirror/state"
export function getLineAt(doc: Text, offset: number, linePosCache: IndexCache) {
if (linePosCache.number > doc.lines) { linePosCache.number = doc.lines }
let curLine = doc.line(linePosCache.number);
if (offset < curLine.from) {
do {
curLine = doc.line(curLine.number - 1);
} while (offset < curLine.from)
} else if (offset > curLine.to) {
do {
curLine = doc.line(curLine.number + 1);
} while (offset > curLine.to)
}
linePosCache.number = curLine.number;
return curLine;
}

View file

@ -0,0 +1,7 @@
import { Token } from "src/types";
export function getTagRange(token: Token) {
let from = token.from + token.openLen,
to = from + token.tagLen;
return { from, to };
}

View file

@ -1 +1,10 @@
export * from "./createHlDeco";
export * from "./createHlDeco";
export * from "./createInlineDecoRange";
export * from "./getLineAt";
export * from "./sliceStrFromLine";
export * from "./getTagRange";
export * from "./createLineDecoRange";
export * from "./iterLine";
export * from "./iterTokenGroup";
export * from "./moveTokenIndexCache";
export * from "./isEditorModeChanged";

View file

@ -0,0 +1,8 @@
import { EditorState } from "@codemirror/state";
import { editorLivePreviewField } from "obsidian";
export function isEditorModeChanged(curState: EditorState, prevState: EditorState) {
let isLivePreviewCurrently = curState.field(editorLivePreviewField),
isLivePreviewPreviously = prevState.field(editorLivePreviewField);
return isLivePreviewCurrently != isLivePreviewPreviously;
}

View file

@ -0,0 +1,8 @@
import { IterLineSpec } from "src/types";
export function iterLine(spec: IterLineSpec) {
for (let i = spec.fromLn; i <= (spec.toLn ?? spec.doc.lines); i++) {
let curLine = spec.doc.line(i);
if (spec.callback(curLine, spec.doc) === false) { break }
}
}

View file

@ -0,0 +1,17 @@
import { IterTokenGroupSpec } from "src/types";
import { moveTokenIndexCache } from "src/editor-mode/decorator/utils";
export function iterTokenGroup(spec: IterTokenGroupSpec) {
let { tokens, ranges, callback, indexCache } = spec;
moveTokenIndexCache(tokens, ranges[0]?.from ?? 0, indexCache);
for (
let i = indexCache.number, j = 0;
i < tokens.length && j < ranges.length;
) {
if (ranges[j].to <= tokens[i].from) { j++; continue }
if (tokens[i].from < ranges[j].to && tokens[i].to > ranges[j].from) {
callback(tokens[i]);
}
i++;
}
}

View file

@ -0,0 +1,23 @@
import { IndexCache, TokenGroup } from "src/types";
export function moveTokenIndexCache(tokens: TokenGroup, offset: number, indexCache: IndexCache) {
if (tokens.length == 0) {
indexCache.number = 0;
return;
}
if (indexCache.number >= tokens.length) {
indexCache.number = tokens.length - 1;
}
let curIndex = indexCache.number,
curToken = tokens[curIndex];
if (offset < curToken.from && curIndex != 0) {
do {
curToken = tokens[--curIndex];
} while (offset < curToken.from && curIndex != 0)
} else if (offset > curToken.to && curIndex != tokens.length - 1) {
do {
curToken = tokens[++curIndex];
} while (offset > curToken.to && curIndex != tokens.length - 1)
}
indexCache.number = curIndex;
}

View file

@ -0,0 +1,7 @@
import { Line } from "@codemirror/state";
export function sliceStrFromLine(line: Line, from: number, to: number) {
from -= line.from;
to -= line.from;
return line.text.slice(from, to);
}

View file

@ -1,21 +1,8 @@
import { WidgetType, EditorView, Decoration } from "@codemirror/view"
import { MarkdownView, Menu } from "obsidian";
import { appFacet } from "src/editor-mode/facets";
import { CharPos } from "src/types";
// first is the text in the color tag, second is displayed text on menu
const PREDEFINED_COLORS = [
["red", "Red"],
["orange", "Orange"],
["yellow", "Yellow"],
["green", "Green"],
["cyan", "Cyan"],
["blue", "Blue"],
["purple", "Purple"],
["pink", "Pink"],
["accent", "Accent"],
["default", "Default"],
];
import { Menu } from "obsidian";
import { appFacet, settingsFacet } from "src/editor-mode/facets";
import { getActiveCanvasNodeCoords } from "src/editor-mode/utils";
import { PlainRange, Token } from "src/types";
/**
* These code snippets are taken from
@ -24,98 +11,134 @@ const PREDEFINED_COLORS = [
*/
export class ColorButton extends WidgetType {
menu: Menu;
colorTag: CharPos;
open: CharPos;
close: CharPos;
constructor(color: string, open: CharPos, close: CharPos) {
tagRange: PlainRange;
openRange: PlainRange;
closeRange: PlainRange;
constructor(token: Token) {
super();
this.open = open;
this.close = close;
this.colorTag = color ?
{ from: open.to, to: open.to + color.length + 2 } :
{ from: open.to, to: open.to };
this.openRange = { from: token.from, to: token.from + token.openLen };
this.tagRange = { from: this.openRange.to, to: this.openRange.to + token.tagLen };
this.closeRange = { from: token.to - token.closeLen, to: token.to };
}
get openLen() {
return this.openRange.to - this.openRange.from;
}
get closeLen() {
return this.closeRange.to - this.closeRange.from;
}
get tagLen() {
return this.tagRange.to - this.tagRange.from;
}
eq(other: ColorButton) {
return (
other.colorTag.from == this.colorTag.from &&
other.colorTag.to == this.colorTag.to
);
other.tagRange.from == this.tagRange.from &&
other.tagRange.to == this.tagRange.to
);
}
toDOM(view: EditorView): HTMLElement {
let btn = document.createElement("span");
let btn = document.createElement("span"),
settings = view.state.facet(settingsFacet),
colorConfigs = settings.colorConfigs;
btn.setAttribute("aria-hidden", "true");
btn.className = "cm-highlight-color-btn";
btn.onclick = (evt) => {
view.dispatch({
selection: {
anchor: this.colorTag.from,
head: this.colorTag.to
}
});
this.menu = new Menu();
btn.onclick = () => {
view.dispatch({
selection: {
anchor: this.tagRange.from,
head: this.tagRange.to
}
});
this.menu = new Menu();
this.menu.dom.addClass("highlight-colors-modal");
PREDEFINED_COLORS.forEach((color) => {
this.menu.addItem((item) => {
item.setTitle(color[1]);
item.setIcon("palette");
item.dom.addClass(`menu-item-${color[0] || "default"}`);
item.onClick(() => { this.changeColor(view, color[0]) });
});
});
this.menu.addItem(item => {
item.setTitle("Remove");
item.setIcon("eraser");
item.dom.addClass("menu-item-remove-highlight");
item.onClick(() => {
view.dispatch({
changes: [{
from: this.open.from,
to: this.colorTag.to,
insert: ""
}, {
from: this.close.from,
to: this.close.to,
insert: ""
}]
});
});
});
let app = view.state.facet(appFacet.reader),
menuCoord = { x: evt.clientX, y: evt.clientY + 10 };
if (app.workspace.getMostRecentLeaf()?.view.getViewType() == "canvas") {
let containerEl = (app.workspace.activeEditor as MarkdownView)?.containerEl;
if (containerEl) {
let containerCoord = containerEl.getBoundingClientRect();
menuCoord.x += containerCoord.x;
menuCoord.y += containerCoord.y;
}
}
this.menu.showAtPosition(menuCoord);
}
colorConfigs.forEach(({ name, tag, showInMenu }) => {
if (!showInMenu) { return }
this.addItem(name, "palette", "menu-item-" + tag, () => { this.changeColor(view, tag) });
});
this.addItem("Accent", "palette", "menu-item-accent", () => { this.changeColor(view, "accent") });
this.addItem("Default", "palette", "menu-item-default", () => { this.toDefault(view) });
this.addItem("Remove", "eraser", "menu-item-remove-highlight", () => { this.removeColor(view) });
this.showMenu(view);
}
return btn;
}
adjustPos(length: number) {
this.colorTag.to += length;
this.close.from += length;
this.close.to += length;
}
changeColor(view: EditorView, color: string) {
view.dispatch({
changes: {
from: this.colorTag.from,
to: this.colorTag.to,
insert: color != "default" ? `{${color}}` : ""
}
});
let differ = color == "default" ?
this.colorTag.from - this.colorTag.to :
color.length + 2 - (this.colorTag.to - this.colorTag.from);
this.adjustPos(differ);
}
showMenu(view: EditorView) {
let charPos = this.tagRange.from,
menu = this.menu;
view.requestMeasure({
read(view) {
let app = view.state.facet(appFacet.reader),
canvasNodeCoords = getActiveCanvasNodeCoords(app),
charCoords = view.coordsForChar(charPos);
return { charCoords, canvasNodeCoords };
},
write(measure) {
let { charCoords, canvasNodeCoords } = measure;
if (charCoords) {
let menuCoords = { x: charCoords.left, y: charCoords.bottom };
if (canvasNodeCoords) {
menuCoords.x += canvasNodeCoords.x;
menuCoords.y += canvasNodeCoords.y;
}
menu.showAtPosition(menuCoords);
}
}
});
}
adjustPos(differ: number) {
this.tagRange.to += differ;
this.closeRange.from += differ;
this.closeRange.to += differ;
}
changeColor(view: EditorView, color: string) {
view.dispatch({
changes: {
from: this.tagRange.from,
to: this.tagRange.to,
insert: `{${color}}`
}
});
let differ = color.length + 2 - (this.tagRange.to - this.tagRange.from);
this.adjustPos(differ);
}
removeColor(view: EditorView) {
view.dispatch({
changes: [{
from: this.openRange.from,
to: this.tagRange.to,
insert: ""
}, {
from: this.closeRange.from,
to: this.closeRange.to,
insert: ""
}]
});
}
toDefault(view: EditorView) {
view.dispatch({
changes: {
from: this.tagRange.from,
to: this.tagRange.to,
insert: ""
}
});
let tagLen = this.tagLen;
this.adjustPos(-tagLen);
}
addItem(title: string, icon: string, cls: string, callback: (evt: MouseEvent | KeyboardEvent) => unknown) {
this.menu.addItem(item => {
item.setTitle(title);
item.setIcon(icon);
item.dom.addClass(cls);
item.onClick(callback);
});
}
ignoreEvent() {
return false;
}
static of(color: string, open: CharPos, close: CharPos) {
return Decoration.widget({ widget: new ColorButton(color, open, close) }).range(open.to);
}
static of(hlToken: Token) {
let btnOffset = hlToken.from + hlToken.openLen;
return Decoration
.widget({ widget: new ColorButton(hlToken), side: 1 })
.range(btnOffset);
}
}

View file

@ -1,20 +1,23 @@
import { Decoration, WidgetType } from "@codemirror/view";
import { Token } from "src/types";
export class HiddenWidget extends WidgetType {
replaced: Decoration;
constructor(replaced: Decoration) {
token: Token;
constructor(replaced: Token) {
super();
this.replaced = replaced;
this.token = replaced;
}
eq(other: HiddenWidget) {
return other.replaced == this.replaced;
return other.token == this.token;
}
toDOM(): HTMLElement {
return document.createElement("span");
}
static of(from: number, to: number, replaced: Decoration) {
static of(from: number, to: number, token: Token, isBlock = false) {
return Decoration.replace({
widget: new HiddenWidget(replaced)
widget: new HiddenWidget(token),
block: isBlock,
inclusiveEnd: false
}).range(from, to);
}
}

View file

@ -0,0 +1,20 @@
import { Decoration, WidgetType } from "@codemirror/view";
export class LineBreak extends WidgetType {
offset: number;
constructor(offset: number) {
super();
this.offset = offset;
}
eq(other: LineBreak) {
return other.offset === this.offset;
}
toDOM(): HTMLElement {
return document.createElement("br");
}
static of(offset: number) {
return Decoration.replace({
widget: new LineBreak(offset),
}).range(offset - 1, offset);
}
}

View file

@ -1,2 +1,3 @@
export * from "./HiddenWidget";
export * from "./ColorButton";
export * from "./ColorButton";
export * from "./LineBreak";

View file

@ -1,6 +1,19 @@
import { ViewPlugin } from "@codemirror/view";
import { ExtendedSyntax } from "src/editor-mode/decorator/builder";
import { Extension, RangeSet } from "@codemirror/state";
import { EditorView, ViewPlugin } from "@codemirror/view";
import { builderField, decoSetField, parserField, selectionObserverField } from "src/editor-mode/state-fields";
import { BuilderPlugin } from "src/editor-mode/view-plugin";
export const editorExtendedSyntax = ViewPlugin.fromClass(ExtendedSyntax, {
decorations: value => value.combinedSet,
});
const builderPlugin = ViewPlugin.fromClass(BuilderPlugin);
export const editorExtendedSyntax: Extension = [
parserField,
selectionObserverField,
builderField,
decoSetField,
builderPlugin,
EditorView.outerDecorations.of(view => view.plugin(builderPlugin)?.builder.holder.inlineSet ?? RangeSet.empty),
EditorView.decorations.of(view => view.plugin(builderPlugin)?.builder.holder.blockSet ?? RangeSet.empty),
EditorView.decorations.of(view => view.plugin(builderPlugin)?.builder.holder.inlineOmittedSet ?? RangeSet.empty),
EditorView.decorations.of(view => view.plugin(builderPlugin)?.builder.holder.colorBtnSet ?? RangeSet.empty),
EditorView.decorations.of(view => view.plugin(builderPlugin)?.builder.holder.revealedSpoilerSet ?? RangeSet.empty)
];

View file

@ -0,0 +1,13 @@
import { Transaction } from "@codemirror/state";
import { refresherAnnot } from "src/editor-mode/annotations";
export function checkRefreshed(transactions: Transaction[] | readonly Transaction[] | Transaction) {
if (transactions instanceof Array) {
for (let i = 0; i < transactions.length; i++) {
if (transactions[i].annotation(refresherAnnot)) { return true }
}
} else {
if (transactions.annotation(refresherAnnot)) { return true }
}
return false;
}

View file

@ -0,0 +1,9 @@
import { App } from "obsidian";
import { isCanvas } from "src/editor-mode/utils";
export function getActiveCanvasEditor(app: App) {
if (isCanvas(app)) {
return app.workspace.activeEditor;
}
return null;
}

View file

@ -0,0 +1,11 @@
import { App, MarkdownView } from "obsidian";
import { getActiveCanvasEditor } from "./getActiveCanvasEditor";
export function getActiveCanvasNodeCoords(app: App) {
let canvasEditor = getActiveCanvasEditor(app),
containerEl = (canvasEditor as MarkdownView)?.containerEl;
if (containerEl) {
return containerEl.getBoundingClientRect();
}
return null;
}

View file

@ -0,0 +1,4 @@
export * from "./checkRefreshed";
export * from "./getActiveCanvasNodeCoords";
export * from "./getActiveCanvasEditor";
export * from "./isCanvas";

View file

@ -0,0 +1,5 @@
import { App } from "obsidian";
export function isCanvas(app: App) {
return app.workspace.getMostRecentLeaf()?.view.getViewType() == "canvas";
}

View file

@ -0,0 +1,15 @@
import { EditorView, PluginValue, ViewUpdate } from "@codemirror/view";
import { DecorationBuilder } from "src/editor-mode/decorator/builder";
import { builderField } from "src/editor-mode/state-fields";
export class BuilderPlugin implements PluginValue {
builder: DecorationBuilder;
constructor(view: EditorView) {
let state = view.state;
this.builder = state.field(builderField);
this.builder.onViewInit(view);
}
update(update: ViewUpdate) {
this.builder.onViewUpdate(update);
}
}

View file

@ -0,0 +1 @@
export * from "./BuilderPlugin";