@reviewflow/ui 0.0.0-stage → 6.22.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.
Files changed (92) hide show
  1. package/LICENSE +15 -0
  2. package/README.md +95 -2
  3. package/package.json +101 -3
  4. package/src/board/bucket-section.tsx +94 -0
  5. package/src/board/dataSource.ts +44 -0
  6. package/src/board/facts.ts +183 -0
  7. package/src/board/index.ts +12 -0
  8. package/src/board/pull-request-board.tsx +239 -0
  9. package/src/board/pull-request-row.tsx +181 -0
  10. package/src/components/branch-diff/diff-themes.ts +6 -0
  11. package/src/components/branch-diff/diff-worker-pool.tsx +12 -0
  12. package/src/components/branch-diff/diff-worker-pool.web.tsx +56 -0
  13. package/src/components/branch-diff/diff-worker.ts +7 -0
  14. package/src/components/branch-diff/use-collapsed-files.ts +49 -0
  15. package/src/components/branch-diff/use-diff-settings.tsx +97 -0
  16. package/src/components/branch-diff/whitespace-button.tsx +32 -0
  17. package/src/components/copyable-code-block.tsx +101 -0
  18. package/src/components/label-pill.tsx +84 -0
  19. package/src/components/link-in-row.ts +6 -0
  20. package/src/components/markdown-suggestion.tsx +60 -0
  21. package/src/components/markdown-text.tsx +1067 -0
  22. package/src/components/pull-request-actions-merge.tsx +341 -0
  23. package/src/components/pull-request-actions-review.tsx +218 -0
  24. package/src/components/pull-request-checks.tsx +86 -0
  25. package/src/components/pull-request-commit-files.ts +143 -0
  26. package/src/components/pull-request-commits.tsx +221 -0
  27. package/src/components/pull-request-conversation-card.tsx +220 -0
  28. package/src/components/pull-request-conversation-edit.tsx +147 -0
  29. package/src/components/pull-request-conversation-event.tsx +267 -0
  30. package/src/components/pull-request-conversation-person.tsx +28 -0
  31. package/src/components/pull-request-conversation-thread.tsx +158 -0
  32. package/src/components/pull-request-diff-annotations.tsx +443 -0
  33. package/src/components/pull-request-diff-collapse.ts +43 -0
  34. package/src/components/pull-request-diff-data.ts +174 -0
  35. package/src/components/pull-request-diff-impacts-summary.tsx +97 -0
  36. package/src/components/pull-request-diff-impacts.ts +45 -0
  37. package/src/components/pull-request-diff-jump.ts +87 -0
  38. package/src/components/pull-request-diff-lines.ts +103 -0
  39. package/src/components/pull-request-diff-overview.tsx +416 -0
  40. package/src/components/pull-request-diff-view.tsx +438 -0
  41. package/src/components/pull-request-diff-viewer.tsx +1374 -0
  42. package/src/components/pull-request-files.tsx +533 -0
  43. package/src/components/pull-request-markdown-input.tsx +91 -0
  44. package/src/components/pull-request-panel.tsx +1270 -0
  45. package/src/components/pull-request-part-notice.tsx +82 -0
  46. package/src/components/pull-request-reactions.tsx +178 -0
  47. package/src/components/pull-request-review.tsx +164 -0
  48. package/src/components/pull-request-revisions.tsx +273 -0
  49. package/src/components/pull-request-screen.tsx +299 -0
  50. package/src/components/pull-request-suggestion-actions.tsx +201 -0
  51. package/src/components/pull-request-timeline.tsx +270 -0
  52. package/src/components/repository-attachment-banner.tsx +42 -0
  53. package/src/components/scroll-parent.ts +50 -0
  54. package/src/css.d.ts +1 -0
  55. package/src/dataSource.ts +242 -0
  56. package/src/editor/alerts.ts +105 -0
  57. package/src/editor/code-editor.native.tsx +25 -0
  58. package/src/editor/code-editor.tsx +112 -0
  59. package/src/editor/codeEditorExtensions.ts +65 -0
  60. package/src/editor/codeHighlight.ts +71 -0
  61. package/src/editor/commands.ts +175 -0
  62. package/src/editor/decorations.ts +425 -0
  63. package/src/editor/editable-file.tsx +224 -0
  64. package/src/editor/emojiCompletion.ts +110 -0
  65. package/src/editor/extensions.ts +149 -0
  66. package/src/editor/frontmatter.ts +76 -0
  67. package/src/editor/inlineFormat.ts +114 -0
  68. package/src/editor/lineTypes.ts +55 -0
  69. package/src/editor/markdown-editor.css +511 -0
  70. package/src/editor/markdown-editor.native.tsx +29 -0
  71. package/src/editor/markdown-editor.tsx +295 -0
  72. package/src/editor/nodeClasses.ts +69 -0
  73. package/src/index.ts +75 -0
  74. package/src/markdown-github/flatten.ts +49 -0
  75. package/src/markdown-github/html-tokens.ts +93 -0
  76. package/src/markdown-github/pair-html.ts +354 -0
  77. package/src/markdown-github/safe-url.ts +15 -0
  78. package/src/markdown-github/table-columns.ts +23 -0
  79. package/src/markdown-github/toggle-task.ts +164 -0
  80. package/src/nativewind-env.d.ts +1 -0
  81. package/src/prStatus.ts +90 -0
  82. package/src/services/PullRequestDraftsProvider.tsx +179 -0
  83. package/src/services/PullRequestReviewDraftProvider.tsx +178 -0
  84. package/src/services/PullRequestSuggestionBatchProvider.tsx +112 -0
  85. package/src/services/emojify.ts +13 -0
  86. package/src/services/errorToMessage.ts +6 -0
  87. package/src/services/expiringDraftStore.ts +102 -0
  88. package/src/services/formatDataAge.ts +12 -0
  89. package/src/services/formatRelativeDate.ts +56 -0
  90. package/src/services/githubImages.tsx +33 -0
  91. package/src/services/isViewerAuthor.ts +7 -0
  92. 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
+ ];