Line data Source code
1 : import * as dashql from '@ankoh/dashql-core';
2 :
3 : import { codeFolding, foldEffect, foldedRanges, foldKeymap, foldService, unfoldEffect } from '@codemirror/language';
4 : import { EditorState, Extension, Transaction } from '@codemirror/state';
5 : import { EditorView, GutterMarker, keymap } from '@codemirror/view';
6 :
7 : import { DashQLProcessorPlugin, DashQLUpdateEffect } from './dashql_processor.js';
8 :
9 : import './dashql_vis_folding.css';
10 :
11 : /// The range between the braces of a Vega-Lite spec
12 : export interface VegaLiteSpecRange {
13 : from: number;
14 : to: number;
15 : }
16 :
17 : /// The ranges between the braces of the non-empty `@vis vegalite` specs of an update
18 1 : export function findVegaLiteSpecs(update: dashql.buffers.editor.EditorUpdateT | null | undefined): VegaLiteSpecRange[] {
19 1 : const specs: VegaLiteSpecRange[] = [];
20 1 : for (const span of update?.vegaliteSpecs ?? []) {
21 : // The core sends the JSON objects with their braces
22 1 : const from = Number(span.offset) + 1;
23 1 : const to = Number(span.offset + span.length) - 1;
24 1 : if (to > from) specs.push({ from, to });
25 1 : }
26 1 : return specs;
27 1 : }
28 :
29 1 : export function isFolded(state: EditorState, spec: VegaLiteSpecRange): boolean {
30 1 : let folded = false;
31 1 : foldedRanges(state).between(spec.from, spec.to, (from, to) => {
32 1 : if (from === spec.from && to === spec.to) folded = true;
33 1 : });
34 1 : return folded;
35 1 : }
36 :
37 : /// Folds the Vega-Lite specs that a script brings into the editor, e.g. when it is opened or when the agent writes
38 : /// a chart. Specs that the user types or unfolded stay open.
39 1 : const autoFoldVegaLiteSpecs = EditorState.transactionExtender.of(tr => {
40 1 : if (tr.annotation(Transaction.userEvent) != null) return null;
41 1 : let update: dashql.buffers.editor.EditorUpdateT | null = null;
42 1 : let scriptSession: dashql.DashQLScriptSession | null | undefined = null;
43 1 : for (const effect of tr.effects) {
44 1 : if (effect.is(DashQLUpdateEffect)) {
45 1 : update = effect.value.editorUpdate ?? null;
46 1 : scriptSession = effect.value.scriptSession;
47 1 : }
48 : }
49 1 : if (update == null) return null;
50 1 : const prev = tr.startState.field(DashQLProcessorPlugin, false);
51 1 : const initial = prev?.editorUpdate == null || prev.scriptSession !== scriptSession;
52 1 : if (!initial && !tr.docChanged) return null;
53 :
54 : // A spec that is open before and kept by the new text stays open
55 1 : const open = new Set<string>();
56 1 : if (!initial) {
57 1 : for (const spec of findVegaLiteSpecs(prev.editorUpdate)) {
58 1 : if (!isFolded(tr.startState, spec)) open.add(tr.startState.doc.sliceString(spec.from, spec.to));
59 : }
60 : }
61 1 : const head = tr.newSelection.main.head;
62 1 : const effects = findVegaLiteSpecs(update)
63 1 : .filter(spec => (head <= spec.from || head > spec.to) && !open.has(tr.newDoc.sliceString(spec.from, spec.to)))
64 1 : .map(spec => foldEffect.of(spec));
65 1 : return effects.length > 0 ? { effects } : null;
66 1 : });
67 :
68 : /// The spec that is folded from the line of its opening brace
69 1 : function findVegaLiteSpecAtLine(state: EditorState, lineStart: number, lineEnd: number): VegaLiteSpecRange | null {
70 1 : const processor = state.field(DashQLProcessorPlugin, false);
71 1 : return findVegaLiteSpecs(processor?.editorUpdate).find(spec => spec.from >= lineStart && spec.from <= lineEnd) ?? null;
72 1 : }
73 :
74 : /// Lets the Vega-Lite specs be folded from the line of their opening brace
75 1 : const vegaLiteSpecFolds = foldService.of(findVegaLiteSpecAtLine);
76 :
77 : /// Folds or unfolds the spec of a line, returns false if the line opens no spec
78 1 : export function toggleVegaLiteSpecAtLine(view: EditorView, lineStart: number, lineEnd: number): boolean {
79 1 : const spec = findVegaLiteSpecAtLine(view.state, lineStart, lineEnd);
80 1 : if (spec == null) return false;
81 1 : view.dispatch({ effects: isFolded(view.state, spec) ? unfoldEffect.of(spec) : foldEffect.of(spec) });
82 1 : return true;
83 1 : }
84 :
85 : /// Folds and unfolds a spec from the gutter next to the line of its opening brace.
86 : /// The marker shares the gutter of the error markers and turns red if the line has an error.
87 1 : export class VegaLiteFoldMarker extends GutterMarker {
88 1 : constructor(readonly open: boolean, readonly error: boolean) {
89 1 : super();
90 1 : }
91 1 : eq(other: GutterMarker): boolean {
92 1 : return other instanceof VegaLiteFoldMarker && other.open === this.open && other.error === this.error;
93 1 : }
94 1 : toDOM(): Node {
95 1 : const marker = document.createElement('span');
96 1 : marker.className = this.error ? 'dashql-vis-fold-marker dashql-vis-fold-marker-error' : 'dashql-vis-fold-marker';
97 1 : marker.dataset.testid = 'vis-fold-marker';
98 1 : marker.setAttribute('aria-expanded', String(this.open));
99 1 : marker.title = this.open ? 'Fold the Vega-Lite spec' : 'Unfold the Vega-Lite spec';
100 1 : marker.textContent = this.open ? '▾' : '▸';
101 1 : return marker;
102 1 : }
103 1 : }
104 :
105 1 : export const DashQLVisFoldingPlugin: Extension = [
106 1 : codeFolding(),
107 1 : vegaLiteSpecFolds,
108 1 : autoFoldVegaLiteSpecs,
109 1 : keymap.of(foldKeymap),
110 1 : ];
|