All files / services/wiki-highlighter wiki-highlighter.service.ts

94.23% Statements 98/104
87.87% Branches 29/33
100% Functions 18/18
93.87% Lines 92/98

Press n or j to go to the next uncovered block, b, p or k for the previous block.

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 1982x 2x 2x 2x 2x               2x 2x     2x 23x 23x 23x 23x   23x 23x 23x 23x 23x   23x   23x 19x   10x 10x       23x       30x 30x 30x   30x       30x 92x 44x 44x 44x 44x     44x 14x 30x 2x   28x 28x     44x 16x 16x           30x   30x       30x 12x     28x   18x       19x 19x 19x       29x 29x 19x 19x 19x 19x 19x 19x 67x 19x     29x       29x   29x 90x     29x           57x 31x 31x 28x   31x                     19x 28x 28x 28x 28x 28x                     19x                         44x       56x 56x   56x 524x 524x 63x 461x 66x   524x 3x   521x     56x       72x      
import { linksUpdatedEffect } from '../../constants/editor-effects';
import { Injectable } from '@angular/core';
import { RangeSetBuilder, StateField } from '@codemirror/state';
import { Decoration, DecorationSet, EditorView } from '@codemirror/view';
import { Subject } from 'rxjs';
 
interface Match {
    from: number;
    to: number;
    className: string;
}
 
const commonClassName = 'cm-wikiHighlight';
const LINK_STATE_RE = /\bcm-link-(pending|exists|missing)\b/;
 
@Injectable()
export class WikiHighlighterService {
    private readonly footnoteRegex = /\[\[([\s\S]*?)\]\]/g;
    private readonly linkRegex = /\(\((?!\()(.+?)(=.+?)?\)\)(?!\))/g;
    private readonly mapPointRegex = /\{\{Метка:(.+?)\}\}/g;
    private readonly quoteRegex = /^>.*$/gm;
 
    private text = '';
    private readonly matches: Match[] = [];
    private linksState: Record<string, boolean> = {};
    private readonly pendingLinks: string[] = [];
    private readonly updateLinksSubject = new Subject<string[]>();
 
    public readonly updateLinks$ = this.updateLinksSubject.asObservable();
 
    public wikiHighlighter = StateField.define<DecorationSet>({
        create: state => this.createDecorations(state.doc.toString()),
        update: (decorations, transaction) => {
            Eif (transaction.docChanged || transaction.effects.some(eff => eff.is(linksUpdatedEffect))) {
                return this.createDecorations(transaction.newDoc.toString());
            }
            return decorations;
        },
        provide: f => EditorView.decorations.from(f),
    });
 
    public async updateLinksState(updateLinksState: Record<string, boolean>): Promise<boolean> {
        let changed = false;
        this.linksState = { ...this.linksState, ...updateLinksState };
        this.pendingLinks.length = 0;
 
        Iif (this.text.length === 0) {
            return Promise.resolve(true);
        }
 
        for (const match of this.matches) {
            if (LINK_STATE_RE.test(match.className)) {
                const linkText = this.extractLinkText(this.text, match);
                Eif (linkText) {
                    const normalizedLink = this.normalizeLinkText(linkText);
                    const status: boolean | undefined = this.linksState[normalizedLink];
                    let newClass: string | undefined;
 
                    if (status === true) {
                        newClass = `${commonClassName} cm-link-exists`;
                    } else if (status === false) {
                        newClass = `${commonClassName} cm-link-missing`;
                    } else {
                        newClass = `${commonClassName} cm-link-pending`;
                        this.pendingLinks.push(linkText);
                    }
 
                    if (newClass && newClass !== match.className) {
                        match.className = newClass;
                        changed = true;
                    }
                }
            }
        }
 
        this.requestLinksStatus(this.pendingLinks);
 
        return changed;
    }
 
    private requestLinksStatus(links: string[]): void {
        if (!links.length) {
            return;
        }
 
        const uniqueNormalized = new Set(links.map(link => this.normalizeLinkText(link)));
 
        this.updateLinksSubject.next(Array.from(uniqueNormalized));
    }
 
    private reset(text: string): void {
        this.text = text;
        this.matches.length = 0;
        this.pendingLinks.length = 0;
    }
 
    private createDecorations(text: string): DecorationSet {
        const textChanged = this.text !== text;
        if (textChanged) {
            this.reset(text);
            this.collectMatches(this.footnoteRegex, 'cm-footnote');
            this.collectMapPointMatches();
            this.collectMatches(this.linkRegex, 'cm-link', true);
            this.collectMatches(this.quoteRegex, 'cm-quote');
            this.collectLinksMatches();
            this.matches.sort((a, b) => a.from - b.from);
            this.updateLinksState(this.linksState);
        }
 
        return this.buildText();
    }
 
    private buildText(): DecorationSet {
        const builder = new RangeSetBuilder<Decoration>();
 
        for (const { from, to, className } of this.matches) {
            builder.add(from, to, Decoration.mark({ class: className }));
        }
 
        return builder.finish();
    }
 
    private collectMatches(regex: RegExp, className: string, isBalancedCorrectionNeeded = false): void {
        let match;
        // eslint-disable-next-line no-null/no-null
        while ((match = regex.exec(this.text)) !== null) {
            let matchedText = match[0];
            if (isBalancedCorrectionNeeded) {
                matchedText = this.trimToBalanced(matchedText);
            }
            this.matches.push({
                from: match.index,
                to: match.index + matchedText.length,
                className: `${commonClassName} ${className}`,
            });
        }
    }
 
    private collectLinksMatches(): void {
        let match;
        // eslint-disable-next-line no-null/no-null
        while ((match = this.linkRegex.exec(this.text)) !== null) {
            const matchedText = this.trimToBalanced(match[1]);
            const start = match.index + 2; // Skip the opening brackets
            const isMap = matchedText.startsWith('Карты:');
            const specificClass = isMap ? 'cm-map' : 'cm-link-pending';
            this.matches.push({
                from: start,
                to: start + matchedText.length,
                className: `${commonClassName} ${specificClass}`,
            });
        }
    }
 
    private collectMapPointMatches(): void {
        let match: RegExpExecArray | null;
        // eslint-disable-next-line no-null/no-null
        while ((match = this.mapPointRegex.exec(this.text)) !== null) {
            const fullMatch = match[0];
            const start = match.index;
            const end = start + fullMatch.length;
            this.matches.push({
                from: start,
                to: end,
                className: `${commonClassName} cm-map-point`,
            });
        }
    }
 
    private extractLinkText(doc: string, match: Match): string {
        return doc.slice(match.from, match.to);
    }
 
    private trimToBalanced(text: string): string {
        let stack = 0;
        let endIndex = 0;
 
        for (let i = 0; i < text.length; i++) {
            const char = text[i];
            if (char === '(') {
                stack++;
            } else if (char === ')') {
                stack--;
            }
            if (stack < 0) {
                break;
            }
            endIndex = i + 1;
        }
 
        return text.slice(0, endIndex);
    }
 
    private normalizeLinkText(link: string): string {
        return link.trim().toUpperCase().replace(/Ё/g, 'Е').replace(/\s+/g, ' ');
    }
}