@reviewflow/ui 0.0.0-stage → 6.21.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 +15 -0
- package/README.md +95 -2
- package/package.json +102 -4
- package/src/board/bucket-section.tsx +94 -0
- package/src/board/dataSource.ts +44 -0
- package/src/board/facts.ts +183 -0
- package/src/board/index.ts +12 -0
- package/src/board/pull-request-board.tsx +239 -0
- package/src/board/pull-request-row.tsx +181 -0
- package/src/components/branch-diff/diff-themes.ts +6 -0
- package/src/components/branch-diff/diff-worker-pool.tsx +12 -0
- package/src/components/branch-diff/diff-worker-pool.web.tsx +56 -0
- package/src/components/branch-diff/diff-worker.ts +7 -0
- package/src/components/branch-diff/use-collapsed-files.ts +49 -0
- package/src/components/branch-diff/use-diff-settings.tsx +97 -0
- package/src/components/branch-diff/whitespace-button.tsx +32 -0
- package/src/components/copyable-code-block.tsx +101 -0
- package/src/components/label-pill.tsx +84 -0
- package/src/components/link-in-row.ts +6 -0
- package/src/components/markdown-suggestion.tsx +60 -0
- package/src/components/markdown-text.tsx +1067 -0
- package/src/components/pull-request-actions-merge.tsx +341 -0
- package/src/components/pull-request-actions-review.tsx +218 -0
- package/src/components/pull-request-checks.tsx +86 -0
- package/src/components/pull-request-commit-files.ts +143 -0
- package/src/components/pull-request-commits.tsx +221 -0
- package/src/components/pull-request-conversation-card.tsx +220 -0
- package/src/components/pull-request-conversation-edit.tsx +147 -0
- package/src/components/pull-request-conversation-event.tsx +267 -0
- package/src/components/pull-request-conversation-person.tsx +28 -0
- package/src/components/pull-request-conversation-thread.tsx +158 -0
- package/src/components/pull-request-diff-annotations.tsx +443 -0
- package/src/components/pull-request-diff-collapse.ts +43 -0
- package/src/components/pull-request-diff-data.ts +174 -0
- package/src/components/pull-request-diff-impacts-summary.tsx +97 -0
- package/src/components/pull-request-diff-impacts.ts +45 -0
- package/src/components/pull-request-diff-jump.ts +87 -0
- package/src/components/pull-request-diff-lines.ts +103 -0
- package/src/components/pull-request-diff-overview.tsx +416 -0
- package/src/components/pull-request-diff-view.tsx +438 -0
- package/src/components/pull-request-diff-viewer.tsx +1374 -0
- package/src/components/pull-request-files.tsx +533 -0
- package/src/components/pull-request-markdown-input.tsx +91 -0
- package/src/components/pull-request-panel.tsx +1270 -0
- package/src/components/pull-request-part-notice.tsx +82 -0
- package/src/components/pull-request-reactions.tsx +178 -0
- package/src/components/pull-request-review.tsx +164 -0
- package/src/components/pull-request-revisions.tsx +273 -0
- package/src/components/pull-request-screen.tsx +299 -0
- package/src/components/pull-request-suggestion-actions.tsx +201 -0
- package/src/components/pull-request-timeline.tsx +270 -0
- package/src/components/repository-attachment-banner.tsx +42 -0
- package/src/components/scroll-parent.ts +50 -0
- package/src/css.d.ts +1 -0
- package/src/dataSource.ts +242 -0
- package/src/editor/alerts.ts +105 -0
- package/src/editor/code-editor.native.tsx +25 -0
- package/src/editor/code-editor.tsx +112 -0
- package/src/editor/codeEditorExtensions.ts +65 -0
- package/src/editor/codeHighlight.ts +71 -0
- package/src/editor/commands.ts +175 -0
- package/src/editor/decorations.ts +425 -0
- package/src/editor/editable-file.tsx +224 -0
- package/src/editor/emojiCompletion.ts +110 -0
- package/src/editor/extensions.ts +149 -0
- package/src/editor/frontmatter.ts +76 -0
- package/src/editor/inlineFormat.ts +114 -0
- package/src/editor/lineTypes.ts +55 -0
- package/src/editor/markdown-editor.css +511 -0
- package/src/editor/markdown-editor.native.tsx +29 -0
- package/src/editor/markdown-editor.tsx +295 -0
- package/src/editor/nodeClasses.ts +69 -0
- package/src/index.ts +75 -0
- package/src/markdown-github/flatten.ts +49 -0
- package/src/markdown-github/html-tokens.ts +93 -0
- package/src/markdown-github/pair-html.ts +354 -0
- package/src/markdown-github/safe-url.ts +15 -0
- package/src/markdown-github/table-columns.ts +23 -0
- package/src/markdown-github/toggle-task.ts +164 -0
- package/src/nativewind-env.d.ts +1 -0
- package/src/prStatus.ts +90 -0
- package/src/services/PullRequestDraftsProvider.tsx +179 -0
- package/src/services/PullRequestReviewDraftProvider.tsx +178 -0
- package/src/services/PullRequestSuggestionBatchProvider.tsx +112 -0
- package/src/services/emojify.ts +13 -0
- package/src/services/errorToMessage.ts +6 -0
- package/src/services/expiringDraftStore.ts +102 -0
- package/src/services/formatDataAge.ts +12 -0
- package/src/services/formatRelativeDate.ts +56 -0
- package/src/services/githubImages.tsx +33 -0
- package/src/services/isViewerAuthor.ts +7 -0
- package/src/slots.tsx +34 -0
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import type { EditorState } from "@codemirror/state";
|
|
2
|
+
import type { SyntaxNodeRef } from "@lezer/common";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* GitHub alerts (`> [!NOTE]` … `> [!CAUTION]`) are ordinary blockquotes to
|
|
6
|
+
* CommonMark, so they are read off a `Blockquote` node's first line rather than
|
|
7
|
+
* parsed by a block parser of their own, which would have to reimplement
|
|
8
|
+
* blockquote continuation and nesting. Ported from
|
|
9
|
+
* vscode-extension-markdown-beautiful-editor (`src/shared/alerts.ts`,
|
|
10
|
+
* `src/webview/cm/decorations/alerts.ts`).
|
|
11
|
+
*/
|
|
12
|
+
export type AlertType = "caution" | "important" | "note" | "tip" | "warning";
|
|
13
|
+
|
|
14
|
+
const alertHeaderPattern =
|
|
15
|
+
/^>\s*\[!(?<type>NOTE|TIP|IMPORTANT|WARNING|CAUTION)\]\s*$/iu;
|
|
16
|
+
|
|
17
|
+
export const parseAlertHeader = (line: string): AlertType | null => {
|
|
18
|
+
const type = alertHeaderPattern.exec(line)?.groups?.type;
|
|
19
|
+
return type === undefined ? null : (type.toLowerCase() as AlertType);
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* The classes for one line of an alert block. The header and the last line
|
|
24
|
+
* carry boundary classes, so the corners are rounded without a wrapper
|
|
25
|
+
* element: a line decoration has none to give.
|
|
26
|
+
*/
|
|
27
|
+
const alertLineClasses = (
|
|
28
|
+
type: AlertType,
|
|
29
|
+
index: number,
|
|
30
|
+
total: number,
|
|
31
|
+
): string[] => {
|
|
32
|
+
const isLast = index === total - 1;
|
|
33
|
+
const classes =
|
|
34
|
+
index === 0
|
|
35
|
+
? ["md-alert", `md-alert-${type}`, "alert-first"]
|
|
36
|
+
: ["md-alert-content", `md-alert-${type}`];
|
|
37
|
+
if (isLast) classes.push("alert-last");
|
|
38
|
+
if (isLast && index === 0) classes.push("alert-single");
|
|
39
|
+
return classes;
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
export interface AlertLine {
|
|
43
|
+
/** the line's start position */
|
|
44
|
+
from: number;
|
|
45
|
+
classes: string[];
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export const alertLinesOfBlockquote = (
|
|
49
|
+
state: EditorState,
|
|
50
|
+
blockquote: SyntaxNodeRef,
|
|
51
|
+
): AlertLine[] => {
|
|
52
|
+
const firstLine = state.doc.lineAt(blockquote.from);
|
|
53
|
+
const type = parseAlertHeader(firstLine.text);
|
|
54
|
+
if (type === null) return [];
|
|
55
|
+
|
|
56
|
+
const lastLine = state.doc.lineAt(
|
|
57
|
+
Math.max(blockquote.from, blockquote.to - 1),
|
|
58
|
+
);
|
|
59
|
+
const total = lastLine.number - firstLine.number + 1;
|
|
60
|
+
|
|
61
|
+
return Array.from({ length: total }, (_, index) => ({
|
|
62
|
+
from: state.doc.line(firstLine.number + index).from,
|
|
63
|
+
classes: alertLineClasses(type, index, total),
|
|
64
|
+
}));
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
export interface AlertHeaderMark {
|
|
68
|
+
from: number;
|
|
69
|
+
to: number;
|
|
70
|
+
className: "md-alert-type" | "md-syntax";
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* The `NOTE` label and its `[!` / `]` delimiters, in document positions. The
|
|
75
|
+
* leading `>` is a `QuoteMark` and is muted by the node map already.
|
|
76
|
+
*/
|
|
77
|
+
export const alertHeaderMarks = (
|
|
78
|
+
state: EditorState,
|
|
79
|
+
blockquote: SyntaxNodeRef,
|
|
80
|
+
): AlertHeaderMark[] => {
|
|
81
|
+
const line = state.doc.lineAt(blockquote.from);
|
|
82
|
+
if (parseAlertHeader(line.text) === null) return [];
|
|
83
|
+
|
|
84
|
+
const open = line.text.indexOf("[!");
|
|
85
|
+
const close = line.text.indexOf("]", open);
|
|
86
|
+
if (open === -1 || close === -1) return [];
|
|
87
|
+
|
|
88
|
+
return [
|
|
89
|
+
{
|
|
90
|
+
from: line.from + open,
|
|
91
|
+
to: line.from + open + 2,
|
|
92
|
+
className: "md-syntax",
|
|
93
|
+
},
|
|
94
|
+
{
|
|
95
|
+
from: line.from + open + 2,
|
|
96
|
+
to: line.from + close,
|
|
97
|
+
className: "md-alert-type",
|
|
98
|
+
},
|
|
99
|
+
{
|
|
100
|
+
from: line.from + close,
|
|
101
|
+
to: line.from + close + 1,
|
|
102
|
+
className: "md-syntax",
|
|
103
|
+
},
|
|
104
|
+
];
|
|
105
|
+
};
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { TextArea } from "alouette";
|
|
2
|
+
import type { ReactNode } from "react";
|
|
3
|
+
import type { CodeEditorProps } from "./code-editor.tsx";
|
|
4
|
+
|
|
5
|
+
const noop = (): void => undefined;
|
|
6
|
+
|
|
7
|
+
/** What metro picks on a native platform: CodeMirror needs a DOM, so a plain `TextArea`. */
|
|
8
|
+
export function CodeEditor({
|
|
9
|
+
value,
|
|
10
|
+
onChange,
|
|
11
|
+
readOnly = false,
|
|
12
|
+
"aria-label": ariaLabel,
|
|
13
|
+
autoFocus = false,
|
|
14
|
+
}: CodeEditorProps): ReactNode {
|
|
15
|
+
return (
|
|
16
|
+
<TextArea
|
|
17
|
+
aria-label={ariaLabel}
|
|
18
|
+
// oxlint-disable-next-line jsx-a11y/no-autofocus -- forwarded from the caller, as on web
|
|
19
|
+
autoFocus={autoFocus}
|
|
20
|
+
disabled={readOnly}
|
|
21
|
+
value={value}
|
|
22
|
+
onChangeText={onChange ?? noop}
|
|
23
|
+
/>
|
|
24
|
+
);
|
|
25
|
+
}
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import { EditorView } from "@codemirror/view";
|
|
2
|
+
import { View } from "alouette";
|
|
3
|
+
import type { ReactNode } from "react";
|
|
4
|
+
import { useEffect, useEffectEvent, useRef } from "react";
|
|
5
|
+
import type { CodeLanguage } from "./codeEditorExtensions.ts";
|
|
6
|
+
import { codeEditorExtensions } from "./codeEditorExtensions.ts";
|
|
7
|
+
import { changeBetween, externalUpdate } from "./extensions.ts";
|
|
8
|
+
import "./markdown-editor.css";
|
|
9
|
+
|
|
10
|
+
export interface CodeEditorProps {
|
|
11
|
+
value: string;
|
|
12
|
+
/** omitted for a read-only editor */
|
|
13
|
+
onChange?: (value: string) => void;
|
|
14
|
+
language: CodeLanguage;
|
|
15
|
+
readOnly?: boolean;
|
|
16
|
+
"aria-label": string;
|
|
17
|
+
/** on the column holding the editor, which stretches to fill it */
|
|
18
|
+
className?: string;
|
|
19
|
+
/** read at mount, like `readOnly` */
|
|
20
|
+
autoFocus?: boolean;
|
|
21
|
+
/**
|
|
22
|
+
* An offset in `value` to put the cursor at, and to focus. Applied when the
|
|
23
|
+
* number changes (and at mount), never on a keystroke; `null` leaves the
|
|
24
|
+
* selection alone.
|
|
25
|
+
*/
|
|
26
|
+
cursor?: number | null;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Plain code (json or markdown, shown as it is) in CodeMirror, on the markdown
|
|
31
|
+
* editor's base and theme. **Web only**, mounted into a raw `div` for the
|
|
32
|
+
* reason `MarkdownEditor` gives; `code-editor.native.tsx` is the native
|
|
33
|
+
* fallback. `readOnly` and `language` are read at mount: remount by key to
|
|
34
|
+
* change them. A `value` that changes under a mounted editor is applied as one
|
|
35
|
+
* change and not reported back through `onChange`.
|
|
36
|
+
*/
|
|
37
|
+
export function CodeEditor({
|
|
38
|
+
value,
|
|
39
|
+
onChange,
|
|
40
|
+
language,
|
|
41
|
+
readOnly = false,
|
|
42
|
+
"aria-label": ariaLabel,
|
|
43
|
+
className,
|
|
44
|
+
autoFocus = false,
|
|
45
|
+
cursor = null,
|
|
46
|
+
}: CodeEditorProps): ReactNode {
|
|
47
|
+
const hostRef = useRef<HTMLDivElement>(null);
|
|
48
|
+
const viewRef = useRef<EditorView | null>(null);
|
|
49
|
+
const emitChange = useEffectEvent((next: string) => onChange?.(next));
|
|
50
|
+
const readValue = useEffectEvent(() => value);
|
|
51
|
+
const autoFocusRef = useRef(autoFocus);
|
|
52
|
+
|
|
53
|
+
useEffect(() => {
|
|
54
|
+
const host = hostRef.current;
|
|
55
|
+
if (host === null) return undefined;
|
|
56
|
+
const view = new EditorView({
|
|
57
|
+
parent: host,
|
|
58
|
+
// see MarkdownEditor: the selection lives in the host's own tree
|
|
59
|
+
root: host.getRootNode() as Document | ShadowRoot,
|
|
60
|
+
doc: readValue(),
|
|
61
|
+
extensions: [
|
|
62
|
+
codeEditorExtensions({
|
|
63
|
+
language,
|
|
64
|
+
readOnly,
|
|
65
|
+
onChange: (next) => {
|
|
66
|
+
emitChange(next);
|
|
67
|
+
},
|
|
68
|
+
}),
|
|
69
|
+
EditorView.contentAttributes.of({ "aria-label": ariaLabel }),
|
|
70
|
+
],
|
|
71
|
+
});
|
|
72
|
+
viewRef.current = view;
|
|
73
|
+
if (autoFocusRef.current) view.focus();
|
|
74
|
+
return () => {
|
|
75
|
+
view.destroy();
|
|
76
|
+
viewRef.current = null;
|
|
77
|
+
};
|
|
78
|
+
}, [language, readOnly, ariaLabel]);
|
|
79
|
+
|
|
80
|
+
useEffect(() => {
|
|
81
|
+
const view = viewRef.current;
|
|
82
|
+
if (view === null) return;
|
|
83
|
+
const changes = changeBetween(view.state.doc.toString(), value);
|
|
84
|
+
if (changes === null) return;
|
|
85
|
+
view.dispatch({ changes, annotations: externalUpdate.of(true) });
|
|
86
|
+
}, [value]);
|
|
87
|
+
|
|
88
|
+
const readCursor = useEffectEvent(() => cursor);
|
|
89
|
+
useEffect(() => {
|
|
90
|
+
const view = viewRef.current;
|
|
91
|
+
const offset = readCursor();
|
|
92
|
+
if (view === null || offset === null) return;
|
|
93
|
+
view.dispatch({
|
|
94
|
+
selection: { anchor: Math.min(offset, view.state.doc.length) },
|
|
95
|
+
scrollIntoView: true,
|
|
96
|
+
});
|
|
97
|
+
view.focus();
|
|
98
|
+
}, [cursor, language, readOnly, ariaLabel]);
|
|
99
|
+
|
|
100
|
+
return (
|
|
101
|
+
<View className={className}>
|
|
102
|
+
<div
|
|
103
|
+
ref={hostRef}
|
|
104
|
+
className={
|
|
105
|
+
readOnly
|
|
106
|
+
? "markdown-editor markdown-editor-code markdown-editor-readonly"
|
|
107
|
+
: "markdown-editor markdown-editor-code"
|
|
108
|
+
}
|
|
109
|
+
/>
|
|
110
|
+
</View>
|
|
111
|
+
);
|
|
112
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { closeBrackets, closeBracketsKeymap } from "@codemirror/autocomplete";
|
|
2
|
+
import { defaultKeymap, history, historyKeymap } from "@codemirror/commands";
|
|
3
|
+
import { json } from "@codemirror/lang-json";
|
|
4
|
+
import { bracketMatching, indentOnInput } from "@codemirror/language";
|
|
5
|
+
import {
|
|
6
|
+
highlightSelectionMatches,
|
|
7
|
+
search,
|
|
8
|
+
searchKeymap,
|
|
9
|
+
} from "@codemirror/search";
|
|
10
|
+
import type { Extension } from "@codemirror/state";
|
|
11
|
+
import { EditorState } from "@codemirror/state";
|
|
12
|
+
import { EditorView, drawSelection, keymap } from "@codemirror/view";
|
|
13
|
+
import { codeHighlighting } from "./codeHighlight.ts";
|
|
14
|
+
import { externalUpdate, markdownLanguage } from "./extensions.ts";
|
|
15
|
+
|
|
16
|
+
export type CodeLanguage = "json" | "markdown";
|
|
17
|
+
|
|
18
|
+
interface CodeEditorExtensionsOptions {
|
|
19
|
+
language: CodeLanguage;
|
|
20
|
+
readOnly: boolean;
|
|
21
|
+
onChange: (value: string) => void;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Plain code on the markdown editor's base: the same highlighting classes and
|
|
26
|
+
* the same frame (`markdown-editor.css`), but no styling of the text in place —
|
|
27
|
+
* the text is shown as it is. Chosen once at mount, like the markdown editor's:
|
|
28
|
+
* a caller switching `language` or `readOnly` remounts by key.
|
|
29
|
+
*/
|
|
30
|
+
export const codeEditorExtensions = ({
|
|
31
|
+
language,
|
|
32
|
+
readOnly,
|
|
33
|
+
onChange,
|
|
34
|
+
}: CodeEditorExtensionsOptions): Extension[] => [
|
|
35
|
+
language === "json" ? json() : markdownLanguage(),
|
|
36
|
+
codeHighlighting,
|
|
37
|
+
EditorView.lineWrapping,
|
|
38
|
+
drawSelection(),
|
|
39
|
+
bracketMatching(),
|
|
40
|
+
highlightSelectionMatches(),
|
|
41
|
+
search({ top: true }),
|
|
42
|
+
EditorState.readOnly.of(readOnly),
|
|
43
|
+
EditorView.editable.of(!readOnly),
|
|
44
|
+
...(readOnly
|
|
45
|
+
? [keymap.of([...searchKeymap, ...defaultKeymap])]
|
|
46
|
+
: [
|
|
47
|
+
history(),
|
|
48
|
+
indentOnInput(),
|
|
49
|
+
...(language === "json" ? [closeBrackets()] : []),
|
|
50
|
+
keymap.of([
|
|
51
|
+
...closeBracketsKeymap,
|
|
52
|
+
...searchKeymap,
|
|
53
|
+
...historyKeymap,
|
|
54
|
+
...defaultKeymap,
|
|
55
|
+
]),
|
|
56
|
+
EditorView.updateListener.of((update) => {
|
|
57
|
+
const typed = update.transactions.some(
|
|
58
|
+
(transaction) =>
|
|
59
|
+
transaction.docChanged &&
|
|
60
|
+
transaction.annotation(externalUpdate) !== true,
|
|
61
|
+
);
|
|
62
|
+
if (typed) onChange(update.state.doc.toString());
|
|
63
|
+
}),
|
|
64
|
+
]),
|
|
65
|
+
];
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import { cssLanguage } from "@codemirror/lang-css";
|
|
2
|
+
import { htmlLanguage } from "@codemirror/lang-html";
|
|
3
|
+
import { javascriptLanguage } from "@codemirror/lang-javascript";
|
|
4
|
+
import { jsonLanguage } from "@codemirror/lang-json";
|
|
5
|
+
import { yamlLanguage } from "@codemirror/lang-yaml";
|
|
6
|
+
import type { TagStyle } from "@codemirror/language";
|
|
7
|
+
import { HighlightStyle, syntaxHighlighting } from "@codemirror/language";
|
|
8
|
+
import type { Extension } from "@codemirror/state";
|
|
9
|
+
import { tags } from "@lezer/highlight";
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Token classes for the code inside a fence and for the frontmatter's yaml.
|
|
13
|
+
* **Every style is scoped to a nested language**: `@lezer/markdown` tags its
|
|
14
|
+
* own nodes too (`heading1`, `strong`), and an unscoped style would paint prose
|
|
15
|
+
* a second time over the `md-*` classes. Classes, not colours: the palette is
|
|
16
|
+
* in `markdown-editor.css`, switched with the theme.
|
|
17
|
+
*
|
|
18
|
+
* Coarse on purpose — a parent tag catches its children, so a language emitting
|
|
19
|
+
* a tag nobody listed still lands somewhere sensible. Ported from
|
|
20
|
+
* vscode-extension-markdown-beautiful-editor (`src/webview/cm/codeHighlight.ts`).
|
|
21
|
+
*/
|
|
22
|
+
const codeTokenStyles: TagStyle[] = [
|
|
23
|
+
{ tag: tags.comment, class: "tok-comment" },
|
|
24
|
+
{ tag: tags.keyword, class: "tok-keyword" },
|
|
25
|
+
{ tag: tags.string, class: "tok-string" },
|
|
26
|
+
{
|
|
27
|
+
tag: [
|
|
28
|
+
tags.number,
|
|
29
|
+
tags.bool,
|
|
30
|
+
tags.null,
|
|
31
|
+
tags.atom,
|
|
32
|
+
tags.unit,
|
|
33
|
+
tags.color,
|
|
34
|
+
tags.url,
|
|
35
|
+
tags.regexp,
|
|
36
|
+
tags.escape,
|
|
37
|
+
],
|
|
38
|
+
class: "tok-constant",
|
|
39
|
+
},
|
|
40
|
+
{ tag: tags.propertyName, class: "tok-property" },
|
|
41
|
+
{
|
|
42
|
+
tag: [tags.typeName, tags.className, tags.namespace, tags.labelName],
|
|
43
|
+
class: "tok-type",
|
|
44
|
+
},
|
|
45
|
+
{
|
|
46
|
+
tag: [
|
|
47
|
+
tags.function(tags.variableName),
|
|
48
|
+
tags.function(tags.propertyName),
|
|
49
|
+
tags.macroName,
|
|
50
|
+
],
|
|
51
|
+
class: "tok-function",
|
|
52
|
+
},
|
|
53
|
+
{ tag: tags.operator, class: "tok-operator" },
|
|
54
|
+
{ tag: tags.invalid, class: "tok-invalid" },
|
|
55
|
+
];
|
|
56
|
+
|
|
57
|
+
const scopedLanguages = [
|
|
58
|
+
cssLanguage,
|
|
59
|
+
htmlLanguage,
|
|
60
|
+
javascriptLanguage,
|
|
61
|
+
jsonLanguage,
|
|
62
|
+
yamlLanguage,
|
|
63
|
+
];
|
|
64
|
+
|
|
65
|
+
export const codeHighlighters = scopedLanguages.map((language) =>
|
|
66
|
+
HighlightStyle.define(codeTokenStyles, { scope: language }),
|
|
67
|
+
);
|
|
68
|
+
|
|
69
|
+
export const codeHighlighting: Extension = codeHighlighters.map((highlighter) =>
|
|
70
|
+
syntaxHighlighting(highlighter),
|
|
71
|
+
);
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
import { syntaxTree } from "@codemirror/language";
|
|
2
|
+
import type {
|
|
3
|
+
EditorState,
|
|
4
|
+
SelectionRange,
|
|
5
|
+
StateCommand,
|
|
6
|
+
} from "@codemirror/state";
|
|
7
|
+
import { EditorSelection } from "@codemirror/state";
|
|
8
|
+
import type { KeyBinding } from "@codemirror/view";
|
|
9
|
+
import type { SyntaxNode } from "@lezer/common";
|
|
10
|
+
import type { InlineFormat, InlineFormatChange } from "./inlineFormat.ts";
|
|
11
|
+
import { inlineFormatChange } from "./inlineFormat.ts";
|
|
12
|
+
import type { LineType } from "./lineTypes.ts";
|
|
13
|
+
import { applyLinePrefix, stripLinePrefix } from "./lineTypes.ts";
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* The toolbar's and the shortcuts' commands: the pure transforms of
|
|
17
|
+
* `inlineFormat.ts` and `lineTypes.ts` applied to every selection range. Ported
|
|
18
|
+
* from vscode-extension-markdown-beautiful-editor (`src/webview/cm/commands/`).
|
|
19
|
+
*/
|
|
20
|
+
const formatOfNode: Readonly<Record<string, InlineFormat>> = {
|
|
21
|
+
StrongEmphasis: "bold",
|
|
22
|
+
Emphasis: "italic",
|
|
23
|
+
InlineCode: "code",
|
|
24
|
+
Strikethrough: "strikethrough",
|
|
25
|
+
Link: "link",
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* The construct of `format` enclosing `position`, delimiters included: with no
|
|
30
|
+
* selection, a toggle acts on the whole `**bold**` the cursor is in.
|
|
31
|
+
*/
|
|
32
|
+
export const enclosingFormatRange = (
|
|
33
|
+
state: EditorState,
|
|
34
|
+
position: number,
|
|
35
|
+
format: InlineFormat,
|
|
36
|
+
): { from: number; to: number } | null => {
|
|
37
|
+
for (
|
|
38
|
+
let node: SyntaxNode | null = syntaxTree(state).resolveInner(position, -1);
|
|
39
|
+
node;
|
|
40
|
+
node = node.parent
|
|
41
|
+
) {
|
|
42
|
+
if (formatOfNode[node.name] === format) {
|
|
43
|
+
return { from: node.from, to: node.to };
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
return null;
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* After a wrap the selected text sits verbatim inside the insert, so the same
|
|
51
|
+
* text stays selected; after an unwrap the whole insert is what remains.
|
|
52
|
+
*/
|
|
53
|
+
const selectionOverSameText = (
|
|
54
|
+
lineFrom: number,
|
|
55
|
+
selected: string,
|
|
56
|
+
change: InlineFormatChange,
|
|
57
|
+
): SelectionRange => {
|
|
58
|
+
const insertFrom = lineFrom + change.from;
|
|
59
|
+
const index =
|
|
60
|
+
change.insert.length > selected.length
|
|
61
|
+
? change.insert.indexOf(selected)
|
|
62
|
+
: -1;
|
|
63
|
+
return index === -1
|
|
64
|
+
? EditorSelection.range(insertFrom, insertFrom + change.insert.length)
|
|
65
|
+
: EditorSelection.range(
|
|
66
|
+
insertFrom + index,
|
|
67
|
+
insertFrom + index + selected.length,
|
|
68
|
+
);
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
interface RangeToggle {
|
|
72
|
+
changes: { from: number; to: number; insert: string };
|
|
73
|
+
range: SelectionRange;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const rangeToggle = (
|
|
77
|
+
state: EditorState,
|
|
78
|
+
range: SelectionRange,
|
|
79
|
+
format: InlineFormat,
|
|
80
|
+
): RangeToggle | null => {
|
|
81
|
+
const target = range.empty
|
|
82
|
+
? enclosingFormatRange(state, range.head, format)
|
|
83
|
+
: { from: range.from, to: range.to };
|
|
84
|
+
if (target === null) return null;
|
|
85
|
+
|
|
86
|
+
const line = state.doc.lineAt(target.from);
|
|
87
|
+
if (state.doc.lineAt(target.to).number !== line.number) return null;
|
|
88
|
+
|
|
89
|
+
const change = inlineFormatChange({
|
|
90
|
+
text: line.text,
|
|
91
|
+
from: target.from - line.from,
|
|
92
|
+
to: target.to - line.from,
|
|
93
|
+
format,
|
|
94
|
+
});
|
|
95
|
+
if (change === null) return null;
|
|
96
|
+
|
|
97
|
+
return {
|
|
98
|
+
changes: {
|
|
99
|
+
from: line.from + change.from,
|
|
100
|
+
to: line.from + change.to,
|
|
101
|
+
insert: change.insert,
|
|
102
|
+
},
|
|
103
|
+
range: range.empty
|
|
104
|
+
? EditorSelection.cursor(line.from + change.selection)
|
|
105
|
+
: selectionOverSameText(
|
|
106
|
+
line.from,
|
|
107
|
+
state.doc.sliceString(target.from, target.to),
|
|
108
|
+
change,
|
|
109
|
+
),
|
|
110
|
+
};
|
|
111
|
+
};
|
|
112
|
+
|
|
113
|
+
const overlaps = (
|
|
114
|
+
a: { from: number; to: number },
|
|
115
|
+
b: { from: number; to: number },
|
|
116
|
+
): boolean => a.from < b.to && b.from < a.to;
|
|
117
|
+
|
|
118
|
+
export const toggleInlineFormat =
|
|
119
|
+
(format: InlineFormat): StateCommand =>
|
|
120
|
+
({ state, dispatch }) => {
|
|
121
|
+
// two cursors in one construct resolve to the same range and would each
|
|
122
|
+
// rewrite it, doubling the markers: the first one wins
|
|
123
|
+
const applied: { from: number; to: number }[] = [];
|
|
124
|
+
const transaction = state.changeByRange((range) => {
|
|
125
|
+
const toggle = rangeToggle(state, range, format);
|
|
126
|
+
if (
|
|
127
|
+
toggle === null ||
|
|
128
|
+
applied.some((span) => overlaps(span, toggle.changes))
|
|
129
|
+
) {
|
|
130
|
+
return { range };
|
|
131
|
+
}
|
|
132
|
+
applied.push(toggle.changes);
|
|
133
|
+
return toggle;
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
if (applied.length === 0) return false;
|
|
137
|
+
dispatch(state.update(transaction, { scrollIntoView: true }));
|
|
138
|
+
return true;
|
|
139
|
+
};
|
|
140
|
+
|
|
141
|
+
/** the line each cursor's head is on, rewritten once even under two cursors */
|
|
142
|
+
export const setLineType =
|
|
143
|
+
(type: LineType): StateCommand =>
|
|
144
|
+
({ state, dispatch }) => {
|
|
145
|
+
const rewritten = new Set<number>();
|
|
146
|
+
const transaction = state.changeByRange((range) => {
|
|
147
|
+
const line = state.doc.lineAt(range.head);
|
|
148
|
+
if (rewritten.has(line.from)) {
|
|
149
|
+
return { range: EditorSelection.cursor(line.from) };
|
|
150
|
+
}
|
|
151
|
+
const insert = applyLinePrefix(stripLinePrefix(line.text), type);
|
|
152
|
+
if (insert === line.text) return { range };
|
|
153
|
+
rewritten.add(line.from);
|
|
154
|
+
return {
|
|
155
|
+
changes: { from: line.from, to: line.to, insert },
|
|
156
|
+
range: EditorSelection.cursor(line.from),
|
|
157
|
+
};
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
if (rewritten.size === 0) return false;
|
|
161
|
+
dispatch(state.update(transaction, { scrollIntoView: true }));
|
|
162
|
+
return true;
|
|
163
|
+
};
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* Mod-i is `selectParentSyntax` in the default keymap, so this keymap is
|
|
167
|
+
* installed above it (see `extensions.ts`).
|
|
168
|
+
*/
|
|
169
|
+
export const formattingKeymap: KeyBinding[] = [
|
|
170
|
+
{ key: "Mod-b", run: toggleInlineFormat("bold") },
|
|
171
|
+
{ key: "Mod-i", run: toggleInlineFormat("italic") },
|
|
172
|
+
{ key: "Mod-e", run: toggleInlineFormat("code") },
|
|
173
|
+
{ key: "Mod-k", run: toggleInlineFormat("link") },
|
|
174
|
+
{ key: "Mod-Shift-x", run: toggleInlineFormat("strikethrough") },
|
|
175
|
+
];
|