Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add a new lineComments extension for CodeMirror #2549

Draft
wants to merge 10 commits into
base: main
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions app/components/code-mirror.hbs
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@
{{did-update this.optionDidChange "indentWithTab" @indentWithTab}}
{{did-update this.optionDidChange "languageOrFilename" @filename}}
{{did-update this.optionDidChange "languageOrFilename" @language}}
{{did-update this.optionDidChange "lineCommentsOrLineData" (array @lineComments @lineData)}}
{{did-update this.optionDidChange "lineNumbers" @lineNumbers}}
{{did-update this.optionDidChange "lineSeparator" @lineSeparator}}
{{did-update this.optionDidChange "lineWrapping" @lineWrapping}}
Expand Down
12 changes: 11 additions & 1 deletion app/components/code-mirror.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ import { markdown } from '@codemirror/lang-markdown';
import { highlightNewlines } from 'codecrafters-frontend/utils/code-mirror-highlight-newlines';
import { collapseUnchangedGutter } from 'codecrafters-frontend/utils/code-mirror-collapse-unchanged-gutter';
import { highlightActiveLineGutter as highlightActiveLineGutterRS } from 'codecrafters-frontend/utils/code-mirror-gutter-rs';
import { lineComments, type LineDataCollection } from 'codecrafters-frontend/utils/code-mirror-line-comments';

function generateHTMLElement(src: string): HTMLElement {
const div = document.createElement('div');
Expand All @@ -54,7 +55,7 @@ enum FoldGutterIcon {

type DocumentUpdateCallback = (newValue: string) => void;

type Argument = boolean | string | number | undefined | Extension | DocumentUpdateCallback;
type Argument = boolean | string | number | undefined | Extension | DocumentUpdateCallback | LineDataCollection;

type OptionHandler = (args: Signature['Args']['Named']) => Extension[] | Promise<Extension[]>;

Expand All @@ -78,6 +79,7 @@ const OPTION_HANDLERS: { [key: string]: OptionHandler } = {
indentOnInput: ({ indentOnInput: enabled }) => (enabled ? [indentOnInput()] : []),
indentUnit: ({ indentUnit: indentUnitText }) => (indentUnitText !== undefined ? [indentUnit.of(indentUnitText)] : []),
indentWithTab: ({ indentWithTab: enabled }) => (enabled ? [keymap.of([indentWithTab])] : []),
lineCommentsOrLineData: ({ lineData, lineComments: enabled }) => (enabled && lineData ? [lineComments(lineData)] : []),
lineNumbers: ({ lineNumbers: enabled }) => (enabled ? [lineNumbers()] : []),
foldGutter: ({ foldGutter: enabled }) =>
enabled
Expand Down Expand Up @@ -266,6 +268,14 @@ export interface Signature {
* Enable indentation of lines or selection using TAB and Shift+TAB keys, otherwise editor loses focus when TAB is pressed
*/
indentWithTab?: boolean;
/**
* Enable line comments
*/
lineComments?: boolean;
/**
* Line data containing comments counts or other line-related metadata
*/
lineData?: LineDataCollection;
/**
* Enable the line numbers gutter
*/
Expand Down
37 changes: 37 additions & 0 deletions app/controllers/demo/code-mirror.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,36 @@ import { type Extension } from '@codemirror/state';
import type DarkModeService from 'codecrafters-frontend/services/dark-mode';
import { codeCraftersDark, codeCraftersLight } from 'codecrafters-frontend/utils/code-mirror-themes';
import EXAMPLE_DOCUMENTS, { ExampleDocument } from 'codecrafters-frontend/utils/code-mirror-documents';
import { LineData, LineDataCollection } from 'codecrafters-frontend/utils/code-mirror-line-comments';

function generateRandomLineData(linesCount = 0) {
function getRandomInt(inclusiveMin: number, exclusiveMax: number) {
const minCeiled = Math.ceil(inclusiveMin);
const maxFloored = Math.floor(exclusiveMax);

return Math.floor(Math.random() * (maxFloored - minCeiled) + minCeiled);
}

const lineData = Array.from({ length: linesCount }).map(function (_v, lineNumber) {
const rnd = Math.random();

let commentsCount;

if (rnd < 0.05) {
commentsCount = getRandomInt(100, 1000);
} else if (rnd < 0.1) {
commentsCount = getRandomInt(10, 100);
} else if (rnd < 0.8) {
commentsCount = 0;
} else {
commentsCount = getRandomInt(1, 10);
}

return new LineData({ commentsCount, lineNumber });
});

return new LineDataCollection(lineData);
}

const THEME_EXTENSIONS: {
[key: string]: Extension;
Expand Down Expand Up @@ -54,6 +84,7 @@ const OPTION_DEFAULTS = {
indentWithTab: true,
language: true,
lineNumbers: true,
lineComments: false,
lineSeparator: true,
lineWrapping: true,
maxHeight: true,
Expand Down Expand Up @@ -108,6 +139,7 @@ export default class DemoCodeMirrorController extends Controller {
'indentUnit',
'indentWithTab',
'language',
'lineComments',
'lineNumbers',
'lineSeparator',
'lineWrapping',
Expand Down Expand Up @@ -160,6 +192,7 @@ export default class DemoCodeMirrorController extends Controller {
@tracked indentUnits = INDENT_UNITS;
@tracked indentWithTab = OPTION_DEFAULTS.indentWithTab;
@tracked language = OPTION_DEFAULTS.language;
@tracked lineComments = OPTION_DEFAULTS.lineComments;
@tracked lineNumbers = OPTION_DEFAULTS.lineNumbers;
@tracked lineSeparator = OPTION_DEFAULTS.lineSeparator;
@tracked lineSeparators = LINE_SEPARATORS;
Expand Down Expand Up @@ -197,6 +230,10 @@ export default class DemoCodeMirrorController extends Controller {
return this.documents[this.selectedDocumentIndex] || ExampleDocument.createEmpty();
}

get selectedDocumentLineData() {
return generateRandomLineData(this.selectedDocument.document.split(/$/gm).length);
}

get selectedIndentUnit() {
return this.indentUnits[this.selectedIndentUnitIndex];
}
Expand Down
1 change: 1 addition & 0 deletions app/routes/demo/code-mirror.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ const QUERY_PARAMS = [
'indentUnit',
'indentWithTab',
'language',
'lineComments',
'lineNumbers',
'lineSeparator',
'lineWrapping',
Expand Down
10 changes: 8 additions & 2 deletions app/templates/demo/code-mirror.hbs
Original file line number Diff line number Diff line change
Expand Up @@ -167,6 +167,12 @@
<Input @type="checkbox" @checked={{this.foldGutter}} />
<span class="ml-2">foldGutter</span>
</label>
<label class="{{labelClasses}}" title="Enable line comments">
<Input @type="checkbox" @checked={{this.lineComments}} />
<span class="ml-2">lineComments</span>
</label>
</codemirror-options-left>
<codemirror-options-right class="flex flex-wrap">
<label class="{{labelClasses}}" title="Enable visual line wrapping for lines exceeding editor width">
<Input @type="checkbox" @checked={{this.lineWrapping}} />
<span class="ml-2">lineWrapping</span>
Expand All @@ -175,8 +181,6 @@
<Input @type="checkbox" @checked={{this.scrollPastEnd}} />
<span class="ml-2">scrollPastEnd</span>
</label>
</codemirror-options-left>
<codemirror-options-right class="flex flex-wrap">
<label class="{{labelClasses}}" title="Limit maximum height of the component's element">
<Input @type="checkbox" @checked={{this.maxHeight}} />
<span class="ml-2">maxHeight</span>
Expand Down Expand Up @@ -340,6 +344,8 @@
@history={{this.history}}
@indentOnInput={{this.indentOnInput}}
@indentWithTab={{this.indentWithTab}}
@lineComments={{this.lineComments}}
@lineData={{if this.lineComments this.selectedDocumentLineData}}
@lineNumbers={{this.lineNumbers}}
@lineWrapping={{this.lineWrapping}}
@mergeControls={{this.mergeControls}}
Expand Down
54 changes: 54 additions & 0 deletions app/utils/code-mirror-line-comments-expanded-plugin.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
import { Decoration, EditorView, ViewPlugin, ViewUpdate, type DecorationSet } from '@codemirror/view';
import { RangeSetBuilder, StateEffect } from '@codemirror/state';
import { expandedLineNumbersCompartment, expandedLineNumbersFacet } from 'codecrafters-frontend/utils/code-mirror-line-comments';

const lineCommentsExpandedDecoration = Decoration.line({
attributes: { class: 'cm-lineCommentsExpanded' },
});

function lineCommentsExpandedDecorations(view: EditorView) {
const expandedLines = view.state.facet(expandedLineNumbersFacet)[0] || [];
const builder = new RangeSetBuilder<Decoration>();

for (const { from, to } of view.visibleRanges) {
for (let pos = from; pos <= to; ) {
const line = view.state.doc.lineAt(pos);

if (expandedLines.includes(line.number)) {
builder.add(line.from, line.from, lineCommentsExpandedDecoration);
}

pos = line.to + 1;
}
}

return builder.finish();
}

export function lineCommentsExpandedPlugin() {
return ViewPlugin.fromClass(
class {
decorations: DecorationSet;

constructor(view: EditorView) {
this.decorations = lineCommentsExpandedDecorations(view);
}

update(update: ViewUpdate) {
if (update.transactions) {
for (const tr of update.transactions) {
for (const effect of tr.effects) {
if (effect instanceof StateEffect && effect.value?.compartment === expandedLineNumbersCompartment) {
this.decorations = lineCommentsExpandedDecorations(update.view);
break;
}
}
}
}
}
},
{
decorations: (v) => v.decorations,
},
);
}
118 changes: 118 additions & 0 deletions app/utils/code-mirror-line-comments-gutter.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
import { BlockInfo, EditorView } from '@codemirror/view';
import { gutter as gutterRS, GutterMarker as GutterMarkerRS } from 'codecrafters-frontend/utils/code-mirror-gutter-rs';
import { expandedLineNumbersCompartment, expandedLineNumbersFacet, lineDataFacet } from 'codecrafters-frontend/utils/code-mirror-line-comments';

class CommentsCountGutterMarker extends GutterMarkerRS {
line: BlockInfo;

constructor(line: BlockInfo) {
super();
this.line = line;
}

toDOM(view: EditorView) {
const lineNumber = view.state.doc.lineAt(this.line.from).number;
const commentsCount = view.state.facet(lineDataFacet)[0]?.dataForLine(lineNumber)?.commentsCount || 0;
const elem = document.createElement('div');
const classNames = ['cm-commentsCount'];

if (commentsCount > 99) {
classNames.push('cm-commentsCountOver99');
}

elem.className = classNames.join(' ');
elem.innerText = `${commentsCount > 99 ? '99+' : commentsCount}`;

return elem;
}
}

class CommentButtonGutterMarker extends GutterMarkerRS {
line: BlockInfo;

constructor(line: BlockInfo) {
super();
this.line = line;
}

toDOM() {
const elem = document.createElement('div');
elem.className = 'cm-commentButton';

elem.innerText = `💬`;

return elem;
}
}

function lineCommentsGutterLineMarker(view: EditorView, line: BlockInfo) {
const lineNumber = view.state.doc.lineAt(line.from).number;
const commentsCount = view.state.facet(lineDataFacet)[0]?.dataForLine(lineNumber)?.commentsCount || 0;

return new (commentsCount === 0 ? CommentButtonGutterMarker : CommentsCountGutterMarker)(line);
}

function lineCommentsGutterClickHandler(view: EditorView, line: BlockInfo) {
const lineNumber = view.state.doc.lineAt(line.from).number;
const expandedLines = view.state.facet(expandedLineNumbersFacet)[0] || [];
const newExpandedLines = expandedLines.includes(lineNumber) ? expandedLines.without(lineNumber) : [...expandedLines, lineNumber];

view.dispatch({
effects: [expandedLineNumbersCompartment.reconfigure(expandedLineNumbersFacet.of(newExpandedLines))],
});

return true;
}

const lineCommentsGutterBaseTheme = EditorView.baseTheme({
'.cm-lineCommentsGutter': {
minWidth: '24px',
textAlign: 'center',
userSelect: 'none',

'& .cm-gutterElement': {
cursor: 'pointer',

'& .cm-commentButton': {
opacity: '0.15',
},

'& .cm-commentsCount': {
display: 'block',
backgroundColor: '#ffcd72c0',
borderRadius: '50%',
color: '#24292e',
transform: 'scale(0.75)',
fontWeight: '500',
fontSize: '12px',

'&.cm-commentsCountOver99': {
fontSize: '9.5px',
},
},

'&:hover': {
'& .cm-commentButton': {
opacity: '1',
},

'& .cm-commentsCount': {
backgroundColor: '#ffa500',
},
},
},
},
});

export function lineCommentsGutter() {
return [
gutterRS({
class: 'cm-lineCommentsGutter',
lineMarker: lineCommentsGutterLineMarker,
domEventHandlers: {
click: lineCommentsGutterClickHandler,
},
}),
lineCommentsGutterBaseTheme,
];
}
Loading
Loading