async GutterMarker

I’m trying to figure out if there is a way to set GutterMarkers asynchronously? Similar to how LintSource callback can return a Promise<Diagnostic> I want to be able to return Promise<GutterMarker> instead of the object itself in lineMarker function. As far as I can tell, the API does not support this, but is there another way to achieve this?

Not with lineMarker, no, but the markers option allows you to manage the set of markers any way you want, including in a state field that’s updated by transactions dispatched from some asynchronous process (which could be initiated by a view plugin).

@marijn Thank you very much this has been very helpful! I was able to configure this up to a point but now I have a problem that even though the state field updates correctly the gutter marker is not added. Everything looks fine per my understanding and as verified via console logs, so don’t quite understand what the problem is. Maybe you can notice something I’m doing wrong?

const gutterMarkerEffect = StateEffect.define<{ pos: number }>();

const gutterMarkerState = StateField.define<RangeSet<GutterMarker>>({
    create() {
        return RangeSet.empty;
    },
    update(set, transaction) {
        for (let e of transaction.effects) {
            if (e.is(gutterMarkerEffect)) {
                set = set.update({filter: from => from != e.value.pos})
                set = set.update({add: [marker.range(e.value.pos)]})
            }
        }
        return set;
    }
});

const viewUpdatePlugin = ViewPlugin.define(view => ({
    update(update) {
        if (update.docChanged) {
            gutterFunction(view.state.doc.toString()).then(res => {
                if (res == null || res.length == null) {
                    return;
                }
                res.forEach(i => view.dispatch({
                    effects: gutterMarkerEffect.of({pos: i})
                }));
            })
        }
    },
}));

const marker = new class extends GutterMarker {
    toDOM() {
        return document.createTextNode("💔")
    }
}
const gutterExtension = [
    gutterMarkerState,
    gutter({
        class: "cm-change-gutter",
        markers: v => {
            const mark = v.state.field(gutterMarkerState)
            console.log("setting markers: ", mark);
            return mark;
        },
        initialSpacer: () => marker
    }),
    viewUpdatePlugin
];

Can you spot any config mistake I’m making that might cause this?

That code seems to work for me, except that it doesn’t update markers on init, but only once you make a change.

Also, the rangeset update logic seems dodgy—it’ll only clear markers at the exact position where the new marker is, which will probably ‘leak’ markers moved by document changes and such.

Speaking of which, you’ll want to do set.map(transaction.changes) somewhere in that update method so that the markers stick to their line when code above is inserted/deleted. And it’s more efficient to dispatch a single transaction with a bunch of effects than separate transactions for every marker.

Hi @marijn, that’s weird. On my end I can only see an invisible :broken_heart: in the DOM place there by having: initialSpacer: () => marker set. With regards to the dodgy logic, the reason why I did it like this because I noticed duplicates were being added, so this filters them out. I honestly didn’t bother to look at the API for a better way to get rid of the duplicates in the RangeSet because I just wanted to make this work first. I don’t think that causes any issue.

To summarize, the state is being updated correctly, however the view is not. Since this works on your end, the next thing that comes to mind is that I’m not registering the extensions appropriately. Here is a bigger code snippet:

import {FC, useCallback, useEffect, useMemo, useRef, useState} from "react";
import {Diagnostic, linter} from "@codemirror/lint";
import CodeMirror, {basicSetup, EditorState, lineNumbers} from '@uiw/react-codemirror';
import {LanguageName, loadLanguage} from "@uiw/codemirror-extensions-langs";
import {EditorView, gutter, GutterMarker, keymap, ViewPlugin, ViewUpdate} from "@codemirror/view";
import {indentWithTab} from "@codemirror/commands";
import {Extension, RangeSet, StateEffect, StateField} from "@codemirror/state";

export interface CodeMirrorProps {
    language: LanguageName;
    theme?: Extension,
    height: string;
    code: string;
    lineWrapping: boolean;
    linterFunction?: (value: string) => Promise<Diagnostic[]> | null;
    onChange?: (value: string) => Promise<void> | null;
    onCursorChange?: (value: CursorPosition) => Promise<void> | null;
    hidden?: boolean;
    readonly?: boolean;
    style?: React.CSSProperties;
    gutterFunction?: (doc: string) => Promise<number[]>;
}

export interface CursorPosition {
    position: number
    line: number
    ch: number
}

export const CodeMirrorIntern: FC<CodeMirrorProps> = ({
                                                          language,
                                                          code,
                                                          theme,
                                                          height,
                                                          lineWrapping,
                                                          linterFunction,
                                                          gutterFunction,
                                                          onChange,
                                                          onCursorChange,
                                                          hidden,
                                                          readonly,
                                                          style
                                                      }) => {
    const [value, setValue] = useState(code);
    const [cursorPosition, setCursorPosition] = useState<CursorPosition>(null);
    const [gutterExtension, setGutterExtension] = useState<Extension>(null);

    const onUpdateHandler = useCallback((editorView: ViewUpdate) => {
        setCursorPosition(oldCursorPosition => {
            if (!editorView) return oldCursorPosition
            const pos = editorView.state.selection.main.head
            if (pos !== oldCursorPosition?.position) {
                const line = editorView.state.doc.lineAt(pos)
                return {position: pos, line: line.number, ch: pos - line.from}
            }
            return oldCursorPosition
        })
    }, [setCursorPosition])

    const actualStyle = style == null ? {
        height: "auto",
        'max-height': height,
        width: "auto",
    } : style;

    useEffect(() => {
        if (cursorPosition && onCursorChange) {
            onCursorChange(cursorPosition).catch((e) =>
                console.log("Error in onCursorChange function", e)
            )
        }
    }, [cursorPosition, onCursorChange]);

    useEffect(() => {
        setValue(oldCode => oldCode !== code ? code : oldCode)
    }, [code]);

    useEffect(() => {
        const gutterMarkerEffect = StateEffect.define<{ pos: number }>(
            //     {
            //     map: (val, mapping) => ({pos: mapping.mapPos(val.pos)})
            // }
        );

        const gutterMarkerState = StateField.define<RangeSet<GutterMarker>>({
            create() {
                // let ss = RangeSet.empty;
                // ss = ss.update({add: [marker.range(1)]});
                return RangeSet.empty;
            },
            update(set, transaction) {
                // set = set.map(transaction.changes)
                for (let e of transaction.effects) {
                    if (e.is(gutterMarkerEffect)) {
                        set = set.update({filter: from => from != e.value.pos})
                        set = set.update({add: [marker.range(e.value.pos)]})
                    }
                }
                console.log("returned set: ", set);
                return set;
            }
        });

        const viewUpdatePlugin = ViewPlugin.define(view => ({
            update(update) {
                if (update.docChanged) {
                    gutterFunction(view.state.doc.toString()).then(res => {
                        if (res == null || res.length == null) {
                            return;
                        }
                        res.forEach(i => view.dispatch({
                            effects: gutterMarkerEffect.of({pos: i})
                        }));
                    })
                }
            },
        }))

        const marker = new class extends GutterMarker {
            toDOM() {
                return document.createTextNode("💔")
            }
        }
        const gutterExtension = [
            gutterMarkerState,
            gutter({
                class: "cm-change-gutter",
                markers: v => {
                    const mark =  v.state.field(gutterMarkerState)
                    console.log("setting markers: ", mark);
                    return mark;
                },
                initialSpacer: () => marker
            }),
            viewUpdatePlugin
        ];
        setGutterExtension(gutterExtension);
    }, []);

        return <CodeMirror
            value={value}
            height={height}
            theme={theme == null ? "dark" : theme}
            onUpdate={onUpdateHandler}
            extensions={[
                keymap.of([indentWithTab]),
                lineWrapping ? EditorView.lineWrapping : null,
                loadLanguage(language),
                gutterExtension,
                lineNumbers(),
                linter(async editorView => {
                        if (typeof linterFunction !== "function")
                            return []
                        const result = await linterFunction(editorView.state.doc.toString())
                        return result || []
                    }
                )
            ].filter(x => x != null)}
            onChange={(newValue) => {
                if (newValue == value)
                    return
                setValue(newValue)
                // onChange(newValue).catch((e) =>
                //     console.log("Error in onChange function", e)
                // )
            }}
            style={actualStyle}
        />
};

Does your gutterFunction actually return positions at the start of lines? Marker’s must be positioned right at the start of their line.

Since this is a gutter for changes to the saved document and current state of the document, the function checks for each line whether it is different from the original document and current state of the document, returning an array of line numbers that are different from the original document and current state. Here is an example of how I’m invoking it from a cypress test:

it('If gutter is working', () => {
        const lua = "print('Hello World')\nprint('Hello World')\nprint('Hello World')\nprint('Hello World')\n"
        const originalState = lua.split("\n");
        const onChange = cy.stub();
        const onLint = cy.stub();
        const gutterFunction = async (text: string): Promise<number[]> => {
            const currentDocumentLines = text.split("\n");
            const lineNumbersToApplyGutterMarker = [];
            for (let i = 0; i < currentDocumentLines.length; i++) {
                if (currentDocumentLines[i] != originalState[i]) {
                    lineNumbersToApplyGutterMarker.push(i + 1);
                }
            }
            return lineNumbersToApplyGutterMarker;
        }
        mount(<CodeMirrorIntern
            language={"lua" as LanguageName}
            height="calc(100vh - 62px)"
            code={lua}
            gutterFunction={gutterFunction}
            onChange={onChange}
            lineWrapping
        />)
    })

You’ll have to return document positions for line starts, not line numbers.

Can you give me an example on what you mean? How are document positions modeled? Can you give me a reference in the documentation?

See the guide.