@elabs-ai/components-editor 4.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +149 -0
- package/dist/chunk-LBC5VJBD.js +3748 -0
- package/dist/chunk-LBC5VJBD.js.map +1 -0
- package/dist/index.css +233 -0
- package/dist/index.css.map +1 -0
- package/dist/index.d.ts +199 -0
- package/dist/index.js +362 -0
- package/dist/index.js.map +1 -0
- package/dist/lib/monaco-environment.d.ts +2 -0
- package/dist/lib/monaco-environment.js +44 -0
- package/dist/lib/monaco-environment.js.map +1 -0
- package/dist/markdown/frontmatter.d.ts +11 -0
- package/dist/markdown/frontmatter.js +33 -0
- package/dist/markdown/frontmatter.js.map +1 -0
- package/dist/markdown/index.css +233 -0
- package/dist/markdown/index.css.map +1 -0
- package/dist/markdown/index.d.ts +1486 -0
- package/dist/markdown/index.js +4882 -0
- package/dist/markdown/index.js.map +1 -0
- package/dist/markdown/parse.d.ts +25 -0
- package/dist/markdown/parse.js +14 -0
- package/dist/markdown/parse.js.map +1 -0
- package/dist/markdown-editor-DfBZibAn.d.ts +702 -0
- package/package.json +101 -0
- package/src/ai-objects/ai-objects-integration.test.tsx +128 -0
- package/src/ai-objects/ai-objects.stories.tsx +233 -0
- package/src/ai-objects/ai-objects.test.tsx +205 -0
- package/src/ai-objects/decision-card.tsx +178 -0
- package/src/ai-objects/directives.ts +136 -0
- package/src/ai-objects/entity.tsx +181 -0
- package/src/ai-objects/index.ts +34 -0
- package/src/ai-objects/knowledge-card.tsx +143 -0
- package/src/calc-block/calc-block.stories.tsx +297 -0
- package/src/calc-block/calc-block.test.tsx +164 -0
- package/src/calc-block/calc-block.tsx +398 -0
- package/src/calc-block/calc-editor-monaco.ts +214 -0
- package/src/calc-block/calc-editor-prose.ts +128 -0
- package/src/calc-block/calc-editor.css +80 -0
- package/src/calc-block/calc-editor.stories.tsx +272 -0
- package/src/calc-block/calc-editor.test.ts +228 -0
- package/src/calc-block/calc-editor.ts +270 -0
- package/src/calc-block/calc-inline.stories.tsx +44 -0
- package/src/calc-block/calc-inline.test.tsx +44 -0
- package/src/calc-block/calc-inline.tsx +75 -0
- package/src/calc-block/index.ts +20 -0
- package/src/calc-block/types.ts +164 -0
- package/src/code-editor/code-editor.stories.tsx +218 -0
- package/src/code-editor/code-editor.test.tsx +134 -0
- package/src/code-editor/code-editor.tsx +250 -0
- package/src/code-editor/index.ts +6 -0
- package/src/code-workspace/code-workspace.stories.tsx +51 -0
- package/src/code-workspace/code-workspace.test.tsx +146 -0
- package/src/code-workspace/code-workspace.tsx +253 -0
- package/src/code-workspace/index.ts +6 -0
- package/src/copy-button/copy-button.tsx +51 -0
- package/src/copy-button/index.ts +1 -0
- package/src/css.d.ts +3 -0
- package/src/diff-editor/diff-editor.stories.tsx +44 -0
- package/src/diff-editor/diff-editor.test.tsx +68 -0
- package/src/diff-editor/diff-editor.tsx +151 -0
- package/src/diff-editor/index.ts +1 -0
- package/src/editor-context-menu/editor-context-menu.tsx +120 -0
- package/src/editor-context-menu/index.ts +1 -0
- package/src/editor-toolbar/editor-toolbar.tsx +83 -0
- package/src/editor-toolbar/index.ts +1 -0
- package/src/index.ts +48 -0
- package/src/lib/editor-completions-monaco.test.ts +206 -0
- package/src/lib/editor-completions-monaco.ts +127 -0
- package/src/lib/editor-completions.test.ts +156 -0
- package/src/lib/editor-completions.ts +153 -0
- package/src/lib/editor-content-access-prose.test.ts +411 -0
- package/src/lib/editor-content-access-prose.ts +172 -0
- package/src/lib/editor-content-access.test.ts +182 -0
- package/src/lib/editor-content-access.ts +109 -0
- package/src/lib/languages.ts +32 -0
- package/src/lib/markdown/diff.test.ts +80 -0
- package/src/lib/markdown/diff.ts +194 -0
- package/src/lib/markdown/directives.ts +342 -0
- package/src/lib/markdown/frontmatter.ts +50 -0
- package/src/lib/markdown/markdown-scale.test.ts +65 -0
- package/src/lib/markdown/markdown-scale.ts +62 -0
- package/src/lib/markdown/merge.test.ts +64 -0
- package/src/lib/markdown/merge.ts +158 -0
- package/src/lib/markdown/slugify.test.ts +156 -0
- package/src/lib/markdown/slugify.ts +47 -0
- package/src/lib/monaco-environment.ts +103 -0
- package/src/lib/monaco-theme-bridge.ts +293 -0
- package/src/lib/use-data-theme.ts +61 -0
- package/src/markdown/frontmatter.ts +14 -0
- package/src/markdown/index.ts +323 -0
- package/src/markdown/parse.test.ts +60 -0
- package/src/markdown/parse.ts +38 -0
- package/src/markdown-academic/citations.test.ts +77 -0
- package/src/markdown-academic/citations.tsx +442 -0
- package/src/markdown-academic/footnotes.tsx +235 -0
- package/src/markdown-academic/index.ts +32 -0
- package/src/markdown-academic/math.tsx +163 -0
- package/src/markdown-academic/toc.tsx +88 -0
- package/src/markdown-editor/completions/completions-menu.tsx +96 -0
- package/src/markdown-editor/completions/completions-prose.test.ts +356 -0
- package/src/markdown-editor/completions/completions-prose.ts +313 -0
- package/src/markdown-editor/completions/completions-widget.tsx +75 -0
- package/src/markdown-editor/completions/index.ts +37 -0
- package/src/markdown-editor/directive-nodes.ts +151 -0
- package/src/markdown-editor/directive-views.test.tsx +189 -0
- package/src/markdown-editor/directive-views.tsx +795 -0
- package/src/markdown-editor/exit-keymap.test.ts +88 -0
- package/src/markdown-editor/exit-keymap.ts +87 -0
- package/src/markdown-editor/index.ts +6 -0
- package/src/markdown-editor/markdown-editor.css +250 -0
- package/src/markdown-editor/markdown-editor.directives.test.tsx +105 -0
- package/src/markdown-editor/markdown-editor.fill.test.ts +34 -0
- package/src/markdown-editor/markdown-editor.paste-embed.test.tsx +297 -0
- package/src/markdown-editor/markdown-editor.stories.tsx +560 -0
- package/src/markdown-editor/markdown-editor.strictmode.test.tsx +38 -0
- package/src/markdown-editor/markdown-editor.table.test.tsx +287 -0
- package/src/markdown-editor/markdown-editor.tsx +560 -0
- package/src/markdown-editor/milkdown-react/editor.tsx +33 -0
- package/src/markdown-editor/milkdown-react/index.ts +8 -0
- package/src/markdown-editor/milkdown-react/types.ts +27 -0
- package/src/markdown-editor/milkdown-react/use-editor.ts +27 -0
- package/src/markdown-editor/milkdown-react/use-get-editor.ts +56 -0
- package/src/markdown-editor/milkdown-react/use-instance.ts +23 -0
- package/src/markdown-editor/paste-embed.ts +355 -0
- package/src/markdown-editor/slash/brand-slash-commands.test.ts +194 -0
- package/src/markdown-editor/slash/brand-slash-commands.ts +324 -0
- package/src/markdown-editor/slash/brand-slash-plugin.test.ts +254 -0
- package/src/markdown-editor/slash/brand-slash-plugin.ts +385 -0
- package/src/markdown-editor/slash/index.ts +107 -0
- package/src/markdown-editor/slash/insert-directive.test.ts +263 -0
- package/src/markdown-editor/slash/insert-directive.ts +345 -0
- package/src/markdown-editor/slash/monaco-slash-menu.test.tsx +198 -0
- package/src/markdown-editor/slash/monaco-slash-menu.tsx +356 -0
- package/src/markdown-editor/slash/shortcut-monaco.ts +69 -0
- package/src/markdown-editor/slash/shortcut.test.ts +157 -0
- package/src/markdown-editor/slash/shortcut.ts +57 -0
- package/src/markdown-editor/slash/slash-menu.stories.tsx +139 -0
- package/src/markdown-editor/slash/slash-menu.tsx +115 -0
- package/src/markdown-editor/slash/slash-scroll.test.tsx +52 -0
- package/src/markdown-editor/slash/slash-widget.tsx +97 -0
- package/src/markdown-editor/slash/source-slash-trigger.test.ts +42 -0
- package/src/markdown-editor/slash/source-slash-trigger.ts +46 -0
- package/src/markdown-editor/table-view.tsx +249 -0
- package/src/markdown-iteration/directive.tsx +92 -0
- package/src/markdown-iteration/edit-context.ts +56 -0
- package/src/markdown-iteration/index.ts +24 -0
- package/src/markdown-iteration/iteration-block.test.tsx +138 -0
- package/src/markdown-iteration/iteration-builder-dialog.stories.tsx +293 -0
- package/src/markdown-iteration/iteration-builder-dialog.tsx +312 -0
- package/src/markdown-iteration/iteration-builder.test.ts +252 -0
- package/src/markdown-iteration/iteration-builder.ts +303 -0
- package/src/markdown-iteration/iteration.test.ts +56 -0
- package/src/markdown-iteration/iteration.tsx +333 -0
- package/src/markdown-iteration/template-dialog.stories.tsx +63 -0
- package/src/markdown-iteration/template-dialog.test.tsx +104 -0
- package/src/markdown-iteration/template-dialog.tsx +116 -0
- package/src/markdown-outline/document-outline.stories.tsx +76 -0
- package/src/markdown-outline/document-outline.tsx +83 -0
- package/src/markdown-outline/index.ts +2 -0
- package/src/markdown-outline/markdown-outline.test.ts +49 -0
- package/src/markdown-outline/markdown-outline.ts +60 -0
- package/src/markdown-preview/code-fence.tsx +193 -0
- package/src/markdown-preview/index.ts +8 -0
- package/src/markdown-preview/markdown-preview-academic.stories.tsx +203 -0
- package/src/markdown-preview/markdown-preview-academic.test.tsx +257 -0
- package/src/markdown-preview/markdown-preview-iteration-calc.test.tsx +71 -0
- package/src/markdown-preview/markdown-preview-iteration-nested.test.tsx +58 -0
- package/src/markdown-preview/markdown-preview-iteration.stories.tsx +187 -0
- package/src/markdown-preview/markdown-preview-iteration.test.tsx +90 -0
- package/src/markdown-preview/markdown-preview-linking.test.tsx +61 -0
- package/src/markdown-preview/markdown-preview-transclusion.test.tsx +33 -0
- package/src/markdown-preview/markdown-preview.stories.tsx +108 -0
- package/src/markdown-preview/markdown-preview.test.tsx +411 -0
- package/src/markdown-preview/markdown-preview.tsx +1541 -0
- package/src/markdown-toolbar/index.ts +8 -0
- package/src/markdown-toolbar/markdown-commands.test.ts +84 -0
- package/src/markdown-toolbar/markdown-commands.ts +104 -0
- package/src/markdown-toolbar/markdown-toolbar.stories.tsx +56 -0
- package/src/markdown-toolbar/markdown-toolbar.tsx +263 -0
- package/src/markdown-workspace/focus-writing.test.ts +52 -0
- package/src/markdown-workspace/focus-writing.ts +38 -0
- package/src/markdown-workspace/index.ts +6 -0
- package/src/markdown-workspace/markdown-workspace.stories.tsx +688 -0
- package/src/markdown-workspace/markdown-workspace.test.tsx +326 -0
- package/src/markdown-workspace/markdown-workspace.tsx +787 -0
- package/src/mermaid-diagram/index.ts +1 -0
- package/src/mermaid-diagram/mermaid-diagram.stories.tsx +42 -0
- package/src/mermaid-diagram/mermaid-diagram.test.tsx +86 -0
- package/src/mermaid-diagram/mermaid-diagram.tsx +318 -0
- package/src/mermaid-diagram/mermaid-viewer.test.tsx +89 -0
- package/src/mermaid-diagram/mermaid-viewer.tsx +367 -0
- package/src/mermaid-diagram/real-parse.test.ts +42 -0
- package/src/mermaid-diagram/remediate.test.ts +60 -0
- package/src/mermaid-diagram/remediate.ts +124 -0
- package/src/mermaid-workspace/index.ts +1 -0
- package/src/mermaid-workspace/mermaid-workspace.stories.tsx +28 -0
- package/src/mermaid-workspace/mermaid-workspace.tsx +71 -0
- package/src/metric-block/index.ts +1 -0
- package/src/metric-block/metric-block.stories.tsx +40 -0
- package/src/metric-block/metric-block.tsx +14 -0
- package/src/monaco-workers.d.ts +10 -0
- package/src/prose/index.ts +14 -0
- package/src/prose/prose.stories.tsx +44 -0
- package/src/prose/prose.test.tsx +44 -0
- package/src/prose/prose.tsx +21 -0
- package/src/timeline/index.ts +18 -0
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Unit tests for monacoContentAccess — the Monaco adapter for EditorContentAccess.
|
|
3
|
+
*
|
|
4
|
+
* Monaco can't render in jsdom, so we mock the editor instance and assert the
|
|
5
|
+
* adapter's pure-logic contract (the markdown-commands.test.ts precedent).
|
|
6
|
+
* Live Monaco behavior (real selection round-trips) is verified in Storybook.
|
|
7
|
+
*/
|
|
8
|
+
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
9
|
+
|
|
10
|
+
// Mock monaco-editor (pure value types only; no rendering engine needed).
|
|
11
|
+
vi.mock("monaco-editor", () => ({
|
|
12
|
+
editor: {},
|
|
13
|
+
}));
|
|
14
|
+
|
|
15
|
+
import { monacoContentAccess } from "./editor-content-access";
|
|
16
|
+
|
|
17
|
+
// ---------------------------------------------------------------------------
|
|
18
|
+
// Minimal Monaco editor mock — mirrors the shape used by markdown-commands.test.ts
|
|
19
|
+
// ---------------------------------------------------------------------------
|
|
20
|
+
|
|
21
|
+
function makeMockEditor(selectedText = "", isEmpty = false) {
|
|
22
|
+
const selectionChangeHandlers: Array<() => void> = [];
|
|
23
|
+
const disposable = { dispose: vi.fn() };
|
|
24
|
+
|
|
25
|
+
const selection = {
|
|
26
|
+
isEmpty: vi.fn(() => isEmpty),
|
|
27
|
+
};
|
|
28
|
+
const model = {
|
|
29
|
+
getValueInRange: vi.fn(() => selectedText),
|
|
30
|
+
};
|
|
31
|
+
const editor = {
|
|
32
|
+
getModel: vi.fn(() => model),
|
|
33
|
+
getSelection: vi.fn(() => selection),
|
|
34
|
+
getValue: vi.fn(() => "full document text"),
|
|
35
|
+
executeEdits: vi.fn(),
|
|
36
|
+
pushUndoStop: vi.fn(),
|
|
37
|
+
focus: vi.fn(),
|
|
38
|
+
onDidChangeCursorSelection: vi.fn((cb: () => void) => {
|
|
39
|
+
selectionChangeHandlers.push(cb);
|
|
40
|
+
return disposable;
|
|
41
|
+
}),
|
|
42
|
+
};
|
|
43
|
+
return { editor, model, selection, disposable, selectionChangeHandlers };
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// ---------------------------------------------------------------------------
|
|
47
|
+
// Tests
|
|
48
|
+
// ---------------------------------------------------------------------------
|
|
49
|
+
|
|
50
|
+
beforeEach(() => vi.clearAllMocks());
|
|
51
|
+
|
|
52
|
+
describe("monacoContentAccess — getText", () => {
|
|
53
|
+
it("returns editor.getValue()", () => {
|
|
54
|
+
const { editor } = makeMockEditor();
|
|
55
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
56
|
+
const access = monacoContentAccess(editor as any);
|
|
57
|
+
expect(access.getText()).toBe("full document text");
|
|
58
|
+
expect(editor.getValue).toHaveBeenCalledTimes(1);
|
|
59
|
+
});
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
describe("monacoContentAccess — getSelection", () => {
|
|
63
|
+
it("maps getValueInRange to text, isEmpty() to empty:false when selected", () => {
|
|
64
|
+
const { editor } = makeMockEditor("bold text", false);
|
|
65
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
66
|
+
const access = monacoContentAccess(editor as any);
|
|
67
|
+
const sel = access.getSelection();
|
|
68
|
+
expect(sel.text).toBe("bold text");
|
|
69
|
+
expect(sel.empty).toBe(false);
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it("returns empty:true and text:'' for a collapsed caret", () => {
|
|
73
|
+
const { editor } = makeMockEditor("", true);
|
|
74
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
75
|
+
const access = monacoContentAccess(editor as any);
|
|
76
|
+
const sel = access.getSelection();
|
|
77
|
+
expect(sel.text).toBe("");
|
|
78
|
+
expect(sel.empty).toBe(true);
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it("returns empty:true when getSelection() returns null", () => {
|
|
82
|
+
const { editor } = makeMockEditor("", false);
|
|
83
|
+
editor.getSelection.mockReturnValue(null as unknown as ReturnType<typeof editor.getSelection>);
|
|
84
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
85
|
+
const access = monacoContentAccess(editor as any);
|
|
86
|
+
expect(access.getSelection()).toEqual({ text: "", empty: true });
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it("returns empty:true when getModel() returns null", () => {
|
|
90
|
+
const { editor } = makeMockEditor("text", false);
|
|
91
|
+
editor.getModel.mockReturnValue(null as unknown as ReturnType<typeof editor.getModel>);
|
|
92
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
93
|
+
const access = monacoContentAccess(editor as any);
|
|
94
|
+
expect(access.getSelection()).toEqual({ text: "", empty: true });
|
|
95
|
+
});
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
describe("monacoContentAccess — replaceSelection / insertAtCursor", () => {
|
|
99
|
+
it("replaceSelection calls executeEdits with the selection range + text", () => {
|
|
100
|
+
const { editor, selection } = makeMockEditor("old", false);
|
|
101
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
102
|
+
const access = monacoContentAccess(editor as any);
|
|
103
|
+
access.replaceSelection("new text");
|
|
104
|
+
expect(editor.executeEdits).toHaveBeenCalledWith("editor-content-access", [
|
|
105
|
+
{ range: selection, text: "new text", forceMoveMarkers: true },
|
|
106
|
+
]);
|
|
107
|
+
expect(editor.pushUndoStop).toHaveBeenCalledTimes(1);
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
it("insertAtCursor calls executeEdits identically (same primitive)", () => {
|
|
111
|
+
const { editor, selection } = makeMockEditor("", true);
|
|
112
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
113
|
+
const access = monacoContentAccess(editor as any);
|
|
114
|
+
access.insertAtCursor("inserted");
|
|
115
|
+
expect(editor.executeEdits).toHaveBeenCalledWith("editor-content-access", [
|
|
116
|
+
{ range: selection, text: "inserted", forceMoveMarkers: true },
|
|
117
|
+
]);
|
|
118
|
+
expect(editor.pushUndoStop).toHaveBeenCalledTimes(1);
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
it("no-ops when getSelection() returns null", () => {
|
|
122
|
+
const { editor } = makeMockEditor();
|
|
123
|
+
editor.getSelection.mockReturnValue(null as unknown as ReturnType<typeof editor.getSelection>);
|
|
124
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
125
|
+
const access = monacoContentAccess(editor as any);
|
|
126
|
+
access.replaceSelection("x");
|
|
127
|
+
expect(editor.executeEdits).not.toHaveBeenCalled();
|
|
128
|
+
});
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
describe("monacoContentAccess — focus", () => {
|
|
132
|
+
it("calls editor.focus()", () => {
|
|
133
|
+
const { editor } = makeMockEditor();
|
|
134
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
135
|
+
const access = monacoContentAccess(editor as any);
|
|
136
|
+
access.focus();
|
|
137
|
+
expect(editor.focus).toHaveBeenCalledTimes(1);
|
|
138
|
+
});
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
describe("monacoContentAccess — onSelectionChange", () => {
|
|
142
|
+
it("registers onDidChangeCursorSelection and fires the listener with a fresh snapshot", () => {
|
|
143
|
+
const { editor, selectionChangeHandlers, model, selection } = makeMockEditor("abc", false);
|
|
144
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
145
|
+
const access = monacoContentAccess(editor as any);
|
|
146
|
+
const listener = vi.fn();
|
|
147
|
+
access.onSelectionChange(listener);
|
|
148
|
+
|
|
149
|
+
expect(editor.onDidChangeCursorSelection).toHaveBeenCalledTimes(1);
|
|
150
|
+
|
|
151
|
+
// Simulate Monaco firing the selection-change event.
|
|
152
|
+
model.getValueInRange.mockReturnValue("xyz");
|
|
153
|
+
selectionChangeHandlers[0]?.();
|
|
154
|
+
|
|
155
|
+
expect(listener).toHaveBeenCalledWith({ text: "xyz", empty: false });
|
|
156
|
+
expect(selection.isEmpty).toHaveBeenCalled();
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
it("returns an unsubscribe fn that calls disposable.dispose()", () => {
|
|
160
|
+
const { editor, disposable } = makeMockEditor();
|
|
161
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
162
|
+
const access = monacoContentAccess(editor as any);
|
|
163
|
+
const unsub = access.onSelectionChange(vi.fn());
|
|
164
|
+
expect(disposable.dispose).not.toHaveBeenCalled();
|
|
165
|
+
unsub();
|
|
166
|
+
expect(disposable.dispose).toHaveBeenCalledTimes(1);
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
it("calling the unsubscribe calls dispose() on the Monaco IDisposable", () => {
|
|
170
|
+
// Contract: the adapter calls sub.dispose() when the caller unsubscribes.
|
|
171
|
+
// In production Monaco stops firing the handler after dispose(); in jsdom
|
|
172
|
+
// the mock doesn't truly gate on dispose, so we assert the dispose call
|
|
173
|
+
// rather than trying to gate the mock handler.
|
|
174
|
+
const { editor, disposable } = makeMockEditor();
|
|
175
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
176
|
+
const access = monacoContentAccess(editor as any);
|
|
177
|
+
const unsub = access.onSelectionChange(vi.fn());
|
|
178
|
+
expect(disposable.dispose).not.toHaveBeenCalled();
|
|
179
|
+
unsub();
|
|
180
|
+
expect(disposable.dispose).toHaveBeenCalledTimes(1);
|
|
181
|
+
});
|
|
182
|
+
});
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Engine-agnostic editor content-access interface + Monaco adapter.
|
|
3
|
+
*
|
|
4
|
+
* ISOLATION INVARIANT: this file imports ONLY `monaco-editor` and the
|
|
5
|
+
* `MonacoCodeEditor` type from `../code-editor`. It MUST NOT import
|
|
6
|
+
* `editor-content-access-prose.ts` or anything from `@milkdown`, or the
|
|
7
|
+
* `@milkdown` graph leaks into the `.` barrel (#271-inverse).
|
|
8
|
+
*/
|
|
9
|
+
import type { MonacoCodeEditor } from "../code-editor";
|
|
10
|
+
|
|
11
|
+
/** A snapshot of the editor's current selection. Text, not engine positions —
|
|
12
|
+
* that is what an AI assistant needs and it keeps the interface engine-agnostic. */
|
|
13
|
+
export interface EditorSelection {
|
|
14
|
+
/**
|
|
15
|
+
* The selected text. Plain text for Monaco. For the Milkdown WYSIWYG it is the
|
|
16
|
+
* selection SERIALIZED TO MARKDOWN by default (so `**bold**`, links, lists round-trip);
|
|
17
|
+
* pass `{ fidelity: "plainText" }` to the adapter to get `doc.textBetween` instead.
|
|
18
|
+
*/
|
|
19
|
+
text: string;
|
|
20
|
+
/** `true` when the selection is collapsed (a bare caret, nothing highlighted). */
|
|
21
|
+
empty: boolean;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Engine-agnostic, text-oriented access to an editor's content + selection — the
|
|
26
|
+
* uniform surface an external AI assistant drives across the Monaco code editors
|
|
27
|
+
* AND the Milkdown WYSIWYG markdown editor.
|
|
28
|
+
*
|
|
29
|
+
* D5: this manipulates EDITOR CONTENT only. It performs no model calls, no transport,
|
|
30
|
+
* no fetch. The app owns the AI call and APPLIES the result via these methods.
|
|
31
|
+
*
|
|
32
|
+
* `replaceSelection` and `insertAtCursor` are equivalent primitives (both replace the
|
|
33
|
+
* active range; an empty range is the caret) — two names for two reader intents.
|
|
34
|
+
*/
|
|
35
|
+
export interface EditorContentAccess {
|
|
36
|
+
/** The full document text (Monaco: source; Milkdown: serialized markdown). */
|
|
37
|
+
getText(): string;
|
|
38
|
+
/** A snapshot of the current selection (selected text + whether collapsed). */
|
|
39
|
+
getSelection(): EditorSelection;
|
|
40
|
+
/** Replace the current selection; a collapsed selection inserts at the caret. */
|
|
41
|
+
replaceSelection(text: string): void;
|
|
42
|
+
/**
|
|
43
|
+
* Insert at the caret; a non-empty selection is replaced (platform "typing" semantics).
|
|
44
|
+
* Equivalent to `replaceSelection` — two names for two reader intents (agent-legibility).
|
|
45
|
+
*/
|
|
46
|
+
insertAtCursor(text: string): void;
|
|
47
|
+
/** Move focus to the editing surface (so the user can keep typing after an apply). */
|
|
48
|
+
focus(): void;
|
|
49
|
+
/**
|
|
50
|
+
* Subscribe to selection changes. Fires with a fresh `EditorSelection` whenever the
|
|
51
|
+
* selection or caret moves. Returns an unsubscribe function — the caller MUST call it
|
|
52
|
+
* (e.g. in a React effect cleanup) to dispose the underlying engine listener.
|
|
53
|
+
*/
|
|
54
|
+
onSelectionChange(listener: (selection: EditorSelection) => void): () => void;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Wrap a live Monaco `IStandaloneCodeEditor` as the engine-agnostic
|
|
59
|
+
* {@link EditorContentAccess}. The `CodeEditor` / `DiffEditor` / `CodeWorkspace`
|
|
60
|
+
* `ref` already exposes the raw Monaco instance — pass it here to get the
|
|
61
|
+
* uniform shape used by the markdown surfaces.
|
|
62
|
+
*
|
|
63
|
+
* For a `DiffEditor`, pass the modified (editable) side:
|
|
64
|
+
* `monacoContentAccess(diffRef.current!.getModifiedEditor())`.
|
|
65
|
+
*
|
|
66
|
+
* @example
|
|
67
|
+
* const editorRef = useRef<MonacoCodeEditor>(null);
|
|
68
|
+
* // ...later
|
|
69
|
+
* const access = monacoContentAccess(editorRef.current!);
|
|
70
|
+
* access.insertAtCursor(aiText);
|
|
71
|
+
*/
|
|
72
|
+
export function monacoContentAccess(editor: MonacoCodeEditor): EditorContentAccess {
|
|
73
|
+
const readSelection = (): EditorSelection => {
|
|
74
|
+
const model = editor.getModel();
|
|
75
|
+
const selection = editor.getSelection();
|
|
76
|
+
if (!model || !selection) return { text: "", empty: true };
|
|
77
|
+
const text = model.getValueInRange(selection);
|
|
78
|
+
return { text, empty: selection.isEmpty() };
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Replace the active selection range. An empty selection (caret) inserts at
|
|
83
|
+
* the caret — one path covers both insert & replace (executeEdits over an empty
|
|
84
|
+
* range == insert at that position, exactly the markdown-commands.ts pattern).
|
|
85
|
+
*/
|
|
86
|
+
const applyAtSelection = (text: string): void => {
|
|
87
|
+
const selection = editor.getSelection();
|
|
88
|
+
if (!selection) return;
|
|
89
|
+
editor.executeEdits("editor-content-access", [
|
|
90
|
+
{ range: selection, text, forceMoveMarkers: true },
|
|
91
|
+
]);
|
|
92
|
+
// An explicit undo stop makes this AI apply independently undoable. The
|
|
93
|
+
// toolbar helpers omit it because they immediately re-select (their own stop);
|
|
94
|
+
// for the AI path an explicit stop is the right call.
|
|
95
|
+
editor.pushUndoStop();
|
|
96
|
+
};
|
|
97
|
+
|
|
98
|
+
return {
|
|
99
|
+
getText: () => editor.getValue(),
|
|
100
|
+
getSelection: readSelection,
|
|
101
|
+
replaceSelection: applyAtSelection,
|
|
102
|
+
insertAtCursor: applyAtSelection,
|
|
103
|
+
focus: () => editor.focus(),
|
|
104
|
+
onSelectionChange: (listener) => {
|
|
105
|
+
const sub = editor.onDidChangeCursorSelection(() => listener(readSelection()));
|
|
106
|
+
return () => sub.dispose();
|
|
107
|
+
},
|
|
108
|
+
};
|
|
109
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Curated language list for the editor toolbar's language picker. Ids are
|
|
3
|
+
* Monaco's built-in language ids (`monaco.languages.getLanguages()`), so they
|
|
4
|
+
* map straight onto `setModelLanguage` / model creation.
|
|
5
|
+
*/
|
|
6
|
+
export interface EditorLanguage {
|
|
7
|
+
/** Monaco language id (e.g. "typescript"). */
|
|
8
|
+
id: string;
|
|
9
|
+
/** Human-readable label for the picker. */
|
|
10
|
+
label: string;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export const EDITOR_LANGUAGES: EditorLanguage[] = [
|
|
14
|
+
{ id: "typescript", label: "TypeScript" },
|
|
15
|
+
{ id: "javascript", label: "JavaScript" },
|
|
16
|
+
{ id: "json", label: "JSON" },
|
|
17
|
+
{ id: "html", label: "HTML" },
|
|
18
|
+
{ id: "css", label: "CSS" },
|
|
19
|
+
{ id: "scss", label: "SCSS" },
|
|
20
|
+
{ id: "markdown", label: "Markdown" },
|
|
21
|
+
{ id: "python", label: "Python" },
|
|
22
|
+
{ id: "sql", label: "SQL" },
|
|
23
|
+
{ id: "yaml", label: "YAML" },
|
|
24
|
+
{ id: "xml", label: "XML" },
|
|
25
|
+
{ id: "shell", label: "Shell" },
|
|
26
|
+
{ id: "plaintext", label: "Plain text" },
|
|
27
|
+
];
|
|
28
|
+
|
|
29
|
+
/** Look up a language's label, falling back to the raw id. */
|
|
30
|
+
export function languageLabel(id: string): string {
|
|
31
|
+
return EDITOR_LANGUAGES.find((l) => l.id === id)?.label ?? id;
|
|
32
|
+
}
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
annotationForRange,
|
|
5
|
+
computeMarkdownAnnotations,
|
|
6
|
+
removedMarkerAt,
|
|
7
|
+
shiftAnnotations,
|
|
8
|
+
} from "./diff";
|
|
9
|
+
|
|
10
|
+
describe("computeMarkdownAnnotations", () => {
|
|
11
|
+
it("returns [] for identical inputs", () => {
|
|
12
|
+
expect(computeMarkdownAnnotations("a\nb", "a\nb")).toEqual([]);
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
it("flags pure additions with after-relative 1-based lines", () => {
|
|
16
|
+
const before = "one\ntwo";
|
|
17
|
+
const after = "one\nnew line\ntwo";
|
|
18
|
+
expect(computeMarkdownAnnotations(before, after)).toEqual([
|
|
19
|
+
{ kind: "added", startLine: 2, endLine: 2 },
|
|
20
|
+
]);
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
it("flags replaced runs as modified", () => {
|
|
24
|
+
const before = "# Title\n\nold paragraph\n";
|
|
25
|
+
const after = "# Title\n\nnew paragraph\n";
|
|
26
|
+
expect(computeMarkdownAnnotations(before, after)).toEqual([
|
|
27
|
+
{ kind: "modified", startLine: 3, endLine: 3 },
|
|
28
|
+
]);
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it("anchors pure deletions on the next surviving line", () => {
|
|
32
|
+
const before = "one\ngone\ngone too\ntwo";
|
|
33
|
+
const after = "one\ntwo";
|
|
34
|
+
expect(computeMarkdownAnnotations(before, after)).toEqual([
|
|
35
|
+
{ kind: "removed-before", startLine: 2, endLine: 2, removedCount: 2 },
|
|
36
|
+
]);
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it("clamps a trailing deletion to the last line", () => {
|
|
40
|
+
const before = "one\ntwo\nthree";
|
|
41
|
+
const after = "one";
|
|
42
|
+
const [ann] = computeMarkdownAnnotations(before, after);
|
|
43
|
+
expect(ann).toEqual({ kind: "removed-before", startLine: 1, endLine: 1, removedCount: 2 });
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it("merges adjacent hunks of the same kind", () => {
|
|
47
|
+
const before = "a\nb\nc";
|
|
48
|
+
const after = "a\nx\ny\nb\nz\nc";
|
|
49
|
+
const anns = computeMarkdownAnnotations(before, after);
|
|
50
|
+
expect(anns.every((x) => x.kind === "added")).toBe(true);
|
|
51
|
+
});
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
describe("shiftAnnotations", () => {
|
|
55
|
+
it("shifts by the frontmatter offset and drops swallowed hunks", () => {
|
|
56
|
+
const anns = shiftAnnotations(
|
|
57
|
+
[
|
|
58
|
+
{ kind: "added" as const, startLine: 2, endLine: 3 },
|
|
59
|
+
{ kind: "added" as const, startLine: 6, endLine: 7 },
|
|
60
|
+
],
|
|
61
|
+
4,
|
|
62
|
+
);
|
|
63
|
+
expect(anns).toEqual([{ kind: "added", startLine: 2, endLine: 3 }]);
|
|
64
|
+
});
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
describe("annotationForRange / removedMarkerAt", () => {
|
|
68
|
+
const anns = [
|
|
69
|
+
{ kind: "added" as const, startLine: 3, endLine: 5 },
|
|
70
|
+
{ kind: "removed-before" as const, startLine: 8, endLine: 8, removedCount: 2 },
|
|
71
|
+
];
|
|
72
|
+
it("finds overlapping wash", () => {
|
|
73
|
+
expect(annotationForRange(anns, 5, 6)?.kind).toBe("added");
|
|
74
|
+
expect(annotationForRange(anns, 6, 7)).toBeUndefined();
|
|
75
|
+
});
|
|
76
|
+
it("finds the removed marker only at its anchor line", () => {
|
|
77
|
+
expect(removedMarkerAt(anns, 8)?.removedCount).toBe(2);
|
|
78
|
+
expect(removedMarkerAt(anns, 9)).toBeUndefined();
|
|
79
|
+
});
|
|
80
|
+
});
|
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Line-level markdown diff → block annotations for `MarkdownPreview` (#L18).
|
|
3
|
+
*
|
|
4
|
+
* `computeMarkdownAnnotations(before, after)` diffs two markdown sources and
|
|
5
|
+
* returns the annotation set the preview renders as a "ghost diff": added /
|
|
6
|
+
* modified blocks get a wash + accent rail, pure deletions become a slim
|
|
7
|
+
* "removed here" marker before the next surviving block.
|
|
8
|
+
*
|
|
9
|
+
* Lines are 1-based and refer to the AFTER source **including** any YAML
|
|
10
|
+
* frontmatter — `MarkdownPreview` shifts them when it strips frontmatter, so
|
|
11
|
+
* callers never have to think about the offset.
|
|
12
|
+
*
|
|
13
|
+
* The diff is a classic LCS (O(n·m) DP) — markdown documents are
|
|
14
|
+
* authoring-sized. Inputs beyond {@link MAX_DIFF_LINES} lines fall back to an
|
|
15
|
+
* empty annotation set (the UI simply shows no wash) instead of freezing.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
export type MarkdownAnnotationKind = "added" | "modified" | "removed-before";
|
|
19
|
+
|
|
20
|
+
export interface MarkdownAnnotation {
|
|
21
|
+
kind: MarkdownAnnotationKind;
|
|
22
|
+
/**
|
|
23
|
+
* For `added`/`modified`: the 1-based inclusive line range in the AFTER
|
|
24
|
+
* source. For `removed-before`: `startLine === endLine` is the line in the
|
|
25
|
+
* AFTER source that now sits where the removed content used to be.
|
|
26
|
+
*/
|
|
27
|
+
startLine: number;
|
|
28
|
+
endLine: number;
|
|
29
|
+
/** For `removed-before`: how many lines were removed. */
|
|
30
|
+
removedCount?: number;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** Above this many lines on either side the diff degrades to "no annotations". */
|
|
34
|
+
export const MAX_DIFF_LINES = 5000;
|
|
35
|
+
|
|
36
|
+
const splitLines = (s: string): string[] => s.split("\n");
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* LCS keep-table via dynamic programming. Returns pairs of kept (before-index,
|
|
40
|
+
* after-index) in ascending order. Indices are 0-based.
|
|
41
|
+
*
|
|
42
|
+
* Exported for the normalization-aware merge (`merge.ts`) — not public API.
|
|
43
|
+
*/
|
|
44
|
+
export function lcsPairs(a: string[], b: string[]): [number, number][] {
|
|
45
|
+
const n = a.length;
|
|
46
|
+
const m = b.length;
|
|
47
|
+
// dp rows as typed arrays to keep memory flat.
|
|
48
|
+
const dp: Uint32Array[] = Array.from({ length: n + 1 }, () => new Uint32Array(m + 1));
|
|
49
|
+
for (let i = n - 1; i >= 0; i--) {
|
|
50
|
+
const row = dp[i]!;
|
|
51
|
+
const next = dp[i + 1]!;
|
|
52
|
+
for (let j = m - 1; j >= 0; j--) {
|
|
53
|
+
row[j] = a[i] === b[j] ? next[j + 1]! + 1 : Math.max(next[j]!, row[j + 1]!);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
const pairs: [number, number][] = [];
|
|
57
|
+
let i = 0;
|
|
58
|
+
let j = 0;
|
|
59
|
+
while (i < n && j < m) {
|
|
60
|
+
if (a[i] === b[j]) {
|
|
61
|
+
pairs.push([i, j]);
|
|
62
|
+
i++;
|
|
63
|
+
j++;
|
|
64
|
+
} else if (dp[i + 1]![j]! >= dp[i]![j + 1]!) {
|
|
65
|
+
i++;
|
|
66
|
+
} else {
|
|
67
|
+
j++;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
return pairs;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Diff two markdown sources into preview annotations.
|
|
75
|
+
*
|
|
76
|
+
* Hunk semantics: a run of only-added lines → `added`; a run that replaces
|
|
77
|
+
* removed lines → `modified`; a run of only-removed lines → `removed-before`
|
|
78
|
+
* anchored on the first surviving AFTER line at-or-after the removal.
|
|
79
|
+
*/
|
|
80
|
+
export function computeMarkdownAnnotations(before: string, after: string): MarkdownAnnotation[] {
|
|
81
|
+
if (before === after) return [];
|
|
82
|
+
const a = splitLines(before);
|
|
83
|
+
const b = splitLines(after);
|
|
84
|
+
if (a.length > MAX_DIFF_LINES || b.length > MAX_DIFF_LINES) return [];
|
|
85
|
+
|
|
86
|
+
const pairs = lcsPairs(a, b);
|
|
87
|
+
const annotations: MarkdownAnnotation[] = [];
|
|
88
|
+
|
|
89
|
+
let prevA = -1;
|
|
90
|
+
let prevB = -1;
|
|
91
|
+
// Sentinel pair past the end flushes the trailing hunk.
|
|
92
|
+
const walk: [number, number][] = [...pairs, [a.length, b.length]];
|
|
93
|
+
for (const [ai, bi] of walk) {
|
|
94
|
+
const removed = ai - prevA - 1;
|
|
95
|
+
const added = bi - prevB - 1;
|
|
96
|
+
if (added > 0 && removed > 0) {
|
|
97
|
+
annotations.push({ kind: "modified", startLine: prevB + 2, endLine: bi });
|
|
98
|
+
} else if (added > 0) {
|
|
99
|
+
annotations.push({ kind: "added", startLine: prevB + 2, endLine: bi });
|
|
100
|
+
} else if (removed > 0) {
|
|
101
|
+
// Anchor on the next surviving AFTER line (1-based); clamp into range.
|
|
102
|
+
const anchor = Math.min(bi + 1, b.length);
|
|
103
|
+
annotations.push({
|
|
104
|
+
kind: "removed-before",
|
|
105
|
+
startLine: anchor,
|
|
106
|
+
endLine: anchor,
|
|
107
|
+
removedCount: removed,
|
|
108
|
+
});
|
|
109
|
+
}
|
|
110
|
+
prevA = ai;
|
|
111
|
+
prevB = bi;
|
|
112
|
+
}
|
|
113
|
+
return mergeAdjacent(annotations);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** Honest "+a −r" totals for an annotation set (commit churn, drift rows). */
|
|
117
|
+
export function summarizeAnnotations(annotations: MarkdownAnnotation[]): {
|
|
118
|
+
added: number;
|
|
119
|
+
removed: number;
|
|
120
|
+
} {
|
|
121
|
+
let added = 0;
|
|
122
|
+
let removed = 0;
|
|
123
|
+
for (const a of annotations) {
|
|
124
|
+
const span = a.endLine - a.startLine + 1;
|
|
125
|
+
if (a.kind === "added") added += span;
|
|
126
|
+
else if (a.kind === "modified") {
|
|
127
|
+
added += span;
|
|
128
|
+
removed += span;
|
|
129
|
+
} else if (a.kind === "removed-before") removed += a.removedCount ?? 1;
|
|
130
|
+
}
|
|
131
|
+
return { added, removed };
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** Merge touching/overlapping wash hunks of the same kind (keeps the DOM quiet). */
|
|
135
|
+
function mergeAdjacent(annotations: MarkdownAnnotation[]): MarkdownAnnotation[] {
|
|
136
|
+
const out: MarkdownAnnotation[] = [];
|
|
137
|
+
for (const ann of annotations) {
|
|
138
|
+
const last = out[out.length - 1];
|
|
139
|
+
if (
|
|
140
|
+
last &&
|
|
141
|
+
last.kind !== "removed-before" &&
|
|
142
|
+
last.kind === ann.kind &&
|
|
143
|
+
ann.startLine <= last.endLine + 1
|
|
144
|
+
) {
|
|
145
|
+
last.endLine = Math.max(last.endLine, ann.endLine);
|
|
146
|
+
} else {
|
|
147
|
+
out.push({ ...ann });
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
return out;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* Shift annotation lines by `-offset` (used when frontmatter is stripped before
|
|
155
|
+
* rendering). Annotations that fall entirely inside the stripped region drop out.
|
|
156
|
+
*/
|
|
157
|
+
export function shiftAnnotations(
|
|
158
|
+
annotations: MarkdownAnnotation[],
|
|
159
|
+
offset: number,
|
|
160
|
+
): MarkdownAnnotation[] {
|
|
161
|
+
if (offset === 0) return annotations;
|
|
162
|
+
const out: MarkdownAnnotation[] = [];
|
|
163
|
+
for (const ann of annotations) {
|
|
164
|
+
const startLine = ann.startLine - offset;
|
|
165
|
+
const endLine = ann.endLine - offset;
|
|
166
|
+
if (endLine < 1) continue;
|
|
167
|
+
out.push({ ...ann, startLine: Math.max(1, startLine), endLine });
|
|
168
|
+
}
|
|
169
|
+
return out;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/** Does the annotation set wash the given block line range? Most specific wins. */
|
|
173
|
+
export function annotationForRange(
|
|
174
|
+
annotations: MarkdownAnnotation[],
|
|
175
|
+
startLine: number,
|
|
176
|
+
endLine: number,
|
|
177
|
+
): MarkdownAnnotation | undefined {
|
|
178
|
+
let hit: MarkdownAnnotation | undefined;
|
|
179
|
+
for (const ann of annotations) {
|
|
180
|
+
if (ann.kind === "removed-before") continue;
|
|
181
|
+
if (ann.startLine <= endLine && ann.endLine >= startLine) {
|
|
182
|
+
if (!hit || ann.endLine - ann.startLine < hit.endLine - hit.startLine) hit = ann;
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
return hit;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/** The removed-marker (if any) anchored exactly at this block's first line. */
|
|
189
|
+
export function removedMarkerAt(
|
|
190
|
+
annotations: MarkdownAnnotation[],
|
|
191
|
+
startLine: number,
|
|
192
|
+
): MarkdownAnnotation | undefined {
|
|
193
|
+
return annotations.find((a) => a.kind === "removed-before" && a.startLine === startLine);
|
|
194
|
+
}
|